diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index d25b56566..17f01a2c7 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -118,6 +118,74 @@ impl Item { } } +impl SyncRow for Item { + fn label(&self) -> &str { + &self.label + } + + fn show_label(&mut self, label: String) { + match &self.handle { + Handle::Plain(h) => h.set_text(label.replace('&', "&&")), + Handle::Check(h) => h.set_text(label.replace('&', "&&")), + } + self.label = label; + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn show_enabled(&mut self, enabled: bool) { + match &self.handle { + Handle::Plain(h) => h.set_enabled(enabled), + Handle::Check(h) => h.set_enabled(enabled), + } + self.enabled = enabled; + } + + fn show_checked(&mut self, checked: bool) { + if let Handle::Check(h) = &self.handle { + // also resyncs after AppKit toggled it on click + if h.is_checked() != checked { + h.set_checked(checked); + } + self.checked = Some(checked); + } + } +} + +/// What the per-frame sync needs of a native row, so it can be run on stand-ins without a +/// main-thread `muda::Menu` (as [`SwapTarget`] does for the swap). +trait SyncRow { + fn label(&self) -> &str; + fn show_label(&mut self, label: String); + fn enabled(&self) -> bool; + fn show_enabled(&mut self, enabled: bool); + /// Only a row that is a check item takes it. + fn show_checked(&mut self, checked: bool); +} + +/// Puts each model row's live label, enabled and checked state on its own native row, found by +/// the row's native id (two rows may run the same command and params). +fn sync_rows(nodes: &[MenuNode], items: &mut HashMap, keymap: &lightcraft_ui_egui::shortcuts::Keymap) { + let mut rows = Vec::new(); + native_rows(nodes, &mut RowIds::default(), &mut rows); + for (native_id, node) in rows { + let MenuNode::Item { id, params, label, shortcut, enabled, checked } = node else { continue }; + let Some(it) = items.get_mut(&native_id) else { continue }; + let (native_label, _) = native_item_presentation(keymap, id, params, label, shortcut.as_deref()); + if it.label() != native_label { + it.show_label(native_label); + } + if it.enabled() != *enabled { + it.show_enabled(*enabled); + } + if let Some(c) = checked { + it.show_checked(*c); + } + } +} + /// The accelerator the native item has after trying to put `want` on it: `want` when the native /// call worked, else what it had, so the caller sees the mismatch and tries again. fn settled(had: Option, want: Option, native_call_worked: bool) -> Option { @@ -311,6 +379,50 @@ fn tidy_separators(nodes: Vec) -> Vec { out } +/// The model's rows of one menu as the native menu installs them: About, Settings and Quit live in +/// the app menu on macOS. +fn install_nodes(nodes: &[MenuNode]) -> Vec { + let kept = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS || id == QUIT)); + tidy_separators(kept.cloned().collect()) +} + +/// Native (muda) ids, one per menu row. muda ids must be unique, but the model may hold the same +/// command and params twice (`dialog.export` is File ▸ Export… and Export with Preset ▸ Custom…; +/// `dialog.newAlbum` is in File and in Photo ▸ Add to Album), so a repeated key gets an occurrence +/// suffix. The first row keeps the plain [`MenuNode::key`]. Rows are numbered in model order, so +/// the install and the per-frame sync, which both walk the model in that order, agree. +#[derive(Default)] +struct RowIds { + seen: HashMap, +} + +impl RowIds { + /// The row's native id, and whether an earlier row already has the same command and params. + fn next(&mut self, id: &str, params: &Value) -> (String, bool) { + let key = MenuNode::key(id, params); + let n = self.seen.entry(key.clone()).or_insert(0); + *n += 1; + if *n == 1 { (key, false) } else { (format!("{key}#{n}"), true) } + } +} + +/// The key a row is installed with. A repeated row has none: AppKit would list one key twice, and +/// the first row of the command keeps it (a click on either row runs the command). +fn row_accelerator(native_key: Option<&str>, repeated: bool) -> Option { + native_key.filter(|s| !repeated && !CONTEXTUAL.contains(s)).and_then(accelerator) +} + +/// Every item of the model with its native id, in model order. +fn native_rows<'a>(nodes: &'a [MenuNode], ids: &mut RowIds, out: &mut Vec<(String, &'a MenuNode)>) { + for n in nodes { + match n { + MenuNode::Item { id, params, .. } => out.push((ids.next(id, params).0, n)), + MenuNode::Submenu { children, .. } => native_rows(children, ids, out), + MenuNode::Separator => {} + } + } +} + impl NativeMenu { /// Build the menu bar and install it as the application menu (main thread, after launch). pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu { @@ -379,6 +491,8 @@ impl NativeMenu { } let _ = self.menu.append(&app_menu); + let mut ids = RowIds::default(); + let mut help_menu = None; for (title, nodes) in &bar { let sub = Submenu::new(label_text(title), true); if title == "Window" { @@ -388,16 +502,10 @@ impl NativeMenu { &PredefinedMenuItem::separator(), ]); } - // About and Settings live in the app menu on macOS - let nodes: Vec = nodes - .iter() - .filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS || id == QUIT)) - .cloned() - .collect(); - let nodes = tidy_separators(nodes); + let nodes = install_nodes(nodes); // Swapping an item in the Help menu leaves the old row in place and adds a second one // (measured on macOS 27 with muda 0.21): the Help menu is not addressed row for row. - self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help"); + self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help", &mut ids); if title == "File" { // ⌘W, the system's own item let _ = sub.append_items(&[ @@ -413,11 +521,16 @@ impl NativeMenu { sub.set_as_windows_menu_for_nsapp(); } if title == "Help" { - sub.set_as_help_menu_for_nsapp(); + help_menu = Some(sub.clone()); } let _ = self.menu.append(&sub); } self.menu.init_for_nsapp(); + // muda finds the submenu through the main menu installed just above; asked earlier it does + // nothing, and AppKit never learns which menu is the Help menu + if let Some(help) = help_menu { + help.set_as_help_menu_for_nsapp(); + } self.text_focus = false; self.capturing = false; self.accelerators_pending = false; @@ -426,7 +539,7 @@ impl NativeMenu { /// Install translated rows with their effective accelerators, preserving hint-only keys. /// `swappable`: whether its items may be replaced to drop their key (see `Item::swappable`). - fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool) { + fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool, ids: &mut RowIds) { for n in nodes { match n { MenuNode::Separator => { @@ -434,16 +547,13 @@ impl NativeMenu { } MenuNode::Submenu { label, children } => { let s = Submenu::new(label_text(label), true); - self.append_nodes(&s, children, keymap, swappable); + self.append_nodes(&s, children, keymap, swappable, ids); let _ = sub.append(&s); } MenuNode::Item { id, params, label, shortcut, enabled, checked } => { - let key = MenuNode::key(id, params); - if self.items.contains_key(&key) { - continue; // muda ids must be unique - } + let (key, repeated) = ids.next(id, params); let (native_label, native_key) = native_item_presentation(keymap, id, params, label, shortcut.as_deref()); - let accel = native_key.filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator); + let accel = row_accelerator(native_key, repeated); let handle = match checked { Some(c) => { let it = CheckMenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, *c, accel); @@ -497,9 +607,7 @@ impl NativeMenu { // events never reach `shortcuts::handle`, so this is the one guard for AppKit's // accelerators and clicks: while the blocking library window or a quit/settings notice // owns input, a queued command is dropped (Quit stays for the existing confirmation). - let (cmd, params) = if key == QUIT { - (key, serde_json::json!({})) - } else if key == "app.about" || key == SETTINGS { + let (cmd, params) = if key == QUIT || key == "app.about" || key == SETTINGS { (key, serde_json::json!({})) } else if let Some(it) = self.items.get(&key) { (it.cmd.clone(), it.params.clone()) @@ -516,41 +624,10 @@ impl NativeMenu { // to, so the sync below runs in this same frame and no key reaches the menu in between) self.rebuild(app); } - /// Synchronize live labels and states; accelerator ownership changes already triggered a rebuild. - fn walk(nodes: &[MenuNode], items: &mut HashMap, keymap: &lightcraft_ui_egui::shortcuts::Keymap) { - for n in nodes { - match n { - MenuNode::Submenu { children, .. } => walk(children, items, keymap), - MenuNode::Item { id, params, label, shortcut, enabled, checked } => { - let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue }; - let (native_label, _) = native_item_presentation(keymap, id, params, label, shortcut.as_deref()); - if it.label != native_label { - match &it.handle { - Handle::Plain(h) => h.set_text(native_label.replace('&', "&&")), - Handle::Check(h) => h.set_text(native_label.replace('&', "&&")), - } - it.label = native_label; - } - if it.enabled != *enabled { - match &it.handle { - Handle::Plain(h) => h.set_enabled(*enabled), - Handle::Check(h) => h.set_enabled(*enabled), - } - it.enabled = *enabled; - } - if let (Handle::Check(h), Some(c)) = (&it.handle, checked) { - // also resyncs after AppKit toggled it on click - if h.is_checked() != *c { - h.set_checked(*c); - } - it.checked = Some(*c); - } - } - MenuNode::Separator => {} - } - } - } - walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items, &app.ui.settings.keymap); + // Synchronize live labels and states row by row (accelerator ownership changes already + // triggered a rebuild). + let all: Vec = bar.into_iter().flat_map(|(_, v)| v).collect(); + sync_rows(&all, &mut self.items, &app.ui.settings.keymap); let focus = ctx.egui_wants_keyboard_input(); let capturing = app.recording_shortcut.is_some(); @@ -613,6 +690,134 @@ mod tests { set_language(Locale::En); } + /// Every row of the model gets its own native row and keeps its own label, and a click on a + /// native id runs that row's command (two rows may share a command and params). + #[test] + fn rows_sharing_a_command_get_their_own_native_ids_and_labels() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let mut rows = Vec::new(); + native_rows(&all, &mut RowIds::default(), &mut rows); + fn count(n: &[MenuNode]) -> usize { + n.iter() + .map(|x| match x { + MenuNode::Item { .. } => 1, + MenuNode::Submenu { children, .. } => count(children), + MenuNode::Separator => 0, + }) + .sum() + } + assert_eq!(rows.len(), count(&all)); + let ids: HashSet<&str> = rows.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids.len(), rows.len(), "a native id per row"); + // The install walks each menu as `install_nodes` leaves it, the per-frame sync walks the whole + // bar: a row the menu has must get the same id, so the same node (label, command, params), + // from both, and a click on its id runs that row's command. + let mut installed = Vec::new(); + let mut install_ids = RowIds::default(); + let menus: Vec> = bar.iter().map(|(_, nodes)| install_nodes(nodes)).collect(); + for nodes in &menus { + native_rows(nodes, &mut install_ids, &mut installed); + } + let synced: HashMap<&str, &MenuNode> = rows.iter().map(|(id, n)| (id.as_str(), *n)).collect(); + for (nid, n) in &installed { + assert_eq!(synced.get(nid.as_str()), Some(n), "{nid}"); + } + for (cmd, labels) in [("dialog.export", ["Export…", "Custom…"]), ("dialog.newAlbum", ["New Album…", "New Album…"])] { + let own: Vec<(&str, &str)> = installed + .iter() + .filter_map(|(nid, n)| match n { + MenuNode::Item { id, label, params, .. } if id == cmd && params.is_null() => Some((nid.as_str(), label.as_str())), + _ => None, + }) + .collect(); + assert_eq!(own.len(), 2, "{cmd} is in two menus"); + assert_ne!(own[0].0, own[1].0, "{cmd}: one native id per row"); + assert_eq!([own[0].1, own[1].1], labels, "{cmd}: each row keeps its label"); + } + // A repeated row is installed without its key (AppKit would list one key twice). + assert_eq!(row_accelerator(Some("G"), false), accelerator("G")); + assert_eq!(row_accelerator(Some("G"), true), None); + let mut ids = RowIds::default(); + assert_eq!(ids.next("view.layoutImage", &Value::Null), ("view.layoutImage".to_string(), false)); + assert_eq!(ids.next("view.layoutImage", &Value::Null), ("view.layoutImage#2".to_string(), true)); + } + + /// A native row's shown state, to run the per-frame sync without a main-thread `muda::Menu`. + #[derive(Debug, PartialEq)] + struct FakeRow { + label: String, + enabled: bool, + checked: Option, + } + impl SyncRow for FakeRow { + fn label(&self) -> &str { + &self.label + } + fn show_label(&mut self, label: String) { + self.label = label; + } + fn enabled(&self) -> bool { + self.enabled + } + fn show_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + fn show_checked(&mut self, checked: bool) { + self.checked = Some(checked); + } + } + + /// Two rows with the same command and params keep their own label, enabled and checked state + /// when the model changes (the sync once wrote the last row's state onto the first). + #[test] + fn the_sync_updates_rows_sharing_a_command_independently() { + use lightcraft_ui_egui::i18n::{Locale, set_language}; + set_language(Locale::En); + let keymap = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()).ui.settings.keymap.clone(); + let item = |cmd: &str, label: &str, enabled: bool, checked: Option| MenuNode::Item { + id: cmd.into(), + params: Value::Null, + label: label.into(), + shortcut: None, + enabled, + checked, + }; + let model = |a: (&str, bool, Option), b: (&str, bool, Option), c: (&str, bool, Option), d: (&str, bool, Option)| { + vec![ + item("dialog.export", a.0, a.1, a.2), + MenuNode::Separator, + MenuNode::Submenu { label: "Export with Preset".into(), children: vec![item("dialog.export", b.0, b.1, b.2)] }, + item("view.toggle", c.0, c.1, c.2), + MenuNode::Submenu { label: "More".into(), children: vec![item("view.toggle", d.0, d.1, d.2)] }, + ] + }; + // what the native rows show after the install + let installed = model(("Export…", true, None), ("Custom…", true, None), ("Show", true, Some(false)), ("Show", true, Some(false))); + let mut rows = Vec::new(); + native_rows(&installed, &mut RowIds::default(), &mut rows); + let mut items: HashMap = rows + .iter() + .filter_map(|(nid, n)| match n { + MenuNode::Item { label, enabled, checked, .. } => { + Some((nid.clone(), FakeRow { label: label.clone(), enabled: *enabled, checked: *checked })) + } + _ => None, + }) + .collect(); + assert_eq!(items.len(), 4, "a native row for each of the four rows"); + // the model changes: each row of a pair gets a different state + let live = model(("Export…", false, None), ("Custom…", true, None), ("Show", true, Some(true)), ("Hide", false, Some(false))); + sync_rows(&live, &mut items, &keymap); + let shown: Vec<&FakeRow> = rows.iter().map(|(nid, _)| &items[nid]).collect(); + let row = |label: &str, enabled: bool, checked: Option| FakeRow { label: label.into(), enabled, checked }; + assert_eq!(shown[0], &row("Export…", false, None), "File ▸ Export… keeps its label and is disabled alone"); + assert_eq!(shown[1], &row("Custom…", true, None), "Custom… stays enabled and keeps its label"); + assert_eq!(shown[2], &row("Show", true, Some(true)), "the first toggle is checked alone"); + assert_eq!(shown[3], &row("Hide", false, Some(false)), "the second toggle has its own label and state"); + } + #[test] fn accelerators_yield_to_text_focus_and_shortcut_recording() { let g = accelerator("G"); diff --git a/crates/codecs/README.md b/crates/codecs/README.md index c9d5cec7d..b993c52ef 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -46,7 +46,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d | Format | Decode | Encode | Notes | |---|---|---|---| -| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2, multi-chunk), Adobe APP14, MPF previews | +| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2; only complete, consistently counted, uniquely numbered chunk sequences are assembled; malformed profiles use the existing unsupported-profile fallback), Adobe APP14, MPF previews | | PNG | yes, 1–16-bit, palette, tRNS, interlaced | 8/16-bit, gray/GA/RGB/RGBA | iCCP, eXIf, iTXt XMP; sRGB/cICP/cHRM/gAMA honoured when no ICC | | TIFF | 8/16-bit int (32/64-bit int kept to 16 bits), 16/32/64-bit float, gray/RGB/CMYK/palette, alpha (assoc./unassoc.), planar, LZW/Deflate/PackBits/JPEG | 8/16-bit, 32-bit float; none/LZW/Deflate/PackBits | ICC (34675), XMP (700), Orientation; first IFD only; EXIF not written (use `lightcraft-tiff`) | | WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks | diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 2cdf67381..76f0c3a55 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -78,6 +78,8 @@ pub(crate) struct Raw { #[derive(Default)] pub(crate) struct Meta { pub icc: Option>, + /// An ICC profile was declared by the container but its sequence was invalid. + pub icc_invalid: bool, pub exif: Option>, pub xmp: Option, /// Orientation from the container itself (e.g. TIFF tag); EXIF blob is consulted otherwise. @@ -120,7 +122,7 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3 let (image, space) = if let Some(space) = meta.already_linear.clone() { (to_rgb_passthrough(&raw, ch), space) } else if raw.model == Model::Cmyk { - cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref()) + cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref(), meta.icc_invalid) } else { rgb_or_gray(&raw, ch, &meta, parsed_icc.as_ref(), float) }; @@ -299,7 +301,7 @@ fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, f _ => {} } } - if meta.icc.is_some() { + if meta.icc.is_some() || meta.icc_invalid { // Present but unusable (malformed, mismatched model, CMS failure): assume sRGB. let s = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::IccUnsupported); let t = NamedSpace::Srgb.trc(); @@ -323,7 +325,7 @@ fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, f (linearize(raw, ch, &t), s) } -fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>) -> (Rgb32f, SourceSpace) { +fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>, icc_invalid: bool) -> (Rgb32f, SourceSpace) { let samples = device_samples(raw, ch, 4); if let (Some(bytes), Some(info)) = (icc_bytes, info) && info.model == IccColorModel::Cmyk @@ -343,6 +345,6 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn [(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear) }) .collect(); - let origin = if icc_bytes.is_some() { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive }; + let origin = if icc_bytes.is_some() || icc_invalid { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive }; (from_vec(raw.width, raw.height, px), SourceSpace::named(NamedSpace::Srgb, origin)) } diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 7f20c3c41..89af1372b 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -24,6 +24,8 @@ pub(crate) struct Markers { pub exif_offset: usize, pub xmp: Option, pub icc: Option>, + /// An ICC_PROFILE APP2 segment was present but its sequence was invalid. + pub icc_invalid: bool, pub adobe_transform: Option, /// Offset of the MPF TIFF header within the file and its bytes. pub mpf: Option<(usize, Vec)>, @@ -45,7 +47,8 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { return None; } let mut m = Markers::default(); - let mut icc_chunks: Vec<(u8, &[u8])> = Vec::new(); + let mut icc_chunks: [Option<&[u8]>; 255] = [None; 255]; + let mut icc_total = None; let mut p = 2; while p + 4 <= b.len() { if b[p] != 0xFF { @@ -73,9 +76,11 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { if len < 2 { return None; } - let start = p + 4; - let end = (p + 2 + len).min(b.len()); - let seg = &b[start..end]; + let start = p.checked_add(4)?; + let declared_end = p.checked_add(2)?.checked_add(len)?; + let complete = declared_end <= b.len(); + let end = declared_end.min(b.len()); + let seg = b.get(start..end)?; match marker { 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => { m.precision = seg[0]; @@ -96,8 +101,30 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { } } 0xE2 => { - if seg.starts_with(b"ICC_PROFILE\0") && seg.len() >= 14 { - icc_chunks.push((seg[12], &seg[14..])); + if seg.starts_with(b"ICC_PROFILE\0") { + if !complete || seg.len() < 14 { + m.icc_invalid = true; + } else if let (Some(&sequence), Some(&total)) = (seg.get(12), seg.get(13)) { + if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) { + m.icc_invalid = true; + } else { + icc_total = Some(total); + let Some(slot) = icc_chunks.get_mut(usize::from(sequence - 1)) else { + m.icc_invalid = true; + p = declared_end; + continue; + }; + if slot.is_some() { + m.icc_invalid = true; + } else if let Some(data) = seg.get(14..) { + *slot = Some(data); + } else { + m.icc_invalid = true; + } + } + } else { + m.icc_invalid = true; + } } else if seg.starts_with(b"MPF\0") && m.mpf.is_none() { m.mpf = Some((start + 4, seg[4..].to_vec())); } else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN) @@ -111,11 +138,32 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { } _ => {} } - p += 2 + len; + p = declared_end; } - if !icc_chunks.is_empty() { - icc_chunks.sort_by_key(|c| c.0); - m.icc = Some(icc_chunks.iter().flat_map(|c| c.1.iter().copied()).collect()); + if let Some(total) = icc_total { + let mut profile_len = 0usize; + for chunk in icc_chunks.iter().take(usize::from(total)) { + let Some(bytes) = chunk else { + m.icc_invalid = true; + break; + }; + let Some(next_len) = profile_len.checked_add(bytes.len()) else { + m.icc_invalid = true; + break; + }; + profile_len = next_len; + } + let maximum_len = usize::from(total).checked_mul(65_519); + if maximum_len.is_none_or(|maximum| profile_len > maximum) { + m.icc_invalid = true; + } + if !m.icc_invalid { + let mut profile = Vec::with_capacity(profile_len); + for bytes in icc_chunks.iter().take(usize::from(total)).flatten() { + profile.extend_from_slice(bytes); + } + m.icc = Some(profile); + } } Some(m) } @@ -257,8 +305,10 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: }; // A raw container may carry the colour declaration of an otherwise untagged preview. // Apply it before linearization/resizing; existing JPEG metadata always wins. - let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container)); - let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() }; + let hint = fallback + .filter(|_| m.icc.is_none() && !m.icc_invalid && m.exif.is_none()) + .map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container)); + let meta = Meta { icc: m.icc, icc_invalid: m.icc_invalid, exif: m.exif, xmp: m.xmp, hint, ..Default::default() }; finish(F, raw, meta, (m.width, m.height), opts) } @@ -506,6 +556,110 @@ mod tests { assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err()); } + fn app2_icc(sequence: u8, total: u8, data: &[u8]) -> Vec { + let mut payload = b"ICC_PROFILE\0".to_vec(); + payload.extend_from_slice(&[sequence, total]); + payload.extend_from_slice(data); + let mut segment = vec![0xff, 0xe2]; + segment.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + segment.extend_from_slice(&payload); + segment + } + + fn jpeg_with_icc_chunks(chunks: &[Vec]) -> Vec { + use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let pixels = [180, 60, 40, 180, 60, 40, 180, 60, 40, 180, 60, 40]; + let base = encode_jpeg(&EncodeImage::new(2, 2, 3, Samples::U8(&pixels)), 100, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mut jpeg = base.get(..2).unwrap_or_default().to_vec(); + for chunk in chunks { + jpeg.extend_from_slice(chunk); + } + jpeg.extend_from_slice(base.get(2..).unwrap_or_default()); + jpeg + } + + #[test] + fn jpeg_icc_accepts_reordered_chunks_and_preserves_pixels() { + use crate::{NamedSpace, SpaceOrigin, icc}; + let profile = icc::write_named(NamedSpace::DisplayP3); + let (first, second) = profile.split_at(profile.len() / 2); + let single = jpeg_with_icc_chunks(&[app2_icc(1, 1, &profile)]); + let reordered = jpeg_with_icc_chunks(&[app2_icc(2, 2, second), app2_icc(1, 2, first)]); + + let one = crate::decode(&single, DecodeOptions::default()).unwrap(); + let two = crate::decode(&reordered, DecodeOptions::default()).unwrap(); + assert_eq!(one.icc.as_deref(), Some(profile.as_slice())); + assert_eq!(two.icc.as_deref(), Some(profile.as_slice())); + assert!(!parse_markers(&reordered).unwrap().icc_invalid); + assert_eq!(one.space, two.space); + assert_eq!(two.space.origin, SpaceOrigin::IccMatrixTrc); + let (a, b) = (one.to_working(), two.to_working()); + assert_eq!(a.data.len(), b.data.len()); + for (left, right) in a.data.iter().zip(&b.data) { + assert!(left.iter().zip(right).all(|(x, y)| (x - y).abs() < 0.00001), "{left:?} != {right:?}"); + } + } + + #[test] + fn jpeg_icc_rejects_incoherent_chunks_and_uses_unsupported_fallback() { + use crate::{NamedSpace, SpaceOrigin, icc}; + let profile = icc::write_named(NamedSpace::DisplayP3); + let (first, second) = profile.split_at(profile.len() / 2); + let base = jpeg_with_icc_chunks(&[]); + let reference = crate::decode(&base, DecodeOptions::default()).unwrap(); + let cases = [ + ("missing", vec![app2_icc(1, 2, &profile)]), + ("duplicate", vec![app2_icc(1, 2, first), app2_icc(1, 2, second)]), + ("zero sequence", vec![app2_icc(0, 1, &profile)]), + ("zero total", vec![app2_icc(1, 0, &profile)]), + ("out of range", vec![app2_icc(2, 1, &profile)]), + ("gap", vec![app2_icc(1, 3, first), app2_icc(3, 3, second)]), + ("inconsistent totals", vec![app2_icc(1, 2, first), app2_icc(2, 3, second)]), + ("short header", { + let mut segment = vec![0xff, 0xe2]; + segment.extend_from_slice(&15u16.to_be_bytes()); + segment.extend_from_slice(b"ICC_PROFILE\0\x01"); + vec![segment] + }), + ]; + for (name, chunks) in cases { + let jpeg = jpeg_with_icc_chunks(&chunks); + let markers = parse_markers(&jpeg).unwrap(); + let decoded = crate::decode(&jpeg, DecodeOptions::default()).unwrap(); + assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported, "{name}: invalid profile must not be applied"); + assert!(markers.icc.is_none(), "{name}: invalid ICC must not be assembled"); + assert!(markers.icc_invalid, "{name}: invalid ICC presence must be retained"); + assert_eq!(decoded.icc, None, "{name}: invalid ICC bytes must not escape decode"); + assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported, "{name}"); + assert_eq!(decoded.space.named, Some(NamedSpace::Srgb), "{name}"); + assert_eq!(decoded.to_working(), reference.to_working(), "{name}: fallback pixels"); + } + } + + #[test] + fn jpeg_icc_invalid_sequence_prevents_raw_colour_hint() { + use crate::{NamedSpace, SpaceOrigin, icc}; + let profile = icc::write_named(NamedSpace::DisplayP3); + let jpeg = jpeg_with_icc_chunks(&[app2_icc(1, 2, &profile)]); + let decoded = crate::decode_jpeg_with_fallback(&jpeg, DecodeOptions::default(), NamedSpace::AdobeRgb).unwrap(); + assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported); + assert_eq!(decoded.space.named, Some(NamedSpace::Srgb)); + } + + #[test] + fn jpeg_icc_rejects_truncated_app2_segment() { + use crate::{NamedSpace, icc}; + let profile = icc::write_named(NamedSpace::DisplayP3); + let mut segment = app2_icc(1, 1, &profile); + let declared = u16::from_be_bytes([segment[2], segment[3]]).saturating_add(1); + segment[2..4].copy_from_slice(&declared.to_be_bytes()); + let mut jpeg = vec![0xff, 0xd8]; + jpeg.extend_from_slice(&segment); + let markers = parse_markers(&jpeg).unwrap(); + assert!(markers.icc.is_none()); + assert!(markers.icc_invalid); + } + #[test] fn scaled_request_covers() { assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171)); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 1fc172548..014a74408 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -79,7 +79,12 @@ use tone::{HighlightCheck, ToneFit, fit_tone, fit_tone_conditional, highlights}; /// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a /// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data /// stored with each smart preview. -pub const LOOK_VERSION: u32 = 12; +/// 13: tone curves no steeper than a log-log slope of 4 between knots (#1904). +/// 13: the Canon CR3/CR2 camera JPEG read in the colour space of the Canon maker note (#2654). +/// 13: the sensor proxy of every per-file fit gets the embedded vignetting correction the render applies (Fujifilm +/// RAF; other makes with a lens database), so the render no longer applies it twice, at the position a lens warp samples; +/// the warped candidate's alignment leaves out the pixels outside the sensor instead of reading them as black. +pub const LOOK_VERSION: u32 = 13; #[derive(Clone, Debug)] pub(crate) struct CameraLook { diff --git a/crates/engine/src/camera_preview/colour.rs b/crates/engine/src/camera_preview/colour.rs index 2681466a9..6befe7ce1 100644 --- a/crates/engine/src/camera_preview/colour.rs +++ b/crates/engine/src/camera_preview/colour.rs @@ -1,6 +1,6 @@ -use super::look_fit::{fit_dng_tone, fit_preview}; +use super::look_fit::{fit_dng_tone, fit_preview, look_proxies}; use super::mono::{fit_dark_tone, monochrome_look}; -use super::proxy::{LensGeometry, proxies}; +use super::proxy::LensGeometry; use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford}; /// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates @@ -42,7 +42,8 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// as-shot white, the camera transform at it and the fitted look. /// `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. -/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit. +/// `embedded` is the file's embedded lens correction: its vignetting is applied to the sensor proxy of every fit, as +/// the render applies it. pub(crate) fn starting_colour( raw: &mut RawImage, bytes: &[u8], @@ -51,13 +52,13 @@ pub(crate) fn starting_colour( ) -> (Xy, CameraTransform, Option) { let (xy, t, look) = starting_colour_with( raw, - |raw, t| fit_preview(raw, bytes, t, lens), - |raw, t| monochrome_look(raw, bytes, t, lens), + |raw, t| fit_preview(raw, bytes, t, lens, embedded), + |raw, t| monochrome_look(raw, bytes, t, lens, embedded), |raw, t| { if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?; + let (sensor, reference, clipped) = look_proxies(raw, bytes, t, lens, embedded)?; fit_dark_tone(&sensor, &reference, &clipped) }, ); diff --git a/crates/engine/src/camera_preview/look_fit.rs b/crates/engine/src/camera_preview/look_fit.rs index 544db5f5f..d93cbffe9 100644 --- a/crates/engine/src/camera_preview/look_fit.rs +++ b/crates/engine/src/camera_preview/look_fit.rs @@ -6,11 +6,18 @@ use super::tone::{HighlightCheck, highlights}; use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020}; /// 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, lens: Option<&LensGeometry>) -> (Option, bool) { +/// `embedded`: the file's embedded lens correction, whose vignetting the render applies ([`look_proxies`]). +pub(crate) fn fit_preview( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Option, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) }; + let Some((sensor, reference, clipped)) = look_proxies(raw, bytes, transform, lens, embedded) 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). @@ -207,7 +214,9 @@ pub(super) fn fit_dng_tone( } None }; - let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None, None) 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)) { @@ -257,6 +266,20 @@ pub(super) fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE } +/// The proxies a photo's look is fitted on ([`proxies`] at [`PROXY`]), the sensor's with the embedded vignetting +/// correction the render applies. Without it the tone curve learns the camera JPEG's corrected +/// brightness from the uncorrected sensor, and the render then corrects it again: 21 X-S10 RAFs rendered up to 0.3 EV +/// brighter in their midtones and 0.6 EV in their shadows than their camera JPEGs, about 0.05 EV once corrected. +pub(super) fn look_proxies( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, +) -> Option<(Rgb32f, Rgb32f, Vec)> { + proxies(raw, bytes, transform, PROXY, 0.0, lens, embedded) +} + /// 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. diff --git a/crates/engine/src/camera_preview/mono.rs b/crates/engine/src/camera_preview/mono.rs index 3c0bd5736..6939c39ad 100644 --- a/crates/engine/src/camera_preview/mono.rs +++ b/crates/engine/src/camera_preview/mono.rs @@ -1,11 +1,11 @@ -use super::colour::{PROXY, file_local_look}; +use super::colour::file_local_look; use super::hue_sat::HueSat; -use super::look_fit::{with_dro_off_tone, without_clipped}; +use super::look_fit::{look_proxies, with_dro_off_tone, without_clipped}; use super::pairs::{ EDGE_CONTRAST, MIN_IMPROVEMENT, PARTIAL_MIN_RANK_CORRELATION, Pairs, collect_pairs, displayed, fit_tone_with_shadows, local_contrast, luma, rank_correlation_ties, }; -use super::proxy::{LensGeometry, proxies}; +use super::proxy::LensGeometry; use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold}; use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020}; @@ -33,12 +33,18 @@ pub(super) fn monochrome(reference: &Rgb32f) -> bool { /// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve. /// `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_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The -/// chroma curve stays neutral: the JPEG has no colourfulness to learn. -pub(super) fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { +/// chroma curve stays neutral: the JPEG has no colourfulness to learn. `embedded` as for [`fit_preview`]. +pub(super) fn monochrome_look( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, +) -> Option { if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; + let (sensor, reference, clipped) = look_proxies(raw, bytes, transform, lens, embedded)?; if !monochrome(&reference) { return None; } diff --git a/crates/engine/src/camera_preview/proxy.rs b/crates/engine/src/camera_preview/proxy.rs index b3db0a160..e5a9cf06c 100644 --- a/crates/engine/src/camera_preview/proxy.rs +++ b/crates/engine/src/camera_preview/proxy.rs @@ -1,6 +1,6 @@ use super::colour::{PROFILE_PROXY, PROXY, file_local_look}; use super::hue_sat::fit_hue_sat; -use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped}; +use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, correct_vignetting, without_clipped}; use super::pairs::{collect_pairs, fit_matrix}; use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize}; @@ -82,7 +82,8 @@ pub(super) fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { /// 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. +/// and per proxy pixel whether any sensor sample it covers is clipped. `embedded`'s vignetting correction is applied to +/// the sensor ([`correct_vignetting`]) before any `lens` warp, at the source position, as the render applies it. pub(super) fn proxies( raw: &RawImage, bytes: &[u8], @@ -90,6 +91,7 @@ pub(super) fn proxies( size: usize, min_fraction: f64, lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, ) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview( @@ -125,6 +127,7 @@ pub(super) fn proxies( 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); + correct_vignetting(&mut sensor, embedded, raw.orientation); // 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. @@ -319,7 +322,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeom 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, 0.0, lens)?; + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens, None)?; 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); @@ -369,7 +372,10 @@ pub(super) fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { 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::>(); + // NaN (outside a lens-warped proxy) stays NaN: `max` would turn it into a black border and a false edge + let lum = |img: &Rgb32f| { + img.data.iter().map(|p| luminance_2020(*p)).map(|y| if y.is_nan() { f64::NAN } else { (y.max(1e-4) as f64).ln() }).collect::>() + }; let high = |l: &[f64]| -> Vec { (0..w * h) .map(|i| { diff --git a/crates/engine/src/camera_preview/tests.rs b/crates/engine/src/camera_preview/tests.rs index d8442589d..d8673da4e 100644 --- a/crates/engine/src/camera_preview/tests.rs +++ b/crates/engine/src/camera_preview/tests.rs @@ -1,3 +1,4 @@ +use super::tone::{ELASTICITY_BLACK, MAX_TONE_ELASTICITY, TONE_REPAIR_ELASTICITY, limit_elasticity}; use super::*; fn steep_toe_scene() -> (Rgb32f, Rgb32f) { @@ -26,6 +27,74 @@ fn camera_tone_follows_the_jpegs_deep_shadows() { assert_eq!(look.matrix, Mat3::IDENTITY); } +/// Issue #1904: two populations the camera renders at different brightness for the same scene luminance (saturated +/// blue at 1.7x, red at 2.64x) made quantile matching join them with a near-vertical step. +#[test] +fn tone_fit_does_not_step_between_two_pixel_populations() { + let mut pairs = Vec::new(); + for i in 0..620 { + let x = 0.040 + 0.012 * f64::from(i) / 620.0; + pairs.push((x, 1.7 * x)); + } + for i in 0..380 { + let x = 0.02 * (10.0f64).powf(f64::from(i) / 380.0); + pairs.push((x, 2.64 * x)); + } + let elasticity = |k: &[[f32; 2]; 32]| { + k.windows(2) + .map(|w| { + ((f64::from(w[1][1]) + ELASTICITY_BLACK) / (f64::from(w[0][1]) + ELASTICITY_BLACK)).ln() + / (f64::from(w[1][0]) / f64::from(w[0][0])).ln() + }) + .fold(0.0, f64::max) + }; + let mut xs: Vec = pairs.iter().map(|p| p.0).collect(); + let mut ys: Vec = pairs.iter().map(|p| p.1).collect(); + xs.sort_by(f64::total_cmp); + ys.sort_by(f64::total_cmp); + let raw: [[f32; 2]; 32] = std::array::from_fn(|i| { + let mid = (i * xs.len() / 32 + (i + 1) * xs.len() / 32) / 2; + [xs[mid] as f32, ys[mid] as f32] + }); + assert!(elasticity(&raw) > 10.0, "the synthetic scene must reproduce the step: {}", elasticity(&raw)); + let tone = fit_tone(pairs).unwrap(); + let knots = tone.knots(); + assert!(elasticity(knots) <= MAX_TONE_ELASTICITY + 1e-3, "{}", elasticity(knots)); + assert!(knots.iter().zip(&raw).all(|(a, b)| a[0] == b[0]), "only the outputs move"); + for w in knots.windows(2) { + assert!(w[1][1] >= w[0][1]); + } + // A curve within the bound is left alone. + let smooth: [[f32; 2]; 32] = std::array::from_fn(|i| { + let x = 0.01 * 1.15f32.powi(i as i32); + [x, 0.9 * (x / (x + 0.18))] + }); + assert_eq!(limit_elasticity(smooth, &[]), Some(smooth)); +} + +/// A step at the top of the curve reaches its end: the pinned window and the free end are both under the bound, and +/// the pixel pairs choose between them. +#[test] +fn a_step_at_the_curves_end_is_repaired_from_the_side_the_pixels_support() { + let x = |i: usize| 0.01 * 1.15f64.powi(i as i32); + let stepped: [[f32; 2]; 32] = std::array::from_fn(|i| [x(i) as f32, (0.3 * x(i) * if i >= 30 { 3.0 } else { 1.0 }) as f32]); + let pairs = |gain: f64| -> Vec<(f64, f64)> { + (0..4000).map(|j| x(0) * (x(31) / x(0)).powf(f64::from(j) / 3999.0)).map(|x| (x, 0.3 * x * if x >= 0.62 { gain } else { 1.0 })).collect() + }; + // The pixels follow the curve below the step: the end is left free and the top knots drop to the repair slope. + let free = limit_elasticity(stepped, &pairs(1.0)).unwrap(); + assert_eq!(free[..30], stepped[..30], "the curve up to the step is kept"); + let (u29, v29) = (x(29).ln(), (f64::from(stepped[29][1]) + ELASTICITY_BLACK).ln()); + for k in 30..32 { + let limit = (v29 + TONE_REPAIR_ELASTICITY * (x(k).ln() - u29)).exp() - ELASTICITY_BLACK; + assert!(f64::from(free[k][1]) <= limit + 1e-6, "knot {k}: {} over {limit}", free[k][1]); + } + // The pixels do sit above the step: the window stays pinned to the top knot. + let pinned = limit_elasticity(stepped, &pairs(3.0)).unwrap(); + assert_eq!(pinned[31], stepped[31], "the end the pixels support is kept"); + assert!(pinned[29][1] > stepped[29][1] && pinned[30][1] < stepped[30][1], "the step is spread over the window"); +} + #[test] fn colour_holdouts_stay_out_of_the_wider_tone_training() { let (sensor, reference) = steep_toe_scene(); @@ -661,7 +730,7 @@ fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { 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, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) + (proxies(&raw, &bytes, &t, PROXY, 0.0, None, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens), None)) else { continue; }; @@ -928,7 +997,7 @@ fn corpus_cr3_gets_a_framing_aligned_camera_look() { 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, None).0; + let look = fit_preview(&raw, &bytes, &transform, None, 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(); @@ -965,7 +1034,7 @@ fn corpus_uses_the_bundled_profile(file: &str, model: &str) { let lens = lens_geometry(&raw.opcodes.list3); 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, lens.as_ref()).0.expect("fit accepted"); + let look = fit_preview(&raw, &bytes, &transform, lens.as_ref(), 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) { @@ -1346,9 +1415,9 @@ fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() { 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, None).0.expect("a camera look"); + let look = fit_preview(&raw, &bytes, &transform, None, 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, 0.0, None).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None, 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(); @@ -1978,20 +2047,22 @@ fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() { assert!(look.is_some() && !t.matrix_is_fallback); } -/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG, -/// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the -/// same file with a tinted JPEG gets nothing from this step. -#[test] -fn monochrome_look_reads_the_files_black_and_white_jpeg() { +/// A synthetic ILCE-7M4 raw (it has a bundled camera profile; demosaiced grey `sensor` levels) and its file bytes: +/// a minimal TIFF holding the camera JPEG, a bright camera curve on the `scene` luminance as an 8-bit sRGB JPEG in +/// `tint` (grey for `[1.0; 3]`). +fn grey_raw_with_jpeg( + (w, h): (usize, usize), + sensor: impl Fn(usize, usize) -> f32, + scene: impl Fn(usize, usize) -> f32, + tint: [f32; 3], +) -> (RawImage, Vec) { use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect}; - let (w, h) = (192usize, 128usize); - let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); let raw = RawImage { format: RawFormat::Arw, width: w, height: h, cpp: 3, - data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()), + data: RawData::F32((0..w * h).flat_map(|i| [sensor(i % w, i / w); 3]).collect()), cfa: None, bits: 16, black: BlackLevel::uniform(0.0), @@ -2007,38 +2078,146 @@ fn monochrome_look_reads_the_files_black_and_white_jpeg() { metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, lens_metadata: Default::default(), }; + let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + let pixels: Vec = (0..w * h) + .flat_map(|i| { + let v = 1.0 - (-3.0 * scene(i % w, i / w)).exp(); + tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + // II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it + let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] { + tiff.extend(tag.to_le_bytes()); + tiff.extend(4u16.to_le_bytes()); + tiff.extend(1u32.to_le_bytes()); + tiff.extend(value.to_le_bytes()); + } + tiff.extend(0u32.to_le_bytes()); + assert_eq!(tiff.len(), 38); + tiff.extend(jpeg); + (raw, tiff) +} + +/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 whose camera JPEG is black and white gets the tone curve, +/// on the colour of the transform it is given; the same file with a tinted JPEG gets nothing from this step. +#[test] +fn monochrome_look_reads_the_files_black_and_white_jpeg() { + let (w, h) = (192usize, 128usize); + let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); + let (raw, grey) = grey_raw_with_jpeg((w, h), level, level, [1.0; 3]); 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 - let file = |tint: [f32; 3]| { - let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; - let pixels: Vec = (0..w * h) - .flat_map(|i| { - let v = 1.0 - (-3.0 * level(i % w, i / w)).exp(); - tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8) - }) - .collect(); - let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); - let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); - // II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it - let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec(); - for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] { - tiff.extend(tag.to_le_bytes()); - tiff.extend(4u16.to_le_bytes()); - tiff.extend(1u32.to_le_bytes()); - tiff.extend(value.to_le_bytes()); - } - tiff.extend(0u32.to_le_bytes()); - assert_eq!(tiff.len(), 38); - tiff.extend(jpeg); - 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, None).expect("a tone curve from the black-and-white JPEG"); + let look = monochrome_look(&raw, &grey, &t, None, 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, None).is_none(), "a tinted JPEG is not black and white"); + let (_, tinted) = grey_raw_with_jpeg((w, h), level, level, [1.0, 0.9, 0.8]); + assert!(monochrome_look(&raw, &tinted, &t, None, None).is_none(), "a tinted JPEG is not black and white"); +} + +/// The look is fitted on the sensor as the render shows it, with the file's embedded vignetting correction: the +/// camera corrected its JPEG, so a curve fitted to the uncorrected sensor learns that brightening, and the render +/// then applies it twice (X-S10 RAFs up to 0.3 EV brighter than their camera JPEGs). +#[test] +fn the_look_is_fitted_on_the_vignetting_corrected_sensor() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let (w, h) = (192usize, 128usize); + let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }; + let gain = |x: usize, y: usize| { + let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5); + lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32 + }; + let scene = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); + let (raw, bytes) = grey_raw_with_jpeg((w, h), |x, y| scene(x, y) / gain(x, y), scene, [1.0; 3]); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() }; + let camera = 1.0 - (-0.75f32).exp(); + let at_quarter = |embedded: Option<&EmbeddedLens>| { + let look = monochrome_look(&raw, &bytes, &t, None, embedded).expect("a tone curve"); + ToneMap::camera(&look.tone, 0.0, 0.0, 0.0).apply(0.25) + }; + let (corrected, uncorrected) = (at_quarter(Some(&lens)), at_quarter(None)); + assert!((corrected - camera).abs() < 0.03, "the camera's curve at 0.25: {corrected} vs {camera}"); + assert!(uncorrected > camera + 0.03, "without the correction the curve brightens: {uncorrected} vs {camera}"); +} + +/// The colour fit gets the embedded vignetting correction too ([`starting_colour`] forwards it to every fit): the +/// tinted camera JPEG of the vignetted sensor takes the colour path, not the black-and-white one. +#[test] +fn the_colour_look_is_fitted_on_the_vignetting_corrected_sensor() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let (w, h) = (192usize, 128usize); + let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }; + let gain = |x: usize, y: usize| { + let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5); + lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32 + }; + let scene = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); + let (raw, bytes) = grey_raw_with_jpeg((w, h), |x, y| scene(x, y) / gain(x, y), scene, [1.0, 0.9, 0.8]); + let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() }; + let at_quarter = |embedded: Option<&EmbeddedLens>| { + let (_, _, look) = starting_colour(&mut raw.clone(), &bytes, None, embedded); + let look = look.expect("a colour look"); + assert!(look.matrix != Mat3::IDENTITY || look.hue_sat.is_some(), "the colour fit, not the black-and-white one"); + ToneMap::camera(&look.tone, 0.0, 0.0, 0.0).apply(0.25) + }; + let (corrected, uncorrected) = (at_quarter(Some(&lens)), at_quarter(None)); + assert!(uncorrected > corrected + 0.03, "without the correction the curve brightens: {uncorrected} vs {corrected}"); +} + +/// A lens-warped sensor proxy gets the vignetting gain of the position it samples, as the render applies it: the +/// vignetted sensor, corrected, gives the proxy of the same sensor without vignetting. +#[test] +fn the_vignetting_correction_is_applied_before_the_lens_warp() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + // twice the 384 px proxy edge: the demosaiced proxy of this linear raw is then the size a warp needs (bins of 2) + let (w, h) = (768usize, 512usize); + let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }; + let gain = |x: usize, y: usize| { + let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5); + lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32 + }; + // a texture for the warped candidate to line up with: the JPEG shows the sensor's centre zoomed by 1 / 0.8 + let texture = |x: f64, y: f64| 0.2 * (1.0 + 0.3 * ((x / 48.0).floor() + (y / 48.0).floor()).rem_euclid(2.0)) as f32; + let warp = LensGeometry { k: [0.8, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; + let (cx, cy) = ((w - 1) as f64 / 2.0, (h - 1) as f64 / 2.0); + let jpeg = |x: usize, y: usize| texture(cx + (x as f64 - cx) * 0.8, cy + (y as f64 - cy) * 0.8); + let sensor_proxy = |vignetted: bool, embedded: Option<&EmbeddedLens>| { + let sensor = |x: usize, y: usize| texture(x as f64, y as f64) / if vignetted { gain(x, y) } else { 1.0 }; + let (raw, bytes) = grey_raw_with_jpeg((w, h), sensor, jpeg, [1.0; 3]); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let (plain, _, _) = proxies(&raw, &bytes, &t, PROXY, 0.0, None, embedded).unwrap(); + let (sensor, _, _) = proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&warp), embedded).unwrap(); + assert!(sensor.data.iter().zip(&plain.data).any(|(a, b)| a != b), "the warped candidate is chosen"); + sensor + }; + let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() }; + let (corrected, expected) = (sensor_proxy(true, Some(&lens)), sensor_proxy(false, None)); + let worst = corrected.data.iter().zip(&expected.data).map(|(a, b)| (a[1] / b[1] - 1.0).abs()).filter(|d| d.is_finite()).fold(0.0, f32::max); + assert!(worst < 0.02, "largest deviation {worst}"); +} + +/// [`alignment`] leaves out the pixels a proxy lacks (NaN outside a lens-warped one): read as black, they made a +/// false edge around the border, and the warped candidate lost against a worse unwarped one. +#[test] +fn alignment_leaves_out_missing_pixels() { + let (w, h) = (48usize, 32usize); + let mut a = Rgb32f::new(w, h); + for (i, p) in a.data.iter_mut().enumerate() { + *p = [0.1 + 0.05 * ((i * 7919) % 13) as f32; 3]; + } + let mut b = a.clone(); + for (i, p) in b.data.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + if x < 4 || y < 4 || x >= w - 4 || y >= h - 4 { + *p = [f32::NAN; 3]; + } + } + assert!(alignment(&a, &b) > 0.999, "{}", alignment(&a, &b)); } /// 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_preview/tone.rs b/crates/engine/src/camera_preview/tone.rs index f16b8e899..52598d35c 100644 --- a/crates/engine/src/camera_preview/tone.rs +++ b/crates/engine/src/camera_preview/tone.rs @@ -133,7 +133,106 @@ pub(super) fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { return None; } - CameraTone::new(knots) + CameraTone::new(limit_elasticity(knots, &pairs)?) +} + +/// Largest log-log slope (elasticity, d ln y / d ln x with [`ELASTICITY_BLACK`] added to y) a fitted tone curve may +/// have between two knots. A much steeper segment is not a camera curve but two pixel populations the camera renders at +/// different brightness for the same scene luminance (by hue): quantile matching puts all of one population's scene +/// range below the other's JPEG range and joins them with a near-vertical step, which amplifies the luminance noise of +/// every pixel on it and renders as contours and blotches. On a dark ISO 1100 D3400 frame of saturated blue and red +/// (issue #1904; reds 1.55x brighter in the JPEG than blues of the same scene luminance) the step had elasticity 12-28, +/// and the render's fine luminance noise in the blue area was 1.46x that of Adobe's DNG of the same frame; with the +/// bound it is 0.99x. Of 514 fitted looks on the user samples the median curve's steepest segment is 2.1 and the 95th +/// percentile 3.6. The smoothed window ends where its end-to-end slope is first under the bound, so a looser bound +/// (5 or 6) only spreads the D3400 step over a few knots and leaves the noise as it was. +pub(super) const MAX_TONE_ELASTICITY: f64 = 4.0; +/// Log-log slope of a curve end left free by [`limit_elasticity`]: about the median steepest segment of fitted looks +/// (2.1), so the knots past the step follow an ordinary camera curve. At the bound itself (4) the D3400 frame's fine +/// luminance noise stays 1.2x Adobe's; at 2 it is 0.99x. +pub(super) const TONE_REPAIR_ELASTICITY: f64 = 2.0; +/// Display level added to both ends of a segment's elasticity, so a toe rising from black is not counted as infinitely +/// steep. +pub(super) const ELASTICITY_BLACK: f64 = 0.002; + +/// `knots` with every segment steeper than [`MAX_TONE_ELASTICITY`] smoothed. The knots inside the smallest window +/// around the segment (widened a knot on each side at a time) whose end-to-end elasticity is under the bound move onto +/// the straight log-log line between the window's ends, so the step is spread over the scene range of the populations it +/// joined. A window that reaches an end of the curve is pinned there, which can drag most of the curve onto one line +/// (a step at the top of a Coolpix P340 curve pulled knots 3-29 up, held-out error +4 dE00), so it is weighed against +/// leaving that end free: the knots past the segment on that side move onto [`TONE_REPAIR_ELASTICITY`] from the +/// segment's other knot instead. Of the two the one closer to `pairs` (absolute error) is kept. The curve outside the +/// window and its monotonicity are kept. `None` when nothing brings the curve under the bound. Knots within the bound are returned as +/// they are. +pub(super) fn limit_elasticity(mut knots: [[f32; 2]; 32], pairs: &[(f64, f64)]) -> Option<[[f32; 2]; 32]> { + let (bound, repair) = (MAX_TONE_ELASTICITY, TONE_REPAIR_ELASTICITY); + let at = |k: &[[f32; 2]; 32], i: usize| (f64::from(k[i][0]).ln(), (f64::from(k[i][1]) + ELASTICITY_BLACK).ln()); + let slope = |k: &[[f32; 2]; 32], a: usize, b: usize| { + let ((ua, va), (ub, vb)) = (at(k, a), at(k, b)); + (vb - va) / (ub - ua) + }; + let level = |v: f64| (v.exp() - ELASTICITY_BLACK).max(0.0) as f32; + let error = |k: &[[f32; 2]; 32]| { + let tone = CameraTone::new(*k)?; + Some(pairs.iter().map(|&(x, y)| (f64::from(tone.apply(x as f32)) - y).abs()).sum::()) + }; + for _ in 0..64 { + let Some(i) = (0..31).find(|&i| slope(&knots, i, i + 1) > bound) else { return Some(knots) }; + let (mut lo, mut hi) = (i, i + 1); + while slope(&knots, lo, hi) > bound && !(lo == 0 && hi == 31) { + lo = lo.saturating_sub(1); + hi = (hi + 1).min(31); + } + let mut pinned = knots; + let pinned_ok = slope(&knots, lo, hi) <= bound; + if pinned_ok { + let ((ua, va), (ub, vb)) = (at(&knots, lo), at(&knots, hi)); + for k in lo + 1..hi { + pinned[k][1] = level(va + (vb - va) * (f64::from(knots[k][0]).ln() - ua) / (ub - ua)); + } + } + // A window that reaches an end of the curve can leave that end free instead: the knots below the segment rise + // onto the repair slope down from knot i + 1, or the knots above it drop onto it up from knot i. + let mut free = None; + if lo == 0 || hi == 31 { + let mut k2 = knots; + if lo == 0 { + let (ub, vb) = at(&knots, i + 1); + for k in 0..=i { + let (u, v) = at(&knots, k); + k2[k][1] = level(v.max(vb - repair * (ub - u))); + } + } else { + let (ua, va) = at(&knots, i); + for k in i + 1..32 { + let (u, v) = at(&knots, k); + k2[k][1] = level(v.min(va + repair * (u - ua))); + } + } + free = Some(k2); + } + let next = match (pinned_ok, free) { + (true, Some(free)) => { + if error(&free).unwrap_or(f64::INFINITY) < error(&pinned).unwrap_or(f64::INFINITY) { + free + } else { + pinned + } + } + (true, None) => pinned, + (false, Some(free)) => free, + (false, None) => return None, + }; + if lightcraft_pipeline::profiling() { + let moved: Vec = (0..32).filter(|&k| next[k][1] != knots[k][1]).collect(); + eprintln!("[profile] camera tone: elasticity {:.1} at scene {:.4}, knots {moved:?} moved", slope(&knots, i, i + 1), knots[i][0]); + } + if next == knots { + return None; + } + knots = next; + } + None } fn median(values: &mut [f64]) -> Option { @@ -182,5 +281,5 @@ pub(super) fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option bool { + mode == WbMode::AsShot || (info.raw && !info.relative_wb && mode.preset().is_some()) +} + +/// Bring a photo's settings onto the scale and mode a white balance partial names, so a one-field partial means +/// what it says (issue #2910, and its review). `None` when the partial needs no such preparation: it names both +/// numbers, neither, or a mode of its own. [`merge_settings_for_photo`] and `preset.apply` both start here, and +/// both finish with `normalize_wb` so the photo ends on the scale it is edited on. +pub(crate) fn prepare_photo_for_wb_partial(d: &DevelopSettings, partial: &Value, info: &lightcraft_pipeline::SourceInfo) -> Option { + let mut d = (*d).clone(); + let wb = partial.get("wb").and_then(Value::as_object); + // the partial names one of the two numbers, and not both or neither + let one_field = wb.is_some_and(|wb| wb.contains_key("temp") != wb.contains_key("tint")); + if !one_field { + return None; + } + // a photo whose source is edited in Kelvin is the one with two scales to trip over + let kelvin_source = info.raw && !info.relative_wb; + // the scale the partial's numbers are written on: an unmentioned scale means the relative scale, as `merged` reads it + let partial_scale = match wb.as_ref().and_then(|wb| wb.get("scale")).and_then(Value::as_str) { + Some("kelvin") => lightcraft_develop::WbScale::Kelvin, + _ => lightcraft_develop::WbScale::Legacy, + }; + // put the photo onto the scale it is edited on: its values marked Kelvin, or a look still on the relative + // scale converted to the Kelvin / tint that renders the same + lightcraft_pipeline::local::normalize_wb(&mut d, info); + // a mode that answers its own white would swallow the partial's number: write the white the photo really + // renders as Custom values, so the number means what it says and the other field carries that white's look + if !wb.is_some_and(|wb| wb.contains_key("mode")) && wb_mode_ignores_its_numbers(d.wb.mode, info) { + let (t, tint) = lightcraft_pipeline::local::effective_wb(info, &d); + (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, t, tint); + } + if kelvin_source && partial_scale == lightcraft_develop::WbScale::Legacy { + // and onto the partial's scale, so the field the partial leaves out is the photo's own look read there + let as_shot = (info.as_shot_temp, info.as_shot_tint); + let (kt, ktint) = lightcraft_pipeline::local::effective_wb(info, &d); + if let Some((lt, ltint)) = lightcraft_pipeline::local::kelvin_to_legacy(kt, ktint, as_shot) { + (d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale) = (WbMode::Custom, lt, ltint, lightcraft_develop::WbScale::Legacy); + } + } + Some(d) +} + +/// Merge a partial settings JSON into a photo's settings (issue #2910). `DevelopSettings::merged` keeps a white +/// balance that names no scale on the relative scale; on a photo edited in Kelvin the field the partial does not +/// name (a partial with only `temp`, or only `tint`) would then be read on the other scale, giving a white neither +/// the photo nor the partial asked for. A one-field white balance partial is therefore resolved against the photo +/// first: the photo's white balance is brought onto the partial's scale with the photo's as-shot white +/// (`kelvin_to_legacy` / `legacy_to_kelvin` via `normalize_wb`), the partial merges unchanged, and the result lands +/// back on the photo's editing scale (`normalize_wb`), where the kept field carries the look the photo had. +/// Partials naming both fields, or neither, mean the same as before. A photo whose white balance mode renders its +/// own white — `As Shot`, or a named preset on a raw edited in Kelvin — has that white written out as Custom values +/// first ([`wb_mode_ignores_its_numbers`]), because a mode that answers its own white would otherwise store the +/// partial's number without any render reading it; a partial that names a `mode` itself stays authoritative. +pub(crate) fn merge_settings_for_photo( + d: &DevelopSettings, + partial: &Value, + info: &lightcraft_pipeline::SourceInfo, +) -> std::result::Result { + let prepared = prepare_photo_for_wb_partial(d, partial, info); + let on_kelvin_scale = prepared.is_some() && info.raw && !info.relative_wb; + let mut out = prepared.as_ref().unwrap_or(d).merged(partial)?; + if on_kelvin_scale { + // and the result back onto the scale this photo is edited on + lightcraft_pipeline::local::normalize_wb(&mut out, info); + } + Ok(out) +} + /// `v` as a list of numbers; `None` unless it is an array of nothing but numbers. fn numbers(v: Option<&Value>) -> Option> { v?.as_array()?.iter().map(Value::as_f64).collect() @@ -256,8 +330,10 @@ pub fn specs() -> Vec { cmd!("develop.merge", "Apply Settings JSON", [], None, "{settings: partial DevelopSettings JSON, label?}", has_active, |s, p| { let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?; let label = str_param(p, "label").unwrap_or("Edit").to_string(); + let id = active(s, "develop.merge")?; + let info = s.source_info(id); edit(s, "develop.merge", &label, |d| { - *d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?; + *d = merge_settings_for_photo(d, &partial, &info).map_err(|e| bad("develop.merge", e.to_string()))?; Ok(()) }) }), @@ -1016,7 +1092,20 @@ pub fn specs() -> Vec { let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) - .filter_map(|(id, d)| s.develop_op(id, preset.apply(&d, amount), &format!("Preset: {}", preset.name))) + .filter_map(|(id, d)| { + // a hand-written preset may name one white balance number only, and then it means the same as + // a partial: bring the photo onto that scale first, with its own as-shot white, and put the + // result back on the scale the photo is edited on (issue #2910) — the merge `develop.merge` uses + let info = s.source_info(id); + let prepared = prepare_photo_for_wb_partial(&d, &preset.settings, &info); + let on_kelvin_scale = prepared.is_some() && info.raw && !info.relative_wb; + let base: &DevelopSettings = prepared.as_ref().unwrap_or(&d); + let mut next = preset.apply(base, amount); + if on_kelvin_scale { + lightcraft_pipeline::local::normalize_wb(&mut next, &info); + } + s.develop_op(id, next, &format!("Preset: {}", preset.name)) + }) .collect::>(); s.commit(&format!("Preset: {}", preset.name), Op::Batch { ops })?; ok() diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index e1b33cc71..dc1f2c864 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -320,9 +320,13 @@ fn build(s: &mut Session, p: &Value) -> Result { type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); /// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale. +/// So do preview-only raws: their proxy is the embedded JPEG, read in the colour space the raw +/// declares, which a look version bump can change too (#2654). #[cfg(not(target_arch = "wasm32"))] fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool { - photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG")) + photo.fitted_look() + || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG")) + || (photo.kind == lightcraft_catalog::MediaKind::Raw && photo.preview_only.is_some()) } /// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look @@ -691,6 +695,22 @@ mod tests { use crate::Session; use crate::media::SourceRef; + // Given a preview-only raw, its proxy goes stale with the look version: the embedded JPEG's colour + // space can change with it (#2654); a rendered photo's proxy does not + #[test] + fn preview_only_raws_follow_the_look_version() { + use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source}; + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 6000, 4000, "2026-10-23T00:00:00"); + ph.kind = MediaKind::Raw; + assert!(super::preview_fitted_look(&ph), "a developed CR3"); + ph.set_preview_only(Some("unsupported".into())); + assert!(super::preview_fitted_look(&ph), "a preview-only CR3"); + ph.kind = MediaKind::Image; + ph.format = "JPG".into(); + ph.set_preview_only(None); + assert!(!super::preview_fitted_look(&ph), "a JPEG"); + } + // Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not // by reading the file again (here: a thumbnail job pointing at a file that is not there) #[test] diff --git a/crates/engine/src/files/tests.rs b/crates/engine/src/files/tests.rs index 613820257..1b1b380de 100644 --- a/crates/engine/src/files/tests.rs +++ b/crates/engine/src/files/tests.rs @@ -119,6 +119,26 @@ fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); } +/// A Fujifilm X-Trans III body reads Kelvin through the neutral pair its RAF stores (issue #730); without the pair +/// in the file, or on a body with matrices (X-T3), nothing changes. +#[test] +fn a_fujifilm_x_trans_iii_raf_reads_kelvin_through_its_neutral_pair() { + 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 a = [366.0f32 / 302.0, 1.0, 866.0 / 302.0]; + let d65 = [646.0f32 / 302.0, 1.0, 498.0 / 302.0]; + let pair = lightcraft_raw::kelvin_presets::KelvinPresets::from_neutral_pair(a, d65); + assert!(pair.is_some()); + let with = ColorData { kelvin_presets: pair, ..Default::default() }; + let xt20 = WbScaleOf::new(RawFormat::Raf, &with, Some(d65), &meta("FUJIFILM", "X-T20")); + assert!(!xt20.relative && xt20.scale_white.is_some()); + let (t, tint) = xt20.as_shot(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); + assert!((6000.0..7000.0).contains(&t) && tint.abs() < 25.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("RAF", "FUJIFILM X-T20")); + let bare = WbScaleOf::new(RawFormat::Raf, &ColorData::default(), Some(d65), &meta("FUJIFILM", "X-T20")); + assert!(bare.scale_white.is_none()); +} + /// 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 diff --git a/crates/engine/src/tests/color.rs b/crates/engine/src/tests/color.rs index e526d4c47..1ce7a6918 100644 --- a/crates/engine/src/tests/color.rs +++ b/crates/engine/src/tests/color.rs @@ -220,3 +220,247 @@ fn white_balance_commands_write_kelvin_on_raws_whose_camera_has_matrices() { fn lightcraft_engine_effective(info: &lightcraft_pipeline::SourceInfo, d: &lightcraft_develop::DevelopSettings) -> (f64, f64) { lightcraft_pipeline::local::effective_wb(info, d) } + +/// Issue #2910: a `develop.merge` white-balance partial that names only `temp` or only `tint` is resolved against +/// the photo, so the field the partial does not name keeps the look it had instead of being re-read on the +/// partial's scale. The partial's own number keeps the meaning an unmentioned scale always had (relative), and a +/// partial that says `scale: "kelvin"` is read in Kelvin. +#[test] +fn one_field_wb_partial_merge_keeps_the_other_field_on_its_scale() { + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + use lightcraft_develop::{DevelopSettings, WbMode, WbScale}; + let mut s = Session::new(); + let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| { + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = camera.into(); + p.as_shot_wb = Some(as_shot); + p.develop = std::sync::Arc::new(p.camera_defaults()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let as_shot = (4986.0, -2.0); + let kelvin = add(&mut s, "a.arw", "SONY ILCE-7M3", as_shot); + let relative = add(&mut s, "b.arw", "SONY ILCE-200", (6500.0, 0.0)); + let dev = |s: &Session, id| (*s.develop_of(id).unwrap()).clone(); + let l2k = |t, ti| lightcraft_pipeline::local::legacy_to_kelvin(t, ti, as_shot); + let k2l = |t, ti| lightcraft_pipeline::local::kelvin_to_legacy(t, ti, as_shot).unwrap(); + // two whites this close in (u, v) read as the same colour change; the bug's re-read of one kept field moves + // the white by ~8e-4 here, so a same-white check with this tolerance catches it + let close = |a: (f64, f64), b: (f64, f64)| { + let (au, av) = lightcraft_color::cct::xy_to_uv(lightcraft_color::cct::temp_tint_to_xy(a.0, a.1)); + let (bu, bv) = lightcraft_color::cct::xy_to_uv(lightcraft_color::cct::temp_tint_to_xy(b.0, b.1)); + (au - bu).hypot(av - bv) < 2e-4 + }; + // a stored white balance equal to an expected conversion, forgiving the last bits of the two code paths and + // the round-trip of the scale conversions; the bug moved the tint by 2+ units, far outside this + let nearly = |wb: &lightcraft_develop::WhiteBalance, want: &(f64, f64), why: &str| { + assert!((wb.temp - want.0).abs() < 1.0 && (wb.tint - want.1).abs() < 0.5, "{why}: {wb:?} vs {want:?}"); + }; + s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap(); + + // a temp-only partial on a photo edited in Kelvin: the tint keeps its Kelvin look (the bug re-read it as a + // relative shift and moved the green/magenta balance although the partial never touched tint) + let edited = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { mode: WbMode::Custom, temp: 5000.0, tint: 12.0, scale: WbScale::Kelvin }, + ..DevelopSettings::for_raw(as_shot.0, as_shot.1) + }; + s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap(); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap(); + let d = dev(&s, kelvin); + let keep = k2l(5000.0, 12.0); + assert_eq!(d.wb.scale, WbScale::Kelvin, "the photo goes back on the scale it is edited on: {:?}", d.wb); + nearly(&d.wb, &l2k(5800.0, keep.1), "the partial's number keeps the meaning an unmarked scale always had"); + // restating the photo's own temperature (the value it carries on the scale an unmarked partial means) leaves + // its white exactly where it was: on main the kept tint was re-read as a relative shift, drifting the white + s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap(); + let look = lightcraft_engine_effective(&s.source_info(kelvin), &edited); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": keep.0}}})).unwrap(); + let again = lightcraft_engine_effective(&s.source_info(kelvin), &dev(&s, kelvin)); + assert!(close(look, again), "a restated one-field partial leaves the white alone: {look:?} -> {again:?}"); + // and the reverse: a tint-only partial keeps the temperature's look + s.execute("develop.merge", &json!({"settings": {"wb": {"tint": -20.0}}})).unwrap(); + let d = dev(&s, kelvin); + nearly(&d.wb, &l2k(keep.0, -20.0), "a tint-only partial keeps the temperature's look"); + // a partial that names its scale is read on it: the same temperature asked for in Kelvin, kept tint unharmed + s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap(); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, 12.0, WbScale::Kelvin), "{:?}", d.wb); + + // from As Shot as well: the as-shot tint keeps its meaning (the bug read it as a relative shift from as shot) + s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "asShot"}}})).unwrap(); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap(); + let d = dev(&s, kelvin); + nearly(&d.wb, &l2k(5800.0, 0.0), "the as-shot tint keeps its meaning"); + assert_eq!(d.wb.scale, WbScale::Kelvin); + + // A mode that answers its own white (As Shot, or a named preset on a raw edited in Kelvin) ignores the numbers + // stored on the photo, so a one-field partial naming its scale landed on a white balance that stored the number + // where no render would read it: the panel showed 5800 while the photo stayed at as shot. The white the photo + // really renders is written out as Custom values first, so the named number means what it says and the field the + // partial leaves out carries that same white (review of #2952). + let as_shot_photo = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: 4986.0, tint: -2.0, scale: WbScale::Kelvin }, + ..DevelopSettings::for_raw(as_shot.0, as_shot.1) + }; + s.set_develop(kelvin, as_shot_photo.clone(), "as shot").unwrap(); + assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &as_shot_photo), as_shot, "as shot renders the file's own white"); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, -2.0, WbScale::Kelvin), "{:?}", d.wb); + assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (5800.0, -2.0), "the temperature the partial asked for renders"); + // the tint side of the same hole + s.set_develop(kelvin, as_shot_photo.clone(), "as shot").unwrap(); + s.execute("develop.merge", &json!({"settings": {"wb": {"tint": 20.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, as_shot.0, 20.0, WbScale::Kelvin), "{:?}", d.wb); + assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (as_shot.0, 20.0)); + // a named preset mode answers its own Kelvin white on such a raw, so its numbers are swallowed the same way + let preset_photo = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { mode: WbMode::Cloudy, temp: 4986.0, tint: -2.0, scale: WbScale::Kelvin }, + ..DevelopSettings::for_raw(as_shot.0, as_shot.1) + }; + s.set_develop(kelvin, preset_photo.clone(), "cloudy").unwrap(); + let cloudy = lightcraft_engine_effective(&s.source_info(kelvin), &preset_photo); + assert_ne!(cloudy, (4986.0, -2.0), "the preset mode renders its own white, not the stored numbers"); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, cloudy.1, WbScale::Kelvin), "{:?}", d.wb); + assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (5800.0, cloudy.1), "the kept tint is that white's own tint"); + // `Auto` resolves its gray when the mode is set and reads its stored numbers back: its mode stays, nothing frozen + let auto_photo = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { mode: WbMode::Auto, temp: 5100.0, tint: 3.0, scale: WbScale::Kelvin }, + ..DevelopSettings::for_raw(as_shot.0, as_shot.1) + }; + s.set_develop(kelvin, auto_photo, "auto").unwrap(); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint), (WbMode::Auto, 5800.0, 3.0), "{:?}", d.wb); + // a partial that names a mode stays authoritative: naming `asShot` puts the photo back on the file's white + s.set_develop(kelvin, as_shot_photo.clone(), "as shot").unwrap(); + s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "asShot", "temp": 5800.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, lightcraft_engine_effective(&s.source_info(kelvin), &d)), (WbMode::AsShot, as_shot), "{:?}", d.wb); + // the same swallow on a source without the Kelvin scale (a raw with no matrices): the number was stored and read + // by nothing there too, and now reaches the render + s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap(); + let before = dev(&s, relative); + assert_eq!(lightcraft_engine_effective(&s.source_info(relative), &before), (6500.0, 0.0), "as shot on this body is 6500 / 0"); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap(); + let d = dev(&s, relative); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, 0.0, WbScale::Legacy), "{:?}", d.wb); + assert_eq!(lightcraft_engine_effective(&s.source_info(relative), &d), (5800.0, 0.0), "the number the partial named renders"); + s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap(); + + // the other direction: an old relative Custom value (no scale) plus a Kelvin temp-only partial — the photo's + // white balance is taken onto the Kelvin scale first, so the kept tint is the Kelvin value of the photo's look + let old = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { mode: WbMode::Custom, temp: 5800.0, tint: 20.0, scale: WbScale::Legacy }, + ..DevelopSettings::for_raw(6500.0, 0.0) + }; + s.set_develop(kelvin, old.clone(), "old relative").unwrap(); + let keep = l2k(5800.0, 20.0); + assert!(!close((5800.0, 20.0), (keep.0, keep.1)), "the two readings really differ on this camera, so the next check can catch the bug"); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin), "{:?}", d.wb); + nearly(&d.wb, &(5800.0, keep.1), "the kept tint is the old relative tint carried onto the Kelvin scale"); + + // a partial naming both fields, or neither, behaves exactly as before + s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 6500.0, "tint": 0.0}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!(d.wb.scale, WbScale::Legacy, "a partial without a scale keeps the relative meaning"); + assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (as_shot.0, as_shot.1), "6500 / 0 without a scale is as shot"); + // the whole white balance arrives on the photo's scale when the partial says both fields and the scale + s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 5200.0, "tint": 7.0, "scale": "kelvin"}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.temp, d.wb.tint, d.wb.scale), (5200.0, 7.0, WbScale::Kelvin), "{:?}", d.wb); + // a one-field partial on a photo edited on the relative scale (no Kelvin scale there): unchanged behaviour, stays relative + s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap(); + let before = dev(&s, relative); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap(); + let d = dev(&s, relative); + assert_eq!((d.wb.temp, d.wb.tint, d.wb.scale), (5800.0, before.wb.tint, WbScale::Legacy), "{:?}", d.wb); + // a non-white-balance partial merges through the same path unchanged + let wb_before = dev(&s, relative).wb; + s.execute("develop.merge", &json!({"settings": {"light": {"contrast": 20}}})).unwrap(); + let d = dev(&s, relative); + assert_eq!(d.light.contrast, 20.0); + assert_eq!(d.wb, wb_before, "the white balance is untouched"); +} + +/// The issue asks for one photo-aware merge in every caller that writes settings onto a photo, and that includes +/// `preset.apply`: a preset a person or an agent authors by hand can name a single white balance number, and on a +/// photo edited in Kelvin that demoted the field it never mentioned to the relative scale, exactly as +/// `develop.merge` did. Presets the app makes and the crs / Luminar importers name `mode`, `temp`, `tint` and +/// `scale` together, and those keep their old path through `Preset::apply` → `apply_partial` unchanged. +#[test] +fn one_field_wb_preset_apply_keeps_the_other_field_on_its_scale() { + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + use lightcraft_develop::{DevelopSettings, WbMode, WbScale}; + let mut s = Session::new(); + let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| { + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = camera.into(); + p.as_shot_wb = Some(as_shot); + p.develop = std::sync::Arc::new(p.camera_defaults()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let as_shot = (4986.0, -2.0); + let kelvin = add(&mut s, "preset-wb.arw", "SONY ILCE-7M3", as_shot); + let l2k = |t, ti| lightcraft_pipeline::local::legacy_to_kelvin(t, ti, as_shot); + let k2l = |t, ti| lightcraft_pipeline::local::kelvin_to_legacy(t, ti, as_shot).unwrap(); + let nearly = |wb: &lightcraft_develop::WhiteBalance, want: &(f64, f64), why: &str| { + assert!((wb.temp - want.0).abs() < 1.0 && (wb.tint - want.1).abs() < 0.5, "{why}: {wb:?} vs {want:?}"); + }; + s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap(); + let edited = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { mode: WbMode::Custom, temp: 5000.0, tint: 12.0, scale: WbScale::Kelvin }, + ..DevelopSettings::for_raw(as_shot.0, as_shot.1) + }; + s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap(); + + // a hand-written preset naming one number: the tint it never mentions keeps its Kelvin look, instead of the + // old path that stored 5800 and re-read the photo's +12 tint as a relative shift + s.presets.push(lightcraft_develop::Preset { + id: "user.temp-only".into(), + name: "Temp only".into(), + group: "User".into(), + settings: json!({"wb": {"temp": 5800.0}}), + favorite: false, + builtin: false, + }); + s.execute("preset.apply", &json!({"id": "user.temp-only"})).unwrap(); + let d = (*s.develop_of(kelvin).unwrap()).clone(); + let keep = k2l(5000.0, 12.0); + assert_eq!(d.wb.mode, WbMode::Custom, "{:?}", d.wb); + assert_eq!(d.wb.scale, WbScale::Kelvin, "the preset left the photo on the scale it is edited on: {:?}", d.wb); + nearly(&d.wb, &l2k(5800.0, keep.1), "the preset's number keeps the meaning an unmarked scale always had"); + // an amount below 100 % blends between the photo's own white and the preset's, on one scale, and never + // mentions the other field at all + s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap(); + s.execute("preset.apply", &json!({"id": "user.temp-only", "amount": 50})).unwrap(); + let half = (*s.develop_of(kelvin).unwrap()).clone(); + let full = l2k(5800.0, keep.1); + assert_eq!(half.wb.scale, WbScale::Kelvin, "{:?}", half.wb); + assert!(half.wb.temp < 5000.0 && half.wb.temp > full.0, "between the photo's own white and the preset's: {:?}", half.wb); + nearly(&half.wb, &(half.wb.temp, 12.0), "the tint the preset never named stays exactly the photo's"); + // the same number through `develop.merge` gives the same white: that is the point of one shared merge + s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap(); + s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap(); + let merged = (*s.develop_of(kelvin).unwrap()).clone(); + nearly(&merged.wb, &(d.wb.temp, d.wb.tint), "preset apply and develop.merge agree on a one-field white balance"); + // a preset made from the photo names the whole white balance: its own path, unchanged + s.execute("preset.create", &json!({"name": "My kelvin look", "groups": ["whiteBalance"]})).unwrap(); + let made = s.presets.iter().find(|p| p.name == "My kelvin look").cloned().unwrap(); + let look = (*s.develop_of(kelvin).unwrap()).clone(); + s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "asShot"}}})).unwrap(); + s.execute("preset.apply", &json!({"id": made.id})).unwrap(); + let back = (*s.develop_of(kelvin).unwrap()).clone(); + assert_eq!(back.wb, look.wb, "a preset made from the photo puts its whole white balance back: {:?} vs {:?}", back.wb, look.wb); +} diff --git a/crates/meta/README.md b/crates/meta/README.md index e39447cc6..0a7f2d802 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -22,7 +22,8 @@ user fields (artist, copyright, title, caption, keywords, hierarchical keywords, EXIF capture-time candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock with more than three colon-separated components is rejected as a whole, allowing the next valid candidate or XMP -fallback to supply the capture time. The original tag remains available in All Metadata. +fallback to supply the capture time. The original tag remains available in All Metadata. JPEG ICC data is exposed only when its APP2 chunks form a complete, consistent, uniquely numbered sequence; +malformed or truncated ICC sequences leave unrelated readable metadata available and produce no profile. ## XMP namespaces diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index e73159d22..0136bf684 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -95,7 +95,9 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded { if !b.starts_with(&[0xff, 0xd8]) { return out; } - let mut icc_parts: Vec<(u8, Vec)> = Vec::new(); + let mut icc_parts: [Option<&[u8]>; 255] = [None; 255]; + let mut icc_total = None; + let mut icc_invalid = false; let mut ext: Vec<(u32, Vec)> = Vec::new(); let mut i = 2usize; while i + 4 <= b.len() { @@ -118,8 +120,11 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded { if len < 2 { break; } - let end = (i + 2 + len).min(b.len()); - let payload = &b[i + 4..end]; + let Some(start) = i.checked_add(4) else { break }; + let Some(declared_end) = i.checked_add(2).and_then(|at| at.checked_add(len)) else { break }; + let complete = declared_end <= b.len(); + let end = declared_end.min(b.len()); + let Some(payload) = b.get(start..end) else { break }; match marker { 0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()), 0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => { @@ -133,15 +138,64 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded { ext.push((off, p[40..].to_vec())); } } - 0xe2 if payload.starts_with(b"ICC_PROFILE\0") && payload.len() >= 14 => icc_parts.push((payload[12], payload[14..].to_vec())), + 0xe2 if payload.starts_with(b"ICC_PROFILE\0") => { + if !complete || payload.len() < 14 { + icc_invalid = true; + } else if let Some(header) = payload.get(12..14) { + let (Some(&sequence), Some(&total)) = (header.first(), header.get(1)) else { + icc_invalid = true; + i = end; + continue; + }; + if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) { + icc_invalid = true; + } else { + icc_total = Some(total); + if let Some(slot) = icc_parts.get_mut(usize::from(sequence - 1)) { + if slot.is_some() { + icc_invalid = true; + } else if let Some(data) = payload.get(14..) { + *slot = Some(data); + } else { + icc_invalid = true; + } + } else { + icc_invalid = true; + } + } + } else { + icc_invalid = true; + } + } 0xed if payload.starts_with(b"Photoshop 3.0\0") && out.iptc.is_none() => out.iptc = photoshop_iptc(&payload[14..]), _ => {} } i = end; } - if !icc_parts.is_empty() { - icc_parts.sort_by_key(|p| p.0); - out.icc = Some(icc_parts.into_iter().flat_map(|p| p.1).collect()); + if let Some(total) = icc_total { + let mut profile_len = 0usize; + for chunk in icc_parts.iter().take(usize::from(total)) { + let Some(bytes) = chunk else { + icc_invalid = true; + break; + }; + let Some(next_len) = profile_len.checked_add(bytes.len()) else { + icc_invalid = true; + break; + }; + profile_len = next_len; + } + let maximum_len = usize::from(total).checked_mul(65_519); + if maximum_len.is_none_or(|maximum| profile_len > maximum) { + icc_invalid = true; + } + if !icc_invalid { + let mut profile = Vec::with_capacity(profile_len); + for bytes in icc_parts.iter().take(usize::from(total)).flatten() { + profile.extend_from_slice(bytes); + } + out.icc = Some(profile); + } } if !ext.is_empty() { ext.sort_by_key(|p| p.0); @@ -230,6 +284,26 @@ mod tests { v } + fn icc_seg(sequence: u8, total: u8, data: &[u8]) -> Vec { + let mut payload = b"ICC_PROFILE\0".to_vec(); + payload.extend_from_slice(&[sequence, total]); + payload.extend_from_slice(data); + seg(0xe2, &payload) + } + + fn jpeg_with_icc(parts: &[Vec]) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + let packet = crate::write_xmp(&crate::Metadata { title: Some("retained".into()), ..Default::default() }, None); + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend_from_slice(&seg(0xe1, &xmp)); + for part in parts { + jpeg.extend_from_slice(part); + } + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg + } + pub(crate) fn sample_jpeg() -> Vec { let mut j = vec![0xff, 0xd8]; j.extend(seg(0xe0, b"JFIF\0\x01\x02\0\0\x01\0\x01\0\0")); @@ -286,6 +360,49 @@ mod tests { assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld")); } + #[test] + fn jpeg_icc_accepts_reordered_chunks() { + let parts = [icc_seg(2, 2, b"world"), icc_seg(1, 2, b"hello")]; + let embedded = jpeg_segments(&jpeg_with_icc(&parts)); + assert_eq!(embedded.icc.as_deref(), Some(&b"helloworld"[..])); + } + + #[test] + fn jpeg_icc_rejects_incomplete_or_incoherent_chunks() { + let cases = [ + ("missing", vec![icc_seg(1, 2, b"helloworld")]), + ("duplicate", vec![icc_seg(1, 2, b"hello"), icc_seg(1, 2, b"world")]), + ("zero sequence", vec![icc_seg(0, 1, b"helloworld")]), + ("zero total", vec![icc_seg(1, 0, b"helloworld")]), + ("out of range", vec![icc_seg(2, 1, b"helloworld")]), + ("gap", vec![icc_seg(1, 3, b"hello"), icc_seg(3, 3, b"world")]), + ("inconsistent totals", vec![icc_seg(1, 2, b"hello"), icc_seg(2, 3, b"world")]), + ("short header", vec![seg(0xe2, b"ICC_PROFILE\0\x01")]), + ]; + for (name, parts) in cases { + let embedded = jpeg_segments(&jpeg_with_icc(&parts)); + assert!(embedded.icc.is_none(), "{name} ICC chunks must be rejected"); + assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("retained")), "{name}: unrelated XMP must remain readable"); + } + } + + #[test] + fn jpeg_icc_rejects_truncated_segment_and_keeps_other_metadata() { + let mut truncated = icc_seg(1, 1, b"profile"); + let declared = u16::from_be_bytes([truncated[2], truncated[3]]).saturating_add(1); + truncated[2..4].copy_from_slice(&declared.to_be_bytes()); + let mut jpeg = vec![0xff, 0xd8]; + let xmp = crate::write_xmp(&crate::Metadata { title: Some("kept".into()), ..Default::default() }, None); + let mut packet = XMP_SIG.to_vec(); + packet.extend_from_slice(xmp.as_bytes()); + jpeg.extend_from_slice(&seg(0xe1, &packet)); + jpeg.extend_from_slice(&truncated); + + let embedded = jpeg_segments(&jpeg); + assert!(embedded.icc.is_none()); + assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("kept"))); + } + #[test] fn png_and_webp() { let exif = sample_exif(ByteOrder::Little); diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index c91872d38..a9dd9cbe6 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -140,6 +140,39 @@ pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64) } } +/// The relative-scale `(temp, tint)` that [`legacy_to_kelvin`] would read as the Kelvin `(temp, tint)` on a source +/// whose real as-shot white is `as_shot`: the inverse of [`legacy_white`]'s composition. It runs the Bradford step +/// backwards — the old target's cone response times the old reference's over the as-shot white's — and reads the +/// result back through [`xy_to_temp_tint`]. `(6500, 0)` is the relative scale's as-shot reference, so the Kelvin +/// white of the source itself maps exactly to it. `None` when the arithmetic has nowhere to go (a non-finite input, +/// a white outside the chromaticity plane, or an as-shot cone response of zero, where the forward step discarded it). +pub fn kelvin_to_legacy(temp: f64, tint: f64, as_shot: (f64, f64)) -> Option<(f64, f64)> { + use lightcraft_color::{BRADFORD, Xy}; + if !(temp.is_finite() && tint.is_finite() && as_shot.0.is_finite() && as_shot.1.is_finite()) { + return None; + } + if (temp - as_shot.0).abs() < 1e-9 && (tint - as_shot.1).abs() < 1e-9 { + // the source's own white: the relative scale's as-shot reference, exactly + return Some((6500.0, 0.0)); + } + let cones = |t: f64, tint: f64| BRADFORD.apply(temp_tint_to_xy(t, tint).to_xyz()); + let (target, reference, shot) = (cones(temp, tint), cones(6500.0, 0.0), cones(as_shot.0, as_shot.1)); + let inverse = BRADFORD.inverse()?; + let mut old = [0.0; 3]; + for i in 0..3 { + if shot[i].abs() < 1e-12 { + return None; + } + old[i] = target[i] * reference[i] / shot[i]; + } + let xy = Xy::from_xyz(inverse.apply(old)); + if !(xy.x.is_finite() && xy.y.is_finite() && xy.y > 0.0) { + return None; + } + let (t, tint) = xy_to_temp_tint(xy); + (t.is_finite() && tint.is_finite()).then_some((t, tint)) +} + /// Bring the white balance of `d` onto the scale `info` is edited on, before a new value is written into it: on a /// raw with a Kelvin scale the values are marked Kelvin, and ones still on the relative scale become the Custom /// Kelvin / tint that renders the same ([`legacy_to_kelvin`]; a preset mode's look is kept as that custom @@ -703,6 +736,7 @@ pub fn airlight_of(mut v: Vec) -> f32 { #[cfg(test)] mod tests { use super::*; + use lightcraft_color::cct::xy_to_uv; #[test] fn guided_preserves_step_edges_and_flattens_texture() { @@ -796,6 +830,70 @@ mod tests { scene_linear_pre(&mut img, &SourceInfo::default(), &s); assert!((img.get(1, 1)[2] - 0.6).abs() < 1e-5); } + + /// [`kelvin_to_legacy`] inverts [`legacy_to_kelvin`] over the whole relative slider range and back: the + /// round trip returns the start, in both directions, to the precision of the Kelvin / tint parametrisation. + #[test] + fn kelvin_to_legacy_inverts_legacy_to_kelvin() { + for as_shot in [(4986.0, -2.0), (6500.0, 0.0), (3200.0, 14.0), (9500.0, -12.0)] { + // the relative slider's whole reach: temp 0..100 (2000–13550 K around 6500), tint −150..150 + for pct in [0.0, 12.5, 25.0, 50.0, 75.0, 100.0] { + for tint in [-150.0, -40.0, 0.0, 33.0, 150.0] { + let (lt, ltint) = controls_relative(pct, tint); + let (kt, ktint) = legacy_to_kelvin(lt, ltint, as_shot); + // past the Kelvin parametrisation's own reach (its search runs 1000–50000 K) the forward + // conversion has already clamped, so the pair is not expected to come back + if !(1050.0..=49_000.0).contains(&kt) { + continue; + } + let back = kelvin_to_legacy(kt, ktint, as_shot).unwrap(); + let (x1, y1) = xy_to_uv(temp_tint_to_xy(lt, ltint)); + let (x2, y2) = xy_to_uv(temp_tint_to_xy(back.0, back.1)); + // the pair may differ by more than the slider's own step at the extreme corners (the Kelvin + // parametrisation is read back through a search), but never by much, and the white it renders + // is the same + assert!( + (back.0 - lt).abs() <= lt.abs() * 0.01 && (back.1 - ltint).abs() < 0.5, + "as shot {as_shot:?}: relative ({lt}, {ltint}) -> kelvin ({kt}, {ktint}) -> {back:?}" + ); + assert!((x1 - x2).hypot(y1 - y2) < 1e-3, "as shot {as_shot:?}: relative ({lt}, {ltint}) -> kelvin ({kt}, {ktint}) -> {back:?}"); + } + } + // the other direction: every white the relative scale can hold maps back to a pair that renders the same + for kt in [as_shot.0, 2500.0, 4200.0, 6500.0, 12000.0] { + for ktint in [-60.0, 0.0, 45.0] { + let Some((lt, ltint)) = kelvin_to_legacy(kt, ktint, as_shot) else { continue }; + // outside the slider's reach the relative scale has no value for this white: the forward + // conversion would clamp, so the round trip is not expected to close there + if !(2000.0..=13550.0).contains(<) || ltint.abs() > 150.0 { + continue; + } + let (kt2, ktint2) = legacy_to_kelvin(lt, ltint, as_shot); + let (x1, y1) = xy_to_uv(temp_tint_to_xy(kt, ktint)); + let (x2, y2) = xy_to_uv(temp_tint_to_xy(kt2, ktint2)); + assert!( + (x1 - x2).hypot(y1 - y2) < 1e-3, + "as shot {as_shot:?}: kelvin ({kt}, {ktint}) -> relative ({lt}, {ltint}) -> ({kt2}, {ktint2})" + ); + } + } + // the source's own white is the relative scale's as-shot reference, exactly + assert_eq!(kelvin_to_legacy(as_shot.0, as_shot.1, as_shot), Some((6500.0, 0.0))); + } + // the exact inverse of the special case inside legacy_to_kelvin + assert_eq!(kelvin_to_legacy(4986.0, -2.0, (4986.0, -2.0)), Some((6500.0, 0.0))); + assert_eq!(legacy_to_kelvin(6500.0, 0.0, (4986.0, -2.0)), (4986.0, -2.0)); + // hostile numbers answer None, never a panic or a plausible lie + assert_eq!(kelvin_to_legacy(f64::NAN, 0.0, (5000.0, 0.0)), None); + assert_eq!(kelvin_to_legacy(5000.0, f64::INFINITY, (5000.0, 0.0)), None); + assert_eq!(kelvin_to_legacy(5000.0, 0.0, (f64::NAN, 0.0)), None); + } + + /// The relative scale's `(temp, tint)` for a slider temperature at `pct` % (2000–7000 K fill the left half) and tint. + fn controls_relative(pct: f64, tint: f64) -> (f64, f64) { + let t = if pct <= 50.0 { 2000.0 + pct * 100.0 } else { 7000.0 + (pct - 50.0) * 131.0 }; + (t, tint) + } } #[cfg(test)] diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index 4db0a521e..58ed39625 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -22,6 +22,11 @@ //! - The isotherms are not perpendicular to the curve in these coordinates: an offset also changes mired by //! [`ISOTHERM_TILT`] times the mired the same distance along the curve would. //! +//! Fujifilm X-Trans III bodies (X-T20, X-T2, X-Pro2, X-E3, X-H1, X100F) store no such presets but the camera's own +//! neutral multipliers for Illuminant A and D65 (`0xf00c`, [`KelvinPresets::from_neutral_pair`], tag number from +//! a black-box `exiftool -v3`). The pair gives a two-point line in the same coordinates; mired is linear along it, +//! isotherms are perpendicular to it, and tint is scaled by the CIE locus spacing. No other camera's data is used. +//! //! The two constants come from the 14 Sony bodies that have both measured matrices and the presets (one CC0 //! raw.pixls.us sample each for the presets, a grid of multipliers through `crate::spectral` for the matrices): //! the tilt measured 0.59–0.84 (mean 0.74), the tint spacing 0.90–1.04 of the locus's (mean 0.98, taken as 1). @@ -55,6 +60,11 @@ const MODELS: &[(&str, &str)] = &[ ("CANON", "PowerShot G1 X Mark III"), ]; +/// Fujifilm's X-Trans III bodies, whose raws carry the camera's own neutral multipliers for Illuminant A and D65 +/// (`vendor::raf`, tag `0xf00c`; see [`KelvinPresets::from_neutral_pair`]): checked on the files of each (X-T20, +/// X-T2, X-Pro2, X-E3, X-H1, X100F), the Exif model as Fujifilm writes it. +const FUJI_MODELS: &[&str] = &["X-T20", "X-T2", "X-Pro2", "X-E3", "X-H1", "X100F"]; + /// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour /// matrices of its own or measured: see [`crate::spectral::find`], which comes first), from the file's presets or /// built-in ones ([`crate::kelvin_builtin`]). @@ -64,14 +74,16 @@ pub fn covers(make: Option<&str>, model: &str) -> bool { } let model = model.trim(); let brand = make.and_then(|m| m.split_whitespace().next()); - MODELS.iter().any(|(b, m)| { - let bare = model + let bare_of = |b: &str| { + model .get(..b.len()) .zip(model.get(b.len()..)) .filter(|(p, rest)| p.eq_ignore_ascii_case(b) && rest.starts_with(' ')) - .map_or(model, |(_, rest)| rest.trim_start()); - brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare.eq_ignore_ascii_case(m) - }) + .map_or(model, |(_, rest)| rest.trim_start()) + }; + let listed = MODELS.iter().any(|(b, m)| brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare_of(b).eq_ignore_ascii_case(m)); + let fuji = brand.is_none_or(|b| b.eq_ignore_ascii_case("FUJIFILM")) && FUJI_MODELS.iter().any(|m| bare_of("FUJIFILM").eq_ignore_ascii_case(m)); + listed || fuji } /// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). @@ -88,8 +100,17 @@ const MAX_POINTS: usize = 32; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KelvinPresets { points: Vec<(f32, [f32; 3])>, + /// The camera's neutral multipliers for Illuminant A and D65 when the presets come from such a pair + /// ([`Self::from_neutral_pair`]); the points are then the line between them at five temperatures. + #[serde(default)] + pair: Option<[[f32; 3]; 2]>, } +/// Illuminant A and D65 as chromaticities (CIE). +const A_XY: Xy = Xy::new(0.44757, 0.40745); +const D65_XY: Xy = Xy::new(0.31271, 0.32902); +const POINT_KELVIN: [f32; 5] = [2500.0, 3200.0, 4500.0, 6000.0, 8500.0]; + fn usable(m: [f32; 3]) -> bool { m.iter().all(|v| *v > 0.0 && v.is_finite()) } @@ -113,7 +134,7 @@ impl KelvinPresets { let (a, b) = (log_point(w[0].1), log_point(w[1].1)); w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1] }); - ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() }) + ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect(), pair: None }) } /// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …): @@ -135,6 +156,51 @@ impl KelvinPresets { KelvinPresets::new(kept) } + /// Presets from a camera's own neutral multipliers (red, green, blue, green = 1) for Illuminant A and D65, as + /// Fujifilm stores them in `WB_GRBLevelsStandard`. Two whites fix the camera's response only on the line + /// between them, and nothing but the colorimetry of the two lights is used to place a white off it: in + /// `(ln R/G, ln B/G)` mired is linear along the line (extended past both ends), the isotherms are taken + /// perpendicular to it (no tilt), and the tint is the Planckian locus's own CIE 1960 spacing per unit length + /// plus the tint the two lights themselves have (A is on the locus, D65 is about +9.5 off it). No other camera's + /// data is involved. `None` for unusable multipliers or two that make no curve. + pub fn from_neutral_pair(a: [f32; 3], d65: [f32; 3]) -> Option { + if !usable(a) || !usable(d65) { + return None; + } + let (a, d65) = ([a[0] / a[1], 1.0, a[2] / a[1]], [d65[0] / d65[1], 1.0, d65[2] / d65[1]]); + let (pa, pd) = (log_point(a), log_point(d65)); + let (ma, md) = (1e6 / cct::xy_to_temp_tint(A_XY).0, 1e6 / cct::xy_to_temp_tint(D65_XY).0); + let points = POINT_KELVIN.map(|k| { + let f = (1e6 / k as f64 - ma) / (md - ma); + let q = [pa[0] + f * (pd[0] - pa[0]), pa[1] + f * (pd[1] - pa[1])]; + (k, [q[0].exp() as f32, 1.0, q[1].exp() as f32]) + }); + let mut out = KelvinPresets::new(points)?; + out.pair = Some([a, d65]); + Some(out) + } + + /// [`Self::temp_tint`] for a neutral pair (see [`Self::from_neutral_pair`]). + fn pair_temp_tint(pair: [[f32; 3]; 2], q: [f64; 2]) -> Option<(f64, f64)> { + let (pa, pd) = (log_point(pair[0]), log_point(pair[1])); + let d = [pd[0] - pa[0], pd[1] - pa[1]]; + let len = d[0].hypot(d[1]); + // only a deserialized pair can repeat a point + if !(len > 0.0 && len.is_finite()) { + return None; + } + let t = [d[0] / len, d[1] / len]; + let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] }; + let r = [q[0] - pa[0], q[1] - pa[1]]; + let along = (r[0] * t[0] + r[1] * t[1]) / len; + let off = r[0] * n[0] + r[1] * n[1]; + let ((ta, tint_a), (td, tint_d)) = (cct::xy_to_temp_tint(A_XY), cct::xy_to_temp_tint(D65_XY)); + let ((u0, v0), (u1, v1)) = (locus_uv(ta), locus_uv(td)); + let tint_per_len = cct::TINT_SCALE * (u1 - u0).hypot(v1 - v0) / len; + let mired = 1e6 / ta + along * (1e6 / td - 1e6 / ta); + Some((1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K), tint_a + along * (tint_d - tint_a) + tint_per_len * off)) + } + /// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable /// multipliers. pub fn temp_tint(&self, wb_multipliers: [f32; 3]) -> Option<(f64, f64)> { @@ -142,6 +208,10 @@ impl KelvinPresets { return None; } let q = log_point(wb_multipliers); + if let Some(pair) = self.pair { + let (t, tint) = Self::pair_temp_tint(pair, q)?; + return Some((t, tint.clamp(-MAX_TINT, MAX_TINT))); + } let last = self.points.len().saturating_sub(2); let mut best: Option<(f64, f64, f64)> = None; for (i, w) in self.points.windows(2).enumerate() { @@ -290,9 +360,52 @@ mod tests { assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None); assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None); // a stored curve with a repeated point (only deserializing can make one) reads without dividing by zero - let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3] }; + let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3], pair: None }; assert_eq!(bad.temp_tint(g(1400.0, 1400.0)), None); - assert_eq!(KelvinPresets { points: vec![] }.temp_tint(g(1400.0, 1400.0)), None); + assert_eq!(KelvinPresets { points: vec![], pair: None }.temp_tint(g(1400.0, 1400.0)), None); + // and so does a stored neutral pair with equal endpoints + let same = KelvinPresets { points: vec![], pair: Some([[1.0, 1.0, 1.0]; 2]) }; + assert_eq!(same.temp_tint(g(1400.0, 1400.0)), None); + } + + /// An X-T20's stored pair (`302 366 866 17 302 646 498 21`). + fn xt20() -> (KelvinPresets, [f32; 3], [f32; 3]) { + let (a, d) = ([366.0 / 302.0, 1.0, 866.0 / 302.0], [646.0 / 302.0, 1.0, 498.0 / 302.0]); + (KelvinPresets::from_neutral_pair(a, d).unwrap(), a, d) + } + + #[test] + fn a_neutral_pair_reads_back_as_its_own_illuminants() { + let (p, a, d) = xt20(); + let (ta, tint_a) = p.temp_tint(a).unwrap(); + let (td, tint_d) = p.temp_tint(d).unwrap(); + let (want_t, want_tint) = cct::xy_to_temp_tint(D65_XY); + assert!((ta - cct::xy_to_temp_tint(A_XY).0).abs() < 2.0 && (tint_a - cct::xy_to_temp_tint(A_XY).1).abs() < 0.5, "{ta} {tint_a}"); + assert!((td - want_t).abs() < 2.0 && (tint_d - want_tint).abs() < 0.5, "{td} {tint_d}"); + // the points are the same reading at their temperatures, on the locus + for (k, m) in p.points.clone() { + let (t, tint) = p.temp_tint(m).unwrap(); + assert!((t - k as f64).abs() < 2.0 && tint.abs() < 12.0, "{k}: {t} {tint}"); + } + // greener light than the daylight pair: positive tint, as for a matrix camera + let (_, green) = p.temp_tint([d[0] * 1.06, 1.0, d[2] * 1.06]).unwrap(); + let (_, magenta) = p.temp_tint([d[0] / 1.06, 1.0, d[2] / 1.06]).unwrap(); + assert!(green > magenta + 15.0, "{green} {magenta}"); + } + + #[test] + fn unusable_pairs_give_nothing() { + assert_eq!(KelvinPresets::from_neutral_pair([0.0, 1.0, 1.0], [2.0, 1.0, 1.5]), None); + } + + #[test] + fn fujifilm_x_trans_iii_models_are_covered() { + for c in ["FUJIFILM X-T20", "Fujifilm X-T2", "FUJIFILM X100F", "FUJIFILM X-H1", "FUJIFILM X-Pro2", "FUJIFILM X-E3"] { + assert!(covers_camera(c), "{c}"); + } + for c in ["FUJIFILM X-T3", "FUJIFILM X-T30", "SONY X-T20", "FUJIFILM GFX 100"] { + assert!(!covers_camera(c), "{c}"); + } } #[test] diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 2047174da..89a5ea48a 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -16,12 +16,13 @@ pub enum PreviewColorSpace { /// The colour space the raw says its embedded JPEG is in, for a JPEG that declares none itself (no ICC profile, /// no EXIF), first available first: the Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB, per Nikon -/// tag documentation); the raw's own EXIF ColorSpace (0xA001: 1 = sRGB), or Uncalibrated (0xFFFF) with the -/// Interoperability IFD's InteropIndex "R03", the DCF option file for Adobe RGB (as a Sony body shot in Adobe RGB -/// writes, issue #778; per the Exif and DCF specifications), not for a DNG. This is a fallback only: a JPEG's own ICC/EXIF colour +/// tag documentation); for a Canon CR2 or CR3, the Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB); +/// the raw's own EXIF ColorSpace (0xA001: 1 = sRGB), or Uncalibrated (0xFFFF) with the Interoperability IFD's +/// InteropIndex "R03", the DCF option file for Adobe RGB (as a Sony body shot in Adobe RGB writes, issue #778; per +/// the Exif and DCF specifications), not for a DNG. This is a fallback only: a JPEG's own ICC/EXIF colour /// declaration takes precedence. pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { - let tiff = Tiff::parse(bytes).ok()?; + let Ok(tiff) = Tiff::parse(bytes) else { return cr3_color_space(bytes) }; let make = tiff.ifds.first()?.string(t::MAKE).unwrap_or_default(); let exif = tiff.exif()?; if make.to_ascii_uppercase().starts_with("NIKON") @@ -33,6 +34,13 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { if tiff.ifds.first()?.contains(t::DNG_VERSION) { return None; } + if make.to_ascii_uppercase().starts_with("CANON") + && let Some(e) = exif.get(t::MAKER_NOTE) + && let Some(mn) = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make) + && let Some(space) = canon_color_space(&mn.ifd) + { + return Some(space); + } match exif.u16(0xA001)? { 1 => Some(PreviewColorSpace::Srgb), 0xFFFF if exif.interop.as_deref()?.bytes(0x0001)?.starts_with(b"R03") => Some(PreviewColorSpace::AdobeRgb), @@ -40,6 +48,29 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { } } +/// A CR3 keeps its EXIF and maker note in `CMT2`/`CMT3` TIFF blocks and its JPEGs carry no EXIF; a body shot in +/// Adobe RGB writes ColorSpace Uncalibrated (0xFFFF) with no Interoperability IFD (issue #2654), so the Canon +/// maker note says which, else an EXIF ColorSpace of sRGB is taken. +fn cr3_color_space(bytes: &[u8]) -> Option { + let cr3 = lightcraft_meta::cr3::parse_cr3(bytes)?; + let first = |i: usize| cr3.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next()); + if let Some(space) = first(2).and_then(|mn| canon_color_space(&mn)) { + return Some(space); + } + (first(1)?.u16(0xA001)? == 1).then_some(PreviewColorSpace::Srgb) +} + +/// The Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB, per ExifTool's published Canon tag names). +/// A Canon body shot in Adobe RGB writes EXIF ColorSpace Uncalibrated (0xFFFF) with InteropIndex "R98" in a CR2 +/// and no Interoperability IFD in a CR3, so the EXIF alone does not say Adobe RGB (issue #2654). +fn canon_color_space(note: &Ifd) -> Option { + match note.u16(0x00b4)? { + 1 => Some(PreviewColorSpace::Srgb), + 2 => Some(PreviewColorSpace::AdobeRgb), + _ => None, + } +} + fn nikon_color_space(bytes: &[u8], tiff: &Tiff, exif: &Ifd, make: &str) -> Option { let e = exif.get(t::MAKER_NOTE)?; let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, make)?; diff --git a/crates/raw/src/preview/tests.rs b/crates/raw/src/preview/tests.rs index 2ee098414..80e3fdb52 100644 --- a/crates/raw/src/preview/tests.rs +++ b/crates/raw/src/preview/tests.rs @@ -282,6 +282,72 @@ fn cr3_prefers_the_full_size_jpeg_track() { assert_eq!(embedded_preview(&file).unwrap(), small); } +/// A minimal CR3 whose Canon box holds `CMT2` (EXIF ColorSpace `exif`) and `CMT3` (maker-note ColorSpace `note`). +fn cr3_with_color_space(exif: Option, note: Option) -> Vec { + let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; + let block = |tag: u16, v: Option| { + let ifd = + v.map_or_else(|| IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![100])), |v| IfdBuilder::new().with(tag, Value::Short(vec![v]))); + TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[ifd]).unwrap() + }; + let cmt1 = + TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("Canon".into()))]).unwrap(); + let canon_uuid = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; + let boxes = [bx(b"CMT1", &cmt1), bx(b"CMT2", &block(0xA001, exif)), bx(b"CMT3", &block(0x00b4, note))].concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &bx(b"uuid", &[&canon_uuid[..], &boxes].concat()))); + file +} + +/// A CR3 (not TIFF-based) says its preview's colour space in the Canon maker note (`CMT3` 0x00b4), else an +/// EXIF ColorSpace of sRGB in `CMT2`: an EOS R shot in Adobe RGB writes Uncalibrated with no interop (issue #2654). +#[test] +fn cr3_preview_colour_space_comes_from_the_canon_maker_note() { + use PreviewColorSpace::{AdobeRgb, Srgb}; + let space = |exif, note| embedded_preview_color_space(&cr3_with_color_space(exif, note)); + assert_eq!(space(Some(0xFFFF), Some(2)), Some(AdobeRgb)); + assert_eq!(space(Some(1), Some(1)), Some(Srgb)); + assert_eq!(space(Some(1), Some(2)), Some(AdobeRgb), "the maker note wins"); + assert_eq!(space(Some(1), None), Some(Srgb), "no maker-note value: the EXIF sRGB"); + assert_eq!(space(Some(0xFFFF), Some(0xFFFF)), None, "neither says"); + assert_eq!(space(Some(0xFFFF), None), None); + assert_eq!(space(None, None), None); + let f = cr3_with_color_space(Some(0xFFFF), Some(2)); + for n in 0..f.len() { + let _ = embedded_preview_color_space(&f[..n]); + } +} + +/// A CR2 shot in Adobe RGB writes EXIF ColorSpace Uncalibrated with InteropIndex "R98", not "R03": the Canon +/// maker note (0x00b4) says Adobe RGB (issue #2654). Another make's note is not read for it. +#[test] +fn cr2_preview_colour_space_comes_from_the_canon_maker_note() { + use PreviewColorSpace::{AdobeRgb, Srgb}; + let cr2 = |make: &str, exif_space: u16, note: Option| { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![exif_space])); + exif.set_child(t::INTEROP_IFD, IfdBuilder::new().with(0x0001, Value::Undefined(b"R98".to_vec()))); + if let Some(v) = note { + // a plain one-entry IFD, its SHORT value inline + let mut mn = 1u16.to_le_bytes().to_vec(); + mn.extend([0x00b4u16.to_le_bytes(), 3u16.to_le_bytes()].concat()); + mn.extend(1u32.to_le_bytes()); + mn.extend([v.to_le_bytes(), [0, 0]].concat()); + mn.extend(0u32.to_le_bytes()); + exif.set(t::MAKER_NOTE, Value::Undefined(mn)); + } + let mut ifd0 = IfdBuilder::new().with(t::MAKE, Value::Ascii(make.into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[ifd0]).unwrap() + }; + assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, Some(2))), Some(AdobeRgb)); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 1, Some(1))), Some(Srgb)); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 1, None)), Some(Srgb), "no maker note: the EXIF"); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, None)), None, "R98 is not Adobe RGB's R03"); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, Some(0xFFFF))), None); + assert_eq!(embedded_preview_color_space(&cr2("Leica", 0xFFFF, Some(2))), None, "only a Canon note"); +} + /// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes. fn scan_jpeg(nc: u8, n: usize) -> Vec { let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00]; diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 42ed29ce8..b60a74266 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -118,6 +118,19 @@ pub(crate) fn unpack_row(src: &[u8], bits: u32, words32: bool, out: &mut [u16]) } } +/// The Kelvin scale of a body without colour matrices, from tag `0xf00c` (`WB_GRBLevelsStandard`; tag number from +/// `exiftool -v3` run as a black-box tool on the files): `G R B 17 G R B 21`, the camera's own neutral multipliers +/// for Illuminant A and D65 (see [`crate::kelvin_presets::KelvinPresets::from_neutral_pair`]). +fn fuji_kelvin_presets(ifd: &Ifd) -> Option { + let v = ifd.f64s(0xf00c).filter(|v| v.len() >= 8)?; + let grb = |at: usize, code: f64| { + (v[at + 3] == code && v[at] > 0.0 && v[at + 1] > 0.0 && v[at + 2] > 0.0) + .then(|| [(v[at + 1] / v[at]) as f32, 1.0, (v[at + 2] / v[at]) as f32]) + }; + let (a, d65) = (grb(0, 17.0)?, grb(4, 21.0)?); + crate::kelvin_presets::KelvinPresets::from_neutral_pair(a, d65) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let h = header(bytes)?; let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?; @@ -191,6 +204,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let wb = ifd.f64s(0xf00e).filter(|v| v.len() >= 3 && v.iter().take(3).all(|x| *x > 0.0)).map(|v| [(v[1] / v[0]) as f32, 1.0, (v[2] / v[0]) as f32]); let white = white_from_data(&data, bits); + let kelvin_presets = fuji_kelvin_presets(&ifd); let mut metadata = h.jpeg.map(lightcraft_meta::extract).unwrap_or_default(); metadata.width = Some(active.width as u32); @@ -209,7 +223,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: active, crop: Rect::new(0, 0, active.width, active.height), orientation, - color: ColorData::default(), + color: ColorData { kelvin_presets, ..Default::default() }, wb_multipliers: wb, linearized: false, pixel_aspect: 1.0, diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 5b9db2628..e0ec9f1ba 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -51,7 +51,7 @@ "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}", + "Creates {stacks} stack{} from {photos} of {scope}": "Vytvoří sady: {stacks} z fotografií: {photos} ({scope}){:.0}", "Decoder: {reason}": "Dekodér: {reason}", "Delete \"{label}\"": "Odstranit „{label}“", "Delete \"{label}\" (and the mask)": "Odstranit „{label}“ (i masku)", @@ -79,6 +79,7 @@ "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 photos with File → Import Photos… ({}), or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… ({}) nebo je přetáhněte sem", "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}", @@ -111,6 +112,7 @@ "Moved; {} photo(s) relinked": "Přesunuto; znovu propojeno fotografií: {}", "Named ({named_n})": "Pojmenované ({named_n})", "New Folder in “{name}”": "Nová složka v „{name}“", + "Created folder “{name}” in “{parent}”": "Vytvořena složka „{name}“ v „{parent}“", "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í", @@ -140,6 +142,7 @@ "Rendering on the CPU: {why}": "Vykreslování na CPU: {why}", "Replace {} with:": "Nahradit {} za:", "Reset {name}": "Obnovit {name}", + "{name} value": "{name} – hodnota", "Reset {} Channel": "Obnovit kanál {}", "Restore {}": "Obnovit {}", "Restore {} Photo{}": "Obnovit fotografie: {}{:.0}", @@ -283,7 +286,7 @@ "Skipped {problem}": "Přeskočeno: {problem}", "…and {more} more": "…a další: {more}", "Template preview: {name}": "Náhled šablony: {name}", - "{n} photos in view · {slides} slides · {time}": "Fotografie v zobrazení: {n} · snímky: {slides} · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "Fotografií v zobrazení: {n} · snímků: {slides} · {time}{:.0}{:.0}", "{n} pages": "Stránky: {n}", "{n} JPEG files": "Soubory JPEG: {n}", "{s} s of AV1 video": "Video AV1: {s} s", @@ -297,7 +300,7 @@ "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Přidáno sloučení {what} v rozlišení {mp}: plné rozlišení {full} vyžaduje více paměti, než má tento počítač", "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Přidáno sloučení {what} v rozlišení {mp}, což je nejvyšší možné rozlišení panoramatu (plné rozlišení je {full})", "Color space: Rec. 2020 {curve} (HDR AVIF)": "Barevný prostor: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "Vybrané fotografie: {n} · stránky: {pages}", + "{n} selected photo{} · {pages} page{}": "Vybrané fotografie: {n} · stránek: {pages}{:.0}{:.0}", "Already in “{}”": "Již v „{}“", "Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?", diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 76bb0c1df..773945015 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -57,6 +57,7 @@ "Add to Local": "Přidat do místních souborů", "Add to My Photos": "Přidat do mých fotografií", "Can't be moved here": "Sem nelze přesunout", + "Import photos with File → Import Photos…, or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… nebo je přetáhněte sem", "This row covers more than one disk: drop on a folder inside it": "Tento řádek zahrnuje více disků: přetáhněte položky na některou ze složek v něm", "Local photos aren't in the library yet: Add to My Photos first, then move them": "Místní fotografie zatím nejsou v knihovně: nejprve použijte Přidat do mých fotografií a pak je přesuňte", "Add to My Photos First": "Nejprve přidat do mých fotografií", @@ -254,6 +255,7 @@ "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…", + "Add Locations": "Doplnit polohy", "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.", @@ -498,6 +500,9 @@ "Cropped": "Oříznuto", "Cross Process": "Křížové vyvolání", "Culling": "Třídění", + "Cull": "Roztřídit", + "Create Stacks": "Vytvořit sady", + "Change Time": "Změnit čas", "Curve": "Křivka", "Custom": "Vlastní", "Custom template…": "Vlastní šablona…", @@ -902,7 +907,6 @@ "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…, or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… nebo je přetáhněte sem", "Imported": "Importováno", "Imported Photos": "Importované fotografie", "Importing": "Importování", @@ -1898,6 +1902,7 @@ "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…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Tlačítko Značky vypíše zástupné symboly pro šablonu, například {date} nebo {seq}. Každý soubor se na disku přejmenuje spolu s doprovodným souborem XMP; obsazený název dostane příponu -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ě", @@ -2429,5 +2434,8 @@ "Set up face recognition": "Nastavit rozpoznávání obličejů", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Rozpoznávání obličejů potřebuje model, aby mohlo navrhovat jména: jednorázové stažení v Nastavení.", "Turn on name suggestions": "Zapnout návrhy jmen", - "Set up name suggestions…": "Nastavit návrhy jmen…" + "Set up name suggestions…": "Nastavit návrhy jmen…", + "Top bar": "Horní lišta", + "Tool strip": "Pruh nástrojů", + "Bottom bar": "Dolní lišta" } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index eabaae2c8..782886be8 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -40,7 +40,7 @@ "Color space: Rec. 2020 {curve} (HDR AVIF)": "Farbraum: Rec. 2020 {curve} (HDR-AVIF)", "Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}", "Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen", - "Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}", + "Creates {stacks} stack{} from {photos} of {scope}": "Erstellt {stacks} Stapel aus {photos} von {scope}{:.0}", "Decoder: {reason}": "Decoder: {reason}", "Delete \"{label}\"": "„{label}“ löschen", "Delete \"{label}\" (and the mask)": "„{label}“ löschen (und die Maske)", @@ -63,6 +63,7 @@ "Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}", "Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt", "Import failed: {e}": "Import fehlgeschlagen: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Importiere Fotos über Datei → Fotos importieren… ({}) oder lege sie hier ab", "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.", @@ -117,6 +118,7 @@ "Rendering on the CPU: {why}": "Rendering auf der CPU: {why}", "Replace {} with:": "{} ersetzen durch:", "Reset {name}": "{name} zurücksetzen", + "{name} value": "{name}: Wert", "Reset {} Channel": "Kanal {} zurücksetzen", "Restore {}": "{} wiederherstellen", "Restore {} Photo{}": "{} Foto{} wiederherstellen", @@ -165,7 +167,6 @@ "{n} photo{}": "{n} Foto{}", "{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", "{n} photo{} now · updates automatically as photos change": "Derzeit {n} Foto{} · wird bei Änderungen an Fotos automatisch aktualisiert", - "{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten", "{pid} (missing)": "{pid} (fehlt)", "{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", @@ -251,6 +252,7 @@ "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)", "New Folder in “{name}”": "Neuer Ordner in „{name}“", + "Created folder “{name}” in “{parent}”": "Ordner „{name}“ in „{parent}“ erstellt", "Moved {} photo(s)": "{} Foto(s) verschoben", "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", @@ -285,7 +287,8 @@ "Skipped {problem}": "Übersprungen: {problem}", "…and {more} more": "…und {more} weitere", "Template preview: {name}": "Vorlagenvorschau: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Dias · {time}", + "{n} selected photo{} · {pages} page{}": "Ausgewählte Fotos: {n} · Seiten: {pages}{:.0}{:.0}", + "{n} photo{} in view · {slides} slide{} · {time}": "Fotos in der Ansicht: {n} · Folien: {slides} · {time}{:.0}{:.0}", "{n} pages": "{n} Seiten", "{n} JPEG files": "{n} JPEG-Dateien", "{s} s of AV1 video": "{s} s AV1-Video", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index ae5a1f804..5bc124f76 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -57,6 +57,7 @@ "Add to Local": "Zu „Lokal“ hinzufügen", "Add to My Photos": "Zu „Meine Fotos“ hinzufügen", "Can't be moved here": "Hierher nicht verschiebbar", + "Import photos with File → Import Photos…, or drop them here": "Importiere Fotos über Datei → Fotos importieren… oder lege sie hier ab", "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", @@ -135,6 +136,7 @@ "Auto-Tag from Tracklog": "Anhand eines GPS-Tracks automatisch verorten", "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatisches Verorten anhand eines GPS-Tracks: GPS-Orte für deine Fotos aus einem GPX-Track, abgeglichen nach Aufnahmezeit.", "Auto-Tag from Tracklog…": "Anhand eines GPS-Tracks automatisch verorten…", + "Add Locations": "Orte hinzufügen", "Automatic": "Automatisch", "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.": "Automatisch rendert die Detailansicht in der angezeigten Größe, bis zu den eigenen Pixeln des Fotos. Eine Größe wählen, um sie zu begrenzen: kleiner ist schneller.", "Automatically Write Changes into XMP": "Änderungen automatisch in XMP schreiben", @@ -359,6 +361,9 @@ "Crop, Rotate, Geometry": "Zuschnitt, Drehung, Geometrie", "Cross Process": "Crossentwicklung", "Culling": "Fotoauswahl", + "Cull": "Auswählen", + "Create Stacks": "Stapel erstellen", + "Change Time": "Zeit ändern", "Curve": "Kurve", "Custom": "Benutzerdefiniert", "Custom template…": "Benutzerdefinierte Vorlage…", @@ -709,7 +714,6 @@ "Import as new": "Neu importieren", "Import from Device": "Von Gerät importieren", "Import from Folder…": "Aus Ordner importieren…", - "Import photos with File → Import Photos…, or drop them here": "Importiere Fotos über Datei → Fotos importieren… oder lege sie hier ab", "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Vorgaben aus anderen Editoren importieren: XMP-Vorgaben, klassische `.lrtemplate`-Dateien, „DNG-Vorgaben“ aus mobilen Apps und `.zip`-Pakete.", "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import → Kopieren prüft jede Kopie wie beim Verschieben (Issue #96): Jede Datei wird neu geschrieben, auf den Datenträger synchronisiert", "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import → Verschieben: Fotos gehen mit derselben Ordnerstruktur und Umbenennung wie beim Kopieren in den Zielordner, etwa", @@ -1526,6 +1530,7 @@ "Table": "Tabelle", "Tags": "Platzhalter", "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Platzhalter: Siehe „Platzhalter“ neben der Vorlage. Dateien werden auf dem Datenträger umbenannt (mit ihren XMP-Begleitdateien); vorhandene Namen erhalten -1, -2…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Die Schaltfläche „Platzhalter“ zeigt, was in der Vorlage stehen kann, z. B. {date} oder {seq}. Jede Datei wird auf dem Datenträger zusammen mit ihrer XMP-Begleitdatei umbenannt; ein bereits vergebener Name erhält -1, -2…", "Take a snapshot": "Schnappschuss erstellen", "Targeted Adjustment": "Gezielte Anpassung", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Gezielte Anpassung: Im Foto nach oben/unten ziehen, um die dortigen Farben anzupassen", @@ -3461,5 +3466,8 @@ "Set up face recognition": "Gesichtserkennung einrichten", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Die Gesichtserkennung braucht ein Modell, um Namen vorzuschlagen: ein einmaliger Download in den Einstellungen.", "Turn on name suggestions": "Namensvorschläge einschalten", - "Set up name suggestions…": "Namensvorschläge einrichten…" + "Set up name suggestions…": "Namensvorschläge einrichten…", + "Top bar": "Obere Leiste", + "Tool strip": "Werkzeugleiste", + "Bottom bar": "Untere Leiste" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index ac945b1cb..41ae09d66 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -40,7 +40,7 @@ "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espacio de color: Rec. 2020 {curve} (AVIF HDR)", "Couldn't open the editor: {e}": "No se pudo abrir el editor: {e}", "Create {n} Virtual Copies": "Crear {n} copias virtuales", - "Creates {} stacks from {} of {scope}": "Crea {} pilas a partir de {} de {scope}", + "Creates {stacks} stack{} from {photos} of {scope}": "Crea {stacks} pila{} a partir de {photos} de {scope}", "Decoder: {reason}": "Decodificador: {reason}", "Delete \"{label}\"": "Eliminar \"{label}\"", "Delete \"{label}\" (and the mask)": "Eliminar \"{label}\" (y la máscara)", @@ -63,6 +63,7 @@ "Healed {n} dust spot{}": "{n} mancha{0} de polvo corregida{0}", "Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}", "Import failed: {e}": "Error de importación: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Importar fotos con Archivo → Importar fotos… ({}) o soltarlas aquí", "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.", @@ -117,6 +118,7 @@ "Rendering on the CPU: {why}": "Renderizado en la CPU: {why}", "Replace {} with:": "Reemplazar {} por:", "Reset {name}": "Restablecer {name}", + "{name} value": "Valor de {name}", "Reset {} Channel": "Restablecer canal {}", "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{}", @@ -165,7 +167,7 @@ "{n} photo{}": "{n} foto{}", "{n} photo{} match · updates automatically as photos change": "{n} foto{0} coincidente{0} · se actualiza automáticamente cuando cambian las fotos", "{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos", - "{n} selected photos · {pages} pages": "{n} fotos seleccionadas · {pages} páginas", + "{n} selected photo{} · {pages} page{}": "{n} foto{0} seleccionada{0} · {pages} página{1}", "{pid} (missing)": "{pid} (falta)", "{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", @@ -251,6 +253,7 @@ "{what} merge cancelled": "Combinación {what} cancelada", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)", "New Folder in “{name}”": "Nueva carpeta en «{name}»", + "Created folder “{name}” in “{parent}”": "Carpeta «{name}» creada en «{parent}»", "Moved {} photo(s)": "{} foto(s) movida(s)", "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", @@ -285,7 +288,7 @@ "Skipped {problem}": "Omitido: {problem}", "…and {more} more": "…y {more} más", "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} photo{} in view · {slides} slide{} · {time}": "{n} foto{0} en la vista · {slides} diapositiva{1} · {time}", "{n} pages": "{n} páginas", "{n} JPEG files": "{n} archivos JPEG", "{s} s of AV1 video": "{s} s de vídeo AV1", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 723639515..2c9ec94b7 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -42,6 +42,7 @@ "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í", + "Import photos with File → Import Photos…, or drop them here": "Importar fotos con Archivo → Importar fotos… o soltarlas 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", @@ -118,6 +119,7 @@ "Auto-Tag Photos from Tracklog": "Etiquetar fotos automáticamente desde el registro de ruta", "Auto-Tag from Tracklog": "Etiquetar automáticamente desde el registro de ruta", "Auto-Tag from Tracklog…": "Etiquetar automáticamente desde el registro de ruta…", + "Add Locations": "Añadir ubicaciones", "Automatic": "Automático", "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.": "Automático muestra la vista Detalle al tamaño en que se ve, hasta los píxeles propios de la foto. Elegir un tamaño para limitarla: cuanto más pequeño, más rápido.", "Automatically Write Changes into XMP": "Escribir automáticamente los cambios en XMP", @@ -349,6 +351,9 @@ "Crop, Rotate, Geometry": "Recorte, rotación, geometría", "Cross Process": "Proceso cruzado", "Culling": "Selección", + "Cull": "Seleccionar", + "Create Stacks": "Crear pilas", + "Change Time": "Cambiar hora", "Curve": "Curva", "Custom": "Personalizado", "Custom template…": "Plantilla personalizada…", @@ -716,7 +721,6 @@ "Import as new": "Importar como nuevas", "Import from Device": "Importar desde dispositivo", "Import from Folder…": "Importar desde carpeta…", - "Import photos with File → Import Photos…, or drop them here": "Importar fotos con Archivo → Importar fotos… o soltarlas aquí", "Imported": "Importadas", "Imported Photos": "Fotos importadas", "Importing": "Importando", @@ -1543,6 +1547,7 @@ "Table": "Tabla", "Tags": "Etiquetas", "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Etiquetas: ver Etiquetas junto a la plantilla. Los archivos cambian de nombre en el disco (con sus archivos XMP complementarios); los nombres existentes reciben -1, -2…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "El botón Etiquetas muestra los marcadores que puedes usar en la plantilla, como {date} o {seq}. Cada archivo cambia de nombre en el disco junto con su archivo XMP; si el nombre ya existe, se le añade -1, -2…", "Take a snapshot": "Crear una instantánea", "Targeted Adjustment": "Ajuste selectivo", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Ajuste selectivo: arrastrar hacia arriba/abajo en la foto para ajustar los colores de ese punto", @@ -2381,5 +2386,8 @@ "Set up face recognition": "Configurar el reconocimiento facial", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "El reconocimiento facial necesita un modelo para sugerir nombres: una descarga única, en Ajustes.", "Turn on name suggestions": "Activar sugerencias de nombres", - "Set up name suggestions…": "Configurar sugerencias de nombres…" + "Set up name suggestions…": "Configurar sugerencias de nombres…", + "Top bar": "Barra superior", + "Tool strip": "Barra de herramientas", + "Bottom bar": "Barra inferior" } diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json index e559b8f9e..720519a1c 100644 --- a/crates/ui-egui/locales/fi-formats.json +++ b/crates/ui-egui/locales/fi-formats.json @@ -60,7 +60,7 @@ "Create Keyword Tag Inside “{name}”…": "Luo avainsanatunniste kohteen ”{name}” sisään…", "Create the keyword “{name}”": "Luo avainsana ”{name}”", "Create {n} Virtual Copies": "Luo {n} virtuaalikopiota", - "Creates {} stacks from {} of {scope}": "Luo pinoja: {} · kuvia: {} ({scope})", + "Creates {stacks} stack{} from {photos} of {scope}": "Luo pinoja: {stacks}, kuvia: {photos} ({scope}){:.0}", "Decoder: {reason}": "Purkaja: {reason}", "Delete \"{label}\"": "Poista ”{label}”", "Delete \"{label}\" (and the mask)": "Poista ”{label}” (ja maski)", @@ -95,6 +95,7 @@ "Healed {n} dust spot{}": "Korjattuja pölyhiukkasia: {n}{:.0}", "Import cancelled · {} photo{} added": "Tuonti peruttu · lisättyjä kuvia: {}{:.0}", "Import failed: {e}": "Tuonti epäonnistui: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Tuo kuvia valitsemalla Tiedosto → Tuo kuvia… ({}) tai pudota ne tähän", "Import warnings ({n}):\n{warnings}": "Tuontivaroitukset ({n}):\n{warnings}", "Imported {e} mask effect{} (Masking ▸ Effect)": "Tuotuja maskitehosteita: {e}{:.0} (Maskaus → Tehoste)", "Imported {name}": "{name} tuotu", @@ -137,6 +138,7 @@ "Moved; {} photo(s) relinked": "Siirretty; yhdistetty uudelleen kuvia: {}", "Named ({named_n})": "Nimetyt ({named_n})", "New Folder in “{name}”": "Uusi kansio kohteessa ”{name}”", + "Created folder “{name}” in “{parent}”": "Kansio ”{name}” luotu kohteeseen ”{parent}”", "On {have} of {of} selected photos": "{have} / {of} valitulla kuvalla", "Painting “{k}”: click photos to add or remove it · Esc stops": "Maalataan ”{k}”: napsauta kuvia lisätäksesi tai poistaaksesi sen · Esc lopettaa", "Paste into {n} photo{} — only settings that were copied are pasted": "Liitä kuviin: {n}{:.0} — vain kopioidut asetukset liitetään", @@ -168,6 +170,7 @@ "Rendering on the CPU: {why}": "Renderöidään suorittimella: {why}", "Replace {} with:": "Korvaa {} seuraavalla:", "Reset {name}": "Nollaa {name}", + "{name} value": "{name}: arvo", "Reset {} Channel": "Nollaa kanava {}", "Restore {}": "Palauta {}", "Restore {} Photo{}": "Palauta kuvia: {}{:.0}", @@ -243,15 +246,15 @@ "{n} originals opened in the other editor": "Toisessa muokkausohjelmassa avattuja alkuperäisiä: {n}", "{n} pages": "Sivuja: {n}", "{n} photos": "Kuvia: {n}", - "{n} photos in view · {slides} slides · {time}": "Kuvia näkymässä: {n} · dioja: {slides} · {time}", "{n} photos in {} are hidden by the filters above": "Yllä olevat suodattimet piilottavat kuvia kohteessa {}: {n}", "{n} photos opened for editing; each is stacked with its original": "Muokattavaksi avattuja kuvia: {n}; kukin pinotaan alkuperäisensä kanssa", "{n} photo{}": "Kuvia: {n}{:.0}", + "{n} photo{} in view · {slides} slide{} · {time}": "Kuvia näkymässä: {n} · dioja: {slides} · {time}{:.0}{:.0}", "{n} photo{} match · updates automatically as photos change": "Täsmääviä kuvia: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa", "{n} photo{} now · updates automatically as photos change": "Kuvia nyt: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa", "{n} preset{}": "Esiasetuksia: {n}{:.0}", "{n} raws can now be developed from their raw data": "Raw-kuvia, jotka voidaan nyt kehittää raakadatastaan: {n}", - "{n} selected photos · {pages} pages": "Valittuja kuvia: {n} · sivuja: {pages}", + "{n} selected photo{} · {pages} page{}": "Valittuja kuvia: {n} · sivuja: {pages}{:.0}{:.0}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Samankaltaisia kuvia: {n}{:.0} · Näytä → Tyhjennä suodattimet näyttääksesi kaikki", "{n} tasks are still running: {names}. Quitting stops them.": "Käynnissä olevia tehtäviä: {n}: {names}. Lopettaminen pysäyttää ne.", "{n} {noun}": "{n} {noun}", diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 1ab11d14e..ff7e03c06 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -45,6 +45,7 @@ "Add Catchlight": "Lisää silmänvalo", "Add Face Model…": "Lisää kasvomalli…", "Add Folder to Local": "Lisää kansio paikalliseen", + "Add Locations": "Lisää sijainnit", "Add Mask": "Lisää maski", "Add Object Click": "Lisää kohdenapsautus", "Add Point Color Sample": "Lisää pistevärin näyte", @@ -269,6 +270,7 @@ "Center": "Keskellä", "Change": "Muuta", "Change Component Mode": "Vaihda komponenttitilaa", + "Change Time": "Muuta aikaa", "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Tiedostonimissä kiellettyjä merkkejä (/ \\ : * ? \" < > |) korvataan merkillä -; olemassa oleva nimi saa lopun -1, -2….", "Charcoal": "Hiili", "Check All": "Valitse kaikki", @@ -429,6 +431,7 @@ "Create Smart Album from Filter…": "Luo älyalbumi suodattimesta…", "Create Smart Album…": "Luo älyalbumi…", "Create Stack": "Luo pino", + "Create Stacks": "Luo pinot", "Create Version": "Luo versio", "Create Version from Step": "Luo versio vaiheesta", "Create Virtual Copy": "Luo virtuaalikopio", @@ -443,6 +446,7 @@ "Crop, Rotate, Geometry": "Rajaus, kierto, geometria", "Cropped": "Rajattu", "Cross Process": "Ristiinkäsittely", + "Cull": "Karsi", "Culling": "Karsinta", "Curve": "Käyrä", "Curve channel": "Käyrän kanava", @@ -2008,6 +2012,7 @@ "That includes the photos in the folders inside it.": "Tämä sisältää myös sen sisällä olevien kansioiden kuvat.", "That key can't be used as a shortcut": "Tätä näppäintä ei voi käyttää pikanäppäimenä", "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 -malli on asennettu: Kohde- ja Kuvaile-maskit ovat valmiita.", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Tunnisteet-painike näyttää mallin paikkamerkit, kuten {date} tai {seq}. Jokainen tiedosto nimetään levyllä uudelleen yhdessä XMP-sivutiedostonsa kanssa; jo käytetty nimi saa lopuksi -1, -2…", "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Kohinanpoistomalli on asennettu ja käytössä: Tekoälykohinanpoiston Määrä-liukusäädin Tarkastelun alla toimii nyt", "The download didn't work:": "Lataus ei onnistunut:", "The ending screen, after the last photo.": "Lopetusnäyttö viimeisen kuvan jälkeen.", @@ -2348,5 +2353,8 @@ "Set up face recognition": "Määritä kasvojentunnistus", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Kasvojentunnistus tarvitsee mallin nimien ehdottamiseen: kertaluonteinen lataus Asetuksissa.", "Turn on name suggestions": "Ota nimiehdotukset käyttöön", - "Set up name suggestions…": "Määritä nimiehdotukset…" + "Set up name suggestions…": "Määritä nimiehdotukset…", + "Top bar": "Yläpalkki", + "Tool strip": "Työkalupalkki", + "Bottom bar": "Alapalkki" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 63e68f57e..fc46d653c 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -40,7 +40,7 @@ "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espace colorimétrique : Rec. 2020 {curve} (AVIF HDR)", "Couldn't open the editor: {e}": "Impossible d’ouvrir l’éditeur : {e}", "Create {n} Virtual Copies": "Créer {n} copies virtuelles", - "Creates {} stacks from {} of {scope}": "Crée {} piles à partir de {} parmi {scope}", + "Creates {stacks} stack{} from {photos} of {scope}": "Crée {stacks} pile{} à partir de {photos} parmi {scope}", "Decoder: {reason}": "Décodeur : {reason}", "Delete \"{label}\"": "Supprimer « {label} »", "Delete \"{label}\" (and the mask)": "Supprimer « {label} » (et le masque)", @@ -63,6 +63,7 @@ "Healed {n} dust spot{}": "{n} tache{} de poussière corrigée(s)", "Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)", "Import failed: {e}": "Échec de l’importation : {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Importez des photos avec Fichier → Importer des photos… ({}) ou déposez-les ici", "Import warnings ({n}):\n{warnings}": "Avertissements d’importation ({n}) :\n{warnings}", "Imported {n} point curve preset{}": "{n} préréglage{} de courbe à points importé(s)", "Imported {n} preset{}": "{n} préréglage{} importé(s)", @@ -117,6 +118,7 @@ "Rendering on the CPU: {why}": "Rendu sur le processeur : {why}", "Replace {} with:": "Remplacer {} par :", "Reset {name}": "Réinitialiser {name}", + "{name} value": "{name} : valeur", "Reset {} Channel": "Réinitialiser le canal {}", "Restore {}": "Restaurer {}", "Restore {} Photo{}": "Restaurer {} photo{}", @@ -165,7 +167,7 @@ "{n} photo{}": "{n} photo{}", "{n} photo{} match · updates automatically as photos change": "{n} photo{} correspondante(s) · mise à jour automatique lorsque les photos changent", "{n} photo{} now · updates automatically as photos change": "{n} photo{} actuellement · mise à jour automatique lorsque les photos changent", - "{n} selected photos · {pages} pages": "{n} photos sélectionnées · {pages} pages", + "{n} selected photo{} · {pages} page{}": "{n} photo{0} sélectionnée{0} · {pages} page{1}", "{pid} (missing)": "{pid} (manquant)", "{sel_n} selected · {counted}": "{sel_n} sélectionné(s) · {counted}", "{stage}… {:.0}%": "{stage}… {:.0} %", @@ -251,6 +253,7 @@ "{what} merge cancelled": "Fusion {what} annulée", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)", "New Folder in “{name}”": "Nouveau dossier dans « {name} »", + "Created folder “{name}” in “{parent}”": "Dossier « {name} » créé dans « {parent} »", "Moved {} photo(s)": "{} photo(s) déplacée(s)", "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} »", @@ -285,7 +288,7 @@ "Skipped {problem}": "Ignoré : {problem}", "…and {more} more": "…et {more} de plus", "Template preview: {name}": "Aperçu du modèle : {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos affichées · {slides} diapositives · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "{n} photo{0} affichée{0} · {slides} diapositive{1} · {time}", "{n} pages": "{n} pages", "{n} JPEG files": "{n} fichiers JPEG", "{s} s of AV1 video": "{s} s de vidéo AV1", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 68784e9a7..e9e237c0c 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -42,6 +42,7 @@ "Add to Local": "Ajouter à Local", "Add to My Photos": "Ajouter à Mes photos", "Can't be moved here": "Impossible de les déplacer ici", + "Import photos with File → Import Photos…, or drop them here": "Importez des photos avec Fichier → Importer des photos… ou déposez-les 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", @@ -117,6 +118,7 @@ "Auto-Tag Photos from Tracklog": "Géolocaliser automatiquement les photos à partir d'une trace GPS", "Auto-Tag from Tracklog": "Géolocaliser automatiquement à partir d'une trace GPS", "Auto-Tag from Tracklog…": "Géolocaliser automatiquement à partir d'une trace GPS…", + "Add Locations": "Ajouter les lieux", "Automatic": "Automatique", "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.": "Automatique calcule la vue Détail à la taille affichée, jusqu'aux pixels de la photo elle-même. Choisissez une taille pour la plafonner : plus petit, c'est plus rapide.", "Automatically Write Changes into XMP": "Écrire automatiquement les modifications dans XMP", @@ -332,6 +334,9 @@ "Crop, Rotate, Geometry": "Recadrage, rotation, géométrie", "Cross Process": "Traitement croisé", "Culling": "Tri", + "Cull": "Trier", + "Create Stacks": "Créer les piles", + "Change Time": "Changer l'heure", "Curve": "Courbe", "Custom": "Personnalisé", "Custom template…": "Modèle personnalisé…", @@ -674,7 +679,6 @@ "Import as new": "Importer comme nouvelles", "Import from Device": "Importer depuis un appareil", "Import from Folder…": "Importer depuis un dossier…", - "Import photos with File → Import Photos…, or drop them here": "Importez des photos avec Fichier → Importer des photos… ou déposez-les ici", "Imported": "Importées", "Imported Photos": "Photos importées", "Importing": "Importation en cours", @@ -1458,6 +1462,7 @@ "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…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Le bouton Balises liste les éléments utilisables dans le modèle, comme {date} ou {seq}. Chaque fichier est renommé sur le disque avec son fichier annexe XMP ; un nom déjà pris reçoit -1, -2…", "Take a snapshot": "Créer un instantané", "Targeted Adjustment": "Réglage ciblé", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster les couleurs à cet endroit", @@ -3564,5 +3569,8 @@ "you come back; un-zoom it and it goes back to the size it had before.": "vous revenez ; dézoomez-la et elle retrouve la taille qu'elle avait avant.", "yours (issues #610, #1066). They were ordinary `.jpg` files in the library's `thumbs` folder, which is usually": "vous appartenant (tickets #610, #1066). C'étaient de simples fichiers `.jpg` dans le dossier `thumbs` de la bibliothèque, qui se trouve généralement", "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": "zoom ; LightCraft se fie donc à la taille de la fenêtre et, dans le doute, la prend pour une simple fenêtre de cette taille : elle", - "⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.": "⇧-clic et la ligne passe progressivement des anciennes valeurs aux nouvelles. La ligne entière forme une seule étape d'annulation." + "⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.": "⇧-clic et la ligne passe progressivement des anciennes valeurs aux nouvelles. La ligne entière forme une seule étape d'annulation.", + "Top bar": "Barre supérieure", + "Tool strip": "Barre d’outils", + "Bottom bar": "Barre inférieure" } diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json index 529291e05..22eb2d49e 100644 --- a/crates/ui-egui/locales/id-formats.json +++ b/crates/ui-egui/locales/id-formats.json @@ -35,6 +35,7 @@ "Exporting {} of {total}": "Mengekspor {} dari {total}", "Filter bar — {active} active filter{}": "Bilah filter — {active} filter aktif{:.0}", "Healed {n} dust spot{}": "{n} bintik debu diperbaiki{:.0}", + "Import photos with File → Import Photos… ({}), or drop them here": "Impor foto melalui File → Impor foto… ({}), atau seret ke sini", "Imported {n} point curve preset{}": "{n} preset kurva titik diimpor{:.0}", "Imported {n} preset{}": "{n} preset{:.0} diimpor", "Imported {n} preset{}, {f} file{} not readable": "{n} preset diimpor{0:.0}, {f} file tidak dapat dibaca{1:.0}", @@ -137,6 +138,7 @@ "Label Set: {name}": "Set label: {name}", "Quick Develop: {name}": "Olah cepat: {name}", "Reset {name}": "Atur ulang {name}", + "{name} value": "Nilai {name}", "Updated “{name}”": "“{name}” diperbarui", "{month}": "{month:02}", "{month} {year}": "{month:02}/{year}", @@ -254,6 +256,7 @@ "{what} merge cancelled": "Penggabungan {what} dibatalkan", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "{n} foto Lokal{:.0} dilewati: album hanya dapat berisi foto pustaka (tambahkan ke Foto saya terlebih dahulu)", "New Folder in “{name}”": "Folder baru di “{name}”", + "Created folder “{name}” in “{parent}”": "Folder “{name}” dibuat di “{parent}”", "Moved {} photo(s)": "{} foto dipindahkan", "not added to Apple Photos: {e}": "tidak ditambahkan ke Apple Photos: {e}", "{n} added to Apple Photos album “{album}”": "{n} ditambahkan ke album Apple Photos “{album}”", @@ -314,5 +317,8 @@ "Panel layout could not be restored: {error}": "Tata letak panel tidak dapat dipulihkan: {error}", "Panel move was not applied: {error}": "Pemindahan panel tidak diterapkan: {error}", "{name} is installed": "{name} sudah terinstal", - "{name} is installed and in use: face recognition is on": "{name} sudah terinstal dan digunakan: pengenalan wajah aktif" + "{name} is installed and in use: face recognition is on": "{name} sudah terinstal dan digunakan: pengenalan wajah aktif", + "Creates {stacks} stack{} from {photos} of {scope}": "Membuat {stacks} tumpukan dari {photos} foto ({scope}){:.0}", + "{n} selected photo{} · {pages} page{}": "{n} foto dipilih · {pages} halaman{:.0}{:.0}", + "{n} photo{} in view · {slides} slide{} · {time}": "{n} foto di tampilan · {slides} slide · {time}{:.0}{:.0}" } diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index f1a754d8b..1c70f2634 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -45,6 +45,7 @@ "Add Catchlight": "Tambahkan catchlight", "Add Face Model…": "Tambahkan model wajah…", "Add Folder to Local": "Tambahkan folder ke Lokal", + "Add Locations": "Tambahkan lokasi", "Add Mask": "Tambahkan masker", "Add Object Click": "Tambahkan titik objek", "Add Point Color Sample": "Tambahkan sampel warna titik", @@ -273,6 +274,7 @@ "Center": "Tengah", "Change": "Ubah", "Change Component Mode": "Ubah mode komponen", + "Change Time": "Ubah waktu", "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Karakter yang tidak boleh ada di nama file (/ \\ : * ? \" < > |) diganti dengan -; nama yang sudah ada diberi akhiran -1, -2….", "Charcoal": "Abu-abu arang", "Check All": "Centang semua", @@ -428,6 +430,7 @@ "Create Smart Album from Filter…": "Buat album pintar dari filter…", "Create Smart Album…": "Buat album pintar…", "Create Stack": "Buat tumpukan", + "Create Stacks": "Buat tumpukan", "Create Version": "Buat versi", "Create Version from Step": "Buat versi dari langkah ini", "Create Virtual Copy": "Buat salinan virtual", @@ -442,6 +445,7 @@ "Crop, Rotate, Geometry": "Pangkas, putar, geometri", "Cropped": "Dipangkas", "Cross Process": "Cross Process", + "Cull": "Seleksi", "Culling": "Seleksi foto", "Curve": "Kurva", "Custom": "Kustom", @@ -2008,6 +2012,7 @@ "That includes the photos in the folders inside it.": "Termasuk foto di dalam subfoldernya.", "That key can't be used as a shortcut": "Tombol itu tidak dapat digunakan sebagai pintasan", "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 sudah terinstal: masker Objek dan Deskripsi siap digunakan.", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Tombol Tag menampilkan placeholder untuk templat, seperti {date} atau {seq}. Setiap file diganti namanya di disk bersama file sidecar XMP-nya; nama yang sudah dipakai mendapat -1, -2…", "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model pengurangan noise sudah terinstal dan digunakan: slider Kekuatan pengurangan noise AI di panel Detail kini dapat dipakai", "The download didn't work:": "Unduhan gagal:", "The ending screen, after the last photo.": "Layar penutup, setelah foto terakhir.", @@ -2362,5 +2367,8 @@ "Set up face recognition": "Siapkan pengenalan wajah", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Pengenalan wajah memerlukan model untuk menyarankan nama: sekali unduh, di Pengaturan.", "Turn on name suggestions": "Aktifkan saran nama", - "Set up name suggestions…": "Siapkan saran nama…" + "Set up name suggestions…": "Siapkan saran nama…", + "Top bar": "Bilah atas", + "Tool strip": "Bilah alat", + "Bottom bar": "Bilah bawah" } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 077543144..4437aa644 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -39,7 +39,7 @@ "Collapse {name}": "Comprimi {name}", "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})", + "Creates {stacks} stack{} from {photos} of {scope}": "Crea pile: {stacks} (da {photos} di {scope}){:.0}", "Decoder: {reason}": "Decodificatore: {reason}", "Delete \"{label}\"": "Elimina \"{label}\"", "Delete \"{label}\" (and the mask)": "Elimina \"{label}\" (e la maschera)", @@ -62,6 +62,7 @@ "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 photos with File → Import Photos… ({}), or drop them here": "Importa le foto con File → Importa foto… ({}), oppure trascinale qui", "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.", @@ -115,6 +116,7 @@ "Rendering on the CPU: {why}": "Rendering sulla CPU: {why}", "Replace {} with:": "Sostituisci {} con:", "Reset {name}": "Reimposta {name}", + "{name} value": "Valore di {name}", "Reset {} Channel": "Reimposta canale {}", "Restore {}": "Ripristina {}", "Restore {} Photo{}": "Ripristina {} foto{:.0}", @@ -243,6 +245,7 @@ "{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}”", + "Created folder “{name}” in “{parent}”": "Cartella “{name}” creata in “{parent}”", "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}", @@ -289,7 +292,7 @@ "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", "Inside: {names}": "Al suo interno: {names}", "Template preview: {name}": "Anteprima del modello: {name}", - "{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}{:.0}{:.0}", "Applied {name}": "{name} applicato", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” è già una persona. Unirle ne crea una sola; Annulla le separa di nuovo.", "{n} originals opened in the other editor": "Originali aperti nell'altro editor: {n}", @@ -297,7 +300,7 @@ "Effect: {name}": "Effetto: {name}", " in {g} group{}": " in gruppi: {g}{:.0}", "Color space: Rec. 2020 {curve} (HDR AVIF)": "Spazio colore: Rec. 2020 {curve} (AVIF HDR)", - "{n} selected photos · {pages} pages": "Foto selezionate: {n} · pagine: {pages}", + "{n} selected photo{} · {pages} page{}": "Foto selezionate: {n} · pagine: {pages}{:.0}{:.0}", "Already in “{}”": "Già in “{}”", "Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}", "Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index caaa98a68..5da41269a 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -42,6 +42,7 @@ "Add to Local": "Aggiungi a Locale", "Add to My Photos": "Aggiungi a Le mie foto", "Can't be moved here": "Non si possono spostare qui", + "Import photos with File → Import Photos…, or drop them here": "Importa le foto con File → Importa foto…, oppure trascinale qui", "This row covers more than one disk: drop on a folder inside it": "Questa riga comprende più di un disco: rilascia su una cartella al suo interno", "Local photos aren't in the library yet: Add to My Photos first, then move them": "Le foto locali non sono ancora nella libreria: prima Aggiungi a Le mie foto, poi spostale", "Add to Selected Photos": "Aggiungi alle foto selezionate", @@ -118,6 +119,7 @@ "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…", + "Add Locations": "Aggiungi posizioni", "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", @@ -333,6 +335,9 @@ "Crop, Rotate, Geometry": "Ritaglio, rotazione, geometria", "Cross Process": "Processo incrociato", "Culling": "Selezione", + "Cull": "Seleziona", + "Create Stacks": "Crea pile", + "Change Time": "Cambia ora", "Curve": "Curva", "Custom": "Personalizzato", "Custom template…": "Modello personalizzato…", @@ -675,7 +680,6 @@ "Import as new": "Importa come nuova", "Import from Device": "Importa da dispositivo", "Import from Folder…": "Importa da cartella…", - "Import photos with File → Import Photos…, or drop them here": "Importa le foto con File → Importa foto…, oppure trascinale qui", "Imported": "Importato", "Imported Photos": "Foto importate", "Importing": "Importazione in corso", @@ -1488,6 +1492,7 @@ "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…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Il pulsante Tag elenca i segnaposto per il modello, come {date} o {seq}. Ogni file viene rinominato sul disco insieme al suo file XMP collaterale; un nome già in uso riceve -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", @@ -2547,5 +2552,8 @@ "source": "origine", "The active photo's capture time can't be read": "Impossibile leggere l'ora di scatto della foto attiva", "The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.": "L'elenco dei luoghi comprende i centri con almeno 15.000 abitanti: per uno più piccolo, Città indica il centro più grande più vicino.", - "Unit": "Unità" + "Unit": "Unità", + "Top bar": "Barra superiore", + "Tool strip": "Barra degli strumenti", + "Bottom bar": "Barra inferiore" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index d41f6cc9b..2fb625ff6 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -35,6 +35,7 @@ "Exporting {} of {total}": "書き出し中:{total}枚中{}枚", "Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}", "Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}", + "Import photos with File → Import Photos… ({}), or drop them here": "「ファイル → 写真を読み込む…」({})から読み込むか、ここに写真をドロップしてください", "Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}", "Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}", "Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}", @@ -101,7 +102,7 @@ "{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} selected photos · {pages} pages": "選択した写真 {n} 枚 · {pages} ページ", + "{n} selected photo{} · {pages} page{}": "選択した写真 {n} 枚 · {pages} ページ{:.0}{:.0}", "{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{what} merge added": "{what}合成を追加しました", @@ -137,6 +138,7 @@ "Label Set: {name}": "ラベルセット:{name}", "Quick Develop: {name}": "クイック現像: {name}", "Reset {name}": "{name}をリセット", + "{name} value": "{name}の値", "Updated “{name}”": "「{name}」を更新しました", "{month}": "{month}月", "{month} {year}": "{year}年{month}月", @@ -158,7 +160,7 @@ "Auto Import: added {} photo{}": "自動読み込み:{}枚の写真を追加しました{:.0}", "Building previews for {} photos…": "{}枚のプレビューを作成中…", "Couldn't open the editor: {e}": "エディターを開けませんでした:{e}", - "Creates {} stacks from {} of {scope}": "{scope}の{1}枚から{0}個のスタックを作成します", + "Creates {stacks} stack{} from {photos} of {scope}": "{scope}の{photos}枚から{stacks}個のスタックを作成します{:.0}", "Done ({what}): {done} photo{plural}": "完了({what}):{done}枚{plural:.0}", "Focus {:.0}": "ピント {:.0}", "Folder “{}” (and its subfolders)": "フォルダ「{}」(サブフォルダを含む)", @@ -255,6 +257,7 @@ "{what} merge cancelled": "{what}合成をキャンセルしました", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)", "New Folder in “{name}”": "「{name}」に新規フォルダ", + "Created folder “{name}” in “{parent}”": "「{parent}」にフォルダ「{name}」を作成しました", "Moved {} photo(s)": "{} 枚の写真を移動しました", "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", @@ -289,7 +292,7 @@ "Skipped {problem}": "スキップ: {problem}", "…and {more} more": "…ほか {more} 件", "Template preview: {name}": "テンプレートのプレビュー: {name}", - "{n} photos in view · {slides} slides · {time}": "表示中の写真 {n} 枚 · スライド {slides} 枚 · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "表示中の写真 {n} 枚 · スライド {slides} 枚 · {time}{:.0}{:.0}", "{n} pages": "{n} ページ", "{n} JPEG files": "JPEG ファイル {n} 個", "{s} s of AV1 video": "AV1 ビデオ {s} 秒", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 6fd559dbe..942f4fa1d 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -43,6 +43,7 @@ "Add to Local": "ローカルに追加", "Add to My Photos": "マイフォトに追加", "Can't be moved here": "ここには移動できません", + "Import photos with File → Import Photos…, or drop them 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": "選択した写真に追加", @@ -120,6 +121,7 @@ "Auto-Tag Photos from Tracklog": "GPS軌跡から写真に位置情報を自動で付ける", "Auto-Tag from Tracklog": "GPS軌跡から位置情報を付ける", "Auto-Tag from Tracklog…": "GPS軌跡から位置情報を付ける…", + "Add Locations": "位置情報を追加", "Automatic": "自動", "Automatically Write Changes into XMP": "変更をXMPに自動保存", "Automatically write changes into XMP sidecars": "変更をXMPサイドカーファイルに自動保存", @@ -324,6 +326,9 @@ "Crop, Rotate, Geometry": "切り抜き・回転・ジオメトリ", "Cross Process": "クロスプロセス", "Culling": "写真の選別", + "Cull": "選別", + "Create Stacks": "スタックを作成", + "Change Time": "時刻を変更", "Curve": "カーブ", "Custom": "カスタム", "Custom template…": "カスタムテンプレート…", @@ -837,7 +842,6 @@ "Import Profiles & Presets…": "プロファイルとプリセットを読み込む…", "Import from Device": "デバイスから読み込む", "Import from Folder…": "フォルダから読み込む…", - "Import photos with File → Import Photos…, or drop them here": "「ファイル → 写真を読み込む…」から読み込むか、ここに写真をドロップしてください", "Imported Photos": "写真を読み込み", "Importing": "読み込み中", "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).": "読み込みでは写真を検索しますが、フォルダはローカルに追加されません。読み込まずに作業する場合は「ローカル → フォルダを参照…」を使ってください", @@ -1400,6 +1404,7 @@ "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…を付けます", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "「タグ」ボタンで、{date} や {seq} などテンプレートに使えるタグの一覧を表示します。各ファイルは XMP ファイルとともにディスク上で名前が変更され、同名のものには -1、-2… が付きます", "Targeted Adjustment": "ターゲット調整", "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": "写真の上を上下にドラッグして、その部分のカーブを調整", @@ -2379,5 +2384,8 @@ "Set up face recognition": "顔認識を設定", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "顔認識で名前の候補を表示するにはモデルが必要です。設定から一度だけダウンロードします。", "Turn on name suggestions": "名前の候補をオンにする", - "Set up name suggestions…": "名前の候補を設定…" + "Set up name suggestions…": "名前の候補を設定…", + "Top bar": "上部バー", + "Tool strip": "ツールバー", + "Bottom bar": "下部バー" } diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index a3bcfc952..329662ac1 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -52,6 +52,7 @@ "Create Keyword Tag Inside “{name}”…": "საკვანძო სიტყვის შექმნა „{name}“-ში…", "Create {n} Virtual Copies": "ვირტუალური ასლების შექმნა: {n}", "Creates {} stacks from {} of {scope}": "შეიქმნება დასტა: {} — {}-დან ({scope})", + "Creates {stacks} stack{} from {photos} of {scope}": "შეიქმნება დასტა: {stacks} — {photos}-დან ({scope}){:.0}", "Decoder: {reason}": "დეკოდერი: {reason}", "Delete \"{label}\"": "„{label}“-ის წაშლა", "Delete \"{label}\" (and the mask)": "„{label}“-ის წაშლა (ნიღაბთან ერთად)", @@ -79,6 +80,7 @@ "Healed {n} dust spot{}": "აღდგენილია მტვრის ლაქა: {n}{:.0}", "Import cancelled · {} photo{} added": "იმპორტი გაუქმდა · დაემატა ფოტო: {}{:.0}", "Import failed: {e}": "იმპორტი ვერ მოხერხდა: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "ფოტოების იმპორტისთვის გამოიყენეთ ფაილი → ფოტოების იმპორტი… ({}), ან ჩააგდეთ ისინი აქ", "Import warnings ({n}):\n{warnings}": "იმპორტის გაფრთხილებები ({n}):\n{warnings}", "Imported {n} point curve preset{}": "იმპორტირებულია წერტილოვანი მრუდის პრესეტი: {n}{:.0}", "Imported {n} preset{}": "იმპორტირებულია პრესეტი: {n}{:.0}", @@ -111,6 +113,7 @@ "Moved; {} photo(s) relinked": "გადატანილია; ხელახლა დაკავშირდა ფოტო: {}", "Named ({named_n})": "სახელიანი ({named_n})", "New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში", + "Created folder “{name}” in “{parent}”": "საქაღალდე „{name}“ შეიქმნა „{parent}“-ში", "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} — ჩაისმება მხოლოდ კოპირებული პარამეტრები", @@ -140,6 +143,7 @@ "Rendering on the CPU: {why}": "რენდერი CPU-ზე: {why}", "Replace {} with:": "{}-ის ჩანაცვლება:", "Reset {name}": "ჩამოყრა: {name}", + "{name} value": "{name}: მნიშვნელობა", "Reset {} Channel": "არხის ჩამოყრა: {}", "Restore {}": "აღდგენა: {}", "Restore {} Photo{}": "ფოტოს აღდგენა: {}{:.0}", @@ -288,6 +292,7 @@ "{n} JPEG files": "JPEG ფაილი: {n}", "{n} pages": "გვერდი: {n}", "{n} photos in view · {slides} slides · {time}": "ფოტო ხედში: {n} · სლაიდი: {slides} · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "ფოტო ხედში: {n} · სლაიდი: {slides} · {time}{:.0}{:.0}", "{n} preset{}": "პრესეტი: {n}{:.0}", "{n} raws can now be developed from their raw data": "RAW ფაილების ({n}) დამუშავება ახლა მათი RAW მონაცემებიდან შეიძლება", "{photos} catalog records processed; {collections} collections.": "დამუშავდა კატალოგის ჩანაწერი: {photos}; კოლექცია: {collections}.", @@ -298,6 +303,7 @@ "…and {more} more": "…და კიდევ {more}", "Color space: Rec. 2020 {curve} (HDR AVIF)": "ფერთა სივრცე: Rec. 2020 {curve} (HDR AVIF)", "{n} selected photos · {pages} pages": "მონიშნული ფოტო: {n} · გვერდი: {pages}", + "{n} selected photo{} · {pages} page{}": "არჩეული ფოტო: {n} · გვერდი: {pages}{:.0}{:.0}", "Already in “{}”": "უკვე „{}“-შია", "Move {n} photo{} to “{}”": "ფოტოს გადატანა „{1}“-ში: {n}{0:.0}", "Move {n} photo{} to “{}”?": "გადავიდეს „{1}“-ში ფოტო: {n}{0:.0}?", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 59ad49cb1..160cbc65b 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -229,6 +229,7 @@ "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…": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან…", + "Add Locations": "მდებარეობების დამატება", "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.": "ავტომატური რეჟიმი იყენებს ვიდეობარათს, როცა ის პროცესორზე სწრაფია: ორივეს დრო ამ კომპიუტერზე იზომება, როცა ფოტო პირველად მზადდება.", @@ -506,6 +507,9 @@ "Cropped": "ჩამოჭრილი", "Cross Process": "ჯვარედინი დამუშავება", "Culling": "გადარჩევა", + "Cull": "გადარჩევა", + "Create Stacks": "დასტების შექმნა", + "Change Time": "დროის შეცვლა", "Culling.": "გადარჩევა.", "Curve": "მრუდი", "Custom": "მორგებული", @@ -2074,6 +2078,7 @@ "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…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "ღილაკი „ტეგები“ აჩვენებს შაბლონში გამოსაყენებელ ჩანაცვლებებს, მაგალითად {date} ან {seq}. თითოეულ ფაილს სახელი დისკზე ეცვლება მის XMP თანმხლებ ფაილთან ერთად; დაკავებულ სახელს ემატება -1, -2…", "Take a snapshot": "მომენტალური ასლის შექმნა", "Targeted Adjustment": "მიზნობრივი კორექცია", "Targeted adjustment on: drag up or down on the photo · Esc to stop": "მიზნობრივი კორექცია ჩართულია: გადაათრიეთ ფოტოზე ზემოთ ან ქვემოთ · Esc — შეწყვეტა", @@ -3650,5 +3655,8 @@ "Set up face recognition": "სახეების ამოცნობის დაყენება", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "სახეების ამოცნობას სახელების შესათავაზებლად მოდელი სჭირდება: ერთჯერადი ჩამოტვირთვა, პარამეტრებში.", "Turn on name suggestions": "სახელების შეთავაზებების ჩართვა", - "Set up name suggestions…": "სახელების შეთავაზებების დაყენება…" + "Set up name suggestions…": "სახელების შეთავაზებების დაყენება…", + "Top bar": "ზედა ზოლი", + "Tool strip": "ხელსაწყოების ზოლი", + "Bottom bar": "ქვედა ზოლი" } diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index b701eda3a..3f0fd43a2 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -39,7 +39,7 @@ "Collapse {name}": "Zwiń {name}", "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}", + "Creates {stacks} stack{} from {photos} of {scope}": "Tworzy stosy: {stacks} z {photos} w zakresie: {scope}{:.0}", "Decoder: {reason}": "Dekoder: {reason}", "Delete \"{label}\"": "Usuń „{label}”", "Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)", @@ -62,6 +62,7 @@ "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}", + "Import photos with File → Import Photos… ({}), or drop them here": "Importuj zdjęcia przez Plik → Importuj zdjęcia… ({}) lub przeciągnij je tutaj", "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}", @@ -108,6 +109,7 @@ "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", "Replace {} with:": "Zamień {} na:", "Reset {name}": "Wyzeruj {name}", + "{name} value": "{name}: wartość", "Reset {} Channel": "Wyzeruj kanał {}", "Restore {}": "Przywróć {}", "Restore {} Photo{}": "Przywróć zdjęcia: {}{:.0}", @@ -239,6 +241,7 @@ "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}”", + "Created folder “{name}” in “{parent}”": "Utworzono folder „{name}” w „{parent}”", "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.", @@ -277,7 +280,7 @@ "Skipped {problem}": "Pominięto: {problem}", "…and {more} more": "…i jeszcze {more}", "Template preview: {name}": "Podgląd szablonu: {name}", - "{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}{:.0}{:.0}", "{n} pages": "Strony: {n}", "{n} JPEG files": "Pliki JPEG: {n}", "{s} s of AV1 video": "Wideo AV1: {s} s", @@ -296,7 +299,7 @@ "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano scalenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer", "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano scalenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar: {full})", "Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}", + "{n} selected photo{} · {pages} page{}": "Wybrane zdjęcia: {n} · stron: {pages}{:.0}{:.0}", "Already in “{}”": "Już w „{}”", "Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index d86475221..2bfb3f896 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -114,6 +114,7 @@ "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…", + "Add Locations": "Dodaj lokalizacje", "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", @@ -321,6 +322,9 @@ "Crop, Rotate, Geometry": "Kadrowanie, obrót, geometria", "Cross Process": "Obróbka krzyżowa", "Culling": "Selekcja zdjęć", + "Cull": "Selekcjonuj", + "Create Stacks": "Utwórz stosy", + "Change Time": "Zmień czas", "Curve": "Krzywa", "Custom": "Niestandardowe", "Custom template…": "Szablon niestandardowy…", @@ -1954,6 +1958,7 @@ "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 Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Przycisk Tagi pokazuje symbole, których można użyć w szablonie, np. {date} lub {seq}. Nazwa każdego pliku na dysku jest zmieniana razem z jego plikiem XMP; zajęta nazwa dostaje 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", @@ -2306,5 +2311,8 @@ "Set up face recognition": "Skonfiguruj rozpoznawanie twarzy", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Rozpoznawanie twarzy potrzebuje modelu, aby proponować imiona: jednorazowe pobranie w Ustawieniach.", "Turn on name suggestions": "Włącz propozycje imion", - "Set up name suggestions…": "Skonfiguruj propozycje imion…" + "Set up name suggestions…": "Skonfiguruj propozycje imion…", + "Top bar": "Górny pasek", + "Tool strip": "Pasek narzędzi", + "Bottom bar": "Dolny pasek" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 6ebb3288c..c26b2e281 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -45,6 +45,7 @@ "Filter bar — {active} active filter{}": "Barra de filtros — {active} filtro{0} ativo{0}", "Healed {n} dust spot{}": "{n} mancha{0} de poeira recuperada{0}", "Import cancelled · {} photo{} added": "Importação cancelada · {0} foto{1} adicionada{1}", + "Import photos with File → Import Photos… ({}), or drop them here": "Importe fotos com Arquivo → Importar fotos… ({}) ou solte-as aqui", "Imported {n} point curve preset{}": "{n} predefinição(ões) de curva de pontos importada(s){:.0}", "Imported {n} preset{}": "{n} predefinição(ões) importada(s){:.0}", "Imported {n} preset{}, {f} file{} not readable": "{n} predefinição(ões) importada(s){:.0}, {f} arquivo(s) não legível(is){:.0}", @@ -86,6 +87,7 @@ "Rename “{name}”": "Renomear “{name}”", "Rendering on the CPU: {why}": "Renderizando na CPU: {why}", "Reset {name}": "Redefinir {name}", + "{name} value": "Valor de {name}", "Reset {} Channel": "Redefinir canal {}", "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{}", @@ -125,7 +127,7 @@ "{n} photo{}": "{n} foto{}", "{n} photo{} match · updates automatically as photos change": "{n} foto{0} correspondente{0} · atualiza automaticamente conforme as fotos mudam", "{n} photo{} now · updates automatically as photos change": "{n} foto{} agora · atualiza automaticamente conforme as fotos mudam", - "{n} selected photos · {pages} pages": "{n} fotos selecionadas · {pages} páginas", + "{n} selected photo{} · {pages} page{}": "{n} foto{0} selecionada{0} · {pages} página{1}", "{sel_n} selected · {counted}": "{sel_n} selecionadas · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} foto{}", @@ -158,7 +160,7 @@ "Auto Import: added {} photo{}": "Importação automática: {} foto{1} adicionada{1}", "Building previews for {} photos…": "Gerando prévias de {} fotos…", "Couldn't open the editor: {e}": "Não foi possível abrir o editor: {e}", - "Creates {} stacks from {} of {scope}": "Cria {} pilhas a partir de {} de {scope}", + "Creates {stacks} stack{} from {photos} of {scope}": "Cria {stacks} pilha{} a partir de {photos} de {scope}", "Done ({what}): {done} photo{plural}": "Concluído ({what}): {done} foto{plural}", "Focus {:.0}": "Foco {:.0}", "Folder “{}” (and its subfolders)": "Pasta “{}” (e suas subpastas)", @@ -255,6 +257,7 @@ "{what} merge cancelled": "{what} mesclagem cancelada", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)", "New Folder in “{name}”": "Nova pasta em “{name}”", + "Created folder “{name}” in “{parent}”": "Pasta “{name}” criada em “{parent}”", "Moved {} photo(s)": "{} foto(s) movida(s)", "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", @@ -289,7 +292,7 @@ "Skipped {problem}": "Ignorado: {problem}", "…and {more} more": "…e mais {more}", "Template preview: {name}": "Prévia do modelo: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} fotos na visualização · {slides} slides · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "{n} foto{0} na visualização · {slides} slide{1} · {time}", "{n} pages": "{n} páginas", "{n} JPEG files": "{n} arquivos JPEG", "{s} s of AV1 video": "{s} s de vídeo AV1", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c475d67e3..3d27fa1fa 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -44,6 +44,7 @@ "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á", + "Import photos with File → Import Photos…, or drop them here": "Importe fotos com Arquivo → Importar fotos… ou solte-as aqui", "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", @@ -123,6 +124,7 @@ "Auto-Tag Photos from Tracklog": "Etiquetar fotos automaticamente pelo tracklog", "Auto-Tag from Tracklog": "Etiquetar automaticamente pelo tracklog", "Auto-Tag from Tracklog…": "Etiquetar automaticamente pelo tracklog…", + "Add Locations": "Adicionar locais", "Automatic": "Automático", "Automatically Write Changes into XMP": "Gravar alterações automaticamente no XMP", "Automatically write changes into XMP sidecars": "Grava automaticamente as alterações nos arquivos XMP auxiliares", @@ -340,6 +342,9 @@ "Crop, Rotate, Geometry": "Recorte, rotação, geometria", "Cross Process": "Processamento cruzado", "Culling": "Triagem", + "Cull": "Fazer triagem", + "Create Stacks": "Criar pilhas", + "Change Time": "Alterar hora", "Curve": "Curva", "Custom": "Personalizado", "Custom template…": "Modelo personalizado…", @@ -683,7 +688,6 @@ "Import as new": "Importar como novas", "Import from Device": "Importar de dispositivo", "Import from Folder…": "Importar de pasta…", - "Import photos with File → Import Photos…, or drop them here": "Importe fotos com Arquivo → Importar fotos… ou solte-as aqui", "Imported": "Importados", "Imported Photos": "Fotos importadas", "Importing": "Importando", @@ -1482,6 +1486,7 @@ "Table": "Tabela", "Tags": "Marcadores", "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Marcadores: consulte Marcadores ao lado do modelo. Os arquivos são renomeados no disco (com seus sidecars XMP); nomes existentes recebem -1, -2…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "O botão Marcadores lista os itens que você pode usar no modelo, como {date} ou {seq}. Cada arquivo é renomeado no disco junto com seu sidecar XMP; um nome já usado recebe -1, -2…", "Take a snapshot": "Tirar um instantâneo", "Targeted Adjustment": "Ajuste direcionado", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Ajuste direcionado: arraste para cima/baixo na foto para ajustar as cores ali", @@ -2225,5 +2230,8 @@ "Set up face recognition": "Configurar o reconhecimento facial", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "O reconhecimento facial precisa de um modelo para sugerir nomes: um download único, em Configurações.", "Turn on name suggestions": "Ativar sugestões de nomes", - "Set up name suggestions…": "Configurar sugestões de nomes…" + "Set up name suggestions…": "Configurar sugestões de nomes…", + "Top bar": "Barra superior", + "Tool strip": "Barra de ferramentas", + "Bottom bar": "Barra inferior" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index f8bf88a88..4be240ec5 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -42,7 +42,7 @@ "Color space: Rec. 2020 {curve} (HDR AVIF)": "Цветовое пространство: Rec. 2020 {curve} (HDR AVIF)", "Couldn't open the editor: {e}": "Не удалось открыть редактор: {e}", "Create {n} Virtual Copies": "Создать виртуальные копии: {n}", - "Creates {} stacks from {} of {scope}": "Будет создано стопок: {} из {} фото ({scope})", + "Creates {stacks} stack{} from {photos} of {scope}": "Будет создано стопок: {stacks} из {photos} фото ({scope}){:.0}", "Decoder: {reason}": "Декодер: {reason}", "Delete \"{label}\"": "Удалить «{label}»", "Delete \"{label}\" (and the mask)": "Удалить «{label}» (и маску)", @@ -66,6 +66,7 @@ "Healed {n} dust spot{}": "Убрано пылинок: {n}{:.0}", "Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}", "Import failed: {e}": "Ошибка импорта: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Импортируйте фото через Файл → Импорт фото… ({}) или перетащите их сюда", "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.", @@ -122,6 +123,7 @@ "Rendering on the CPU: {why}": "Рендеринг на CPU: {why}", "Replace {} with:": "Заменить {} на:", "Reset {name}": "Сбросить: {name}", + "{name} value": "{name}: значение", "Reset {} Channel": "Сбросить канал: {}", "Restore {}": "Восстановить {}", "Restore {} Photo{}": "Восстановить фото: {}{:.0}", @@ -170,7 +172,7 @@ "{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} selected photos · {pages} pages": "Выбрано фото: {n} · страниц: {pages}", + "{n} selected photo{} · {pages} page{}": "Выбрано фото: {n} · страниц: {pages}{:.0}{:.0}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Похожих фото: {n}{:.0} · Вид → Сбросить фильтры, чтобы увидеть все", "{pid} (missing)": "{pid} (отсутствует)", "{sel_n} selected · {counted}": "Выбрано: {sel_n} · {counted}", @@ -251,6 +253,7 @@ "{what} merge cancelled": "{what}: слияние отменено", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)", "New Folder in “{name}”": "Новая папка в «{name}»", + "Created folder “{name}” in “{parent}”": "Папка «{name}» создана в «{parent}»", "Moved {} photo(s)": "Перемещено фото: {}", "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", @@ -285,7 +288,7 @@ "Skipped {problem}": "Пропущено: {problem}", "…and {more} more": "…и ещё {more}", "Template preview: {name}": "Просмотр шаблона: {name}", - "{n} photos in view · {slides} slides · {time}": "Фото в просмотре: {n} · слайдов: {slides} · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "Фото в просмотре: {n} · слайдов: {slides} · {time}{:.0}{:.0}", "{n} pages": "Страниц: {n}", "{n} JPEG files": "Файлов JPEG: {n}", "{s} s of AV1 video": "{s} с видео AV1", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 1450982a3..738d9fd7b 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -218,6 +218,7 @@ "The layout image can't be shown": "Не удаётся показать изображение макета", "Their photos in the grid": "Их фото в сетке", "This page setup is not valid": "Эти параметры страницы недопустимы", + "Import photos with File → Import Photos…, or drop them 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": "Добавить к выбранным фото", @@ -288,6 +289,7 @@ "Auto-Tag Photos from Tracklog": "Автопривязка фото по треку", "Auto-Tag from Tracklog": "Автопривязка по треку", "Auto-Tag from Tracklog…": "Автопривязка по треку…", + "Add Locations": "Добавить места", "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.": "Автоматический режим рендерит «Детализацию» в отображаемом размере, вплоть до собственных пикселей фото. Выберите размер, чтобы ограничить: меньше — быстрее.", "Automatically Write Changes into XMP": "Автоматически записывать изменения в XMP", @@ -478,6 +480,9 @@ "Crop Aspect": "Пропорции кадра", "Cross Process": "Кросс-процесс", "Culling": "Отбор", + "Cull": "Отобрать", + "Create Stacks": "Создать стопки", + "Change Time": "Изменить время", "Curve": "Кривая", "Custom": "Свои", "Custom…": "Свои…", @@ -759,7 +764,6 @@ "Import Profiles & Presets…": "Импортировать профили и пресеты…", "Import from Device": "Импорт с устройства", "Import from Folder…": "Импорт из папки…", - "Import photos with File → Import Photos…, or drop them here": "Импортируйте фото через Файл → Импорт фото… или перетащите их сюда", "Imported Photos": "Импортированные фото", "Importing": "Импорт", "Importing Lightroom Catalog": "Импорт каталога Lightroom", @@ -1452,6 +1456,7 @@ "System default": "Как в системе", "Tags": "Теги", "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Теги: см. «Теги» рядом с шаблоном. Файлы переименовываются на диске (вместе с XMP-файлами); к существующим именам добавляется -1, -2…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Кнопка «Теги» показывает, что можно вставить в шаблон, например {date} или {seq}. Каждый файл переименовывается на диске вместе с XMP-файлом; к занятому имени добавляется -1, -2…", "Take a snapshot": "Сделать снимок", "Targeted Adjustment": "Целевая коррекция", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Целевая коррекция: перетаскивайте вверх/вниз по фото, чтобы менять цвета в этом месте", @@ -2587,5 +2592,8 @@ "Set up face recognition": "Настроить распознавание лиц", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Для подсказок имён распознаванию лиц нужна модель: однократная загрузка в Настройках.", "Turn on name suggestions": "Включить подсказки имён", - "Set up name suggestions…": "Настроить подсказки имён…" + "Set up name suggestions…": "Настроить подсказки имён…", + "Top bar": "Верхняя панель", + "Tool strip": "Панель инструментов", + "Bottom bar": "Нижняя панель" } diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json index 433e3221f..7d40388d1 100644 --- a/crates/ui-egui/locales/sv-formats.json +++ b/crates/ui-egui/locales/sv-formats.json @@ -93,6 +93,7 @@ "Healed {n} dust spot{}": "Retuscherade {n} dammfläck{}", "Import cancelled · {} photo{} added": "Importen avbröts · {} foto{} tillagda", "Import failed: {e}": "Importen misslyckades: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Importera foton med Arkiv → Importera foton… ({}) eller släpp dem här", "Import warnings ({n}):\n{warnings}": "Importvarningar ({n}):\n{warnings}", "Imported {e} mask effect{} (Masking ▸ Effect)": "Importerade {e} maskeffekt{} (Maskering → Effekt)", "Imported {name}": "Importerade {name}", @@ -130,6 +131,7 @@ "Moved; {} photo(s) relinked": "Flyttat; {} foto(n) har länkats om", "Named ({named_n})": "Namngivna ({named_n})", "New Folder in “{name}”": "Ny mapp i ”{name}”", + "Created folder “{name}” in “{parent}”": "Mappen ”{name}” skapad i ”{parent}”", "new, inside {parent}": "ny, inuti {parent}", "none (CPU rendering): {why}": "ingen (CPU-rendering): {why}", "not added to Apple Photos: {e}": "inte tillagt i Apple Photos: {e}", @@ -164,6 +166,7 @@ "Rendering on the CPU: {why}": "Renderar på processorn: {why}", "Replace {} with:": "Ersätt {} med:", "Reset {name}": "Återställ {name}", + "{name} value": "{name}: värde", "Reset {} Channel": "Återställ kanalen {}", "Restore {}": "Återställ {}", "Restore {} Photo{}": "Återställ {} foto{}", @@ -314,5 +317,8 @@ "Panel layout could not be restored: {error}": "Panellayouten kunde inte återställas: {error}", "Panel move was not applied: {error}": "Panelflytten tillämpades inte: {error}", "{name} is installed": "{name} är installerad", - "{name} is installed and in use: face recognition is on": "{name} är installerad och används: ansiktsigenkänning är på" + "{name} is installed and in use: face recognition is on": "{name} är installerad och används: ansiktsigenkänning är på", + "Creates {stacks} stack{} from {photos} of {scope}": "Skapar staplar: {stacks} · foton: {photos} av {scope}{:.0}", + "{n} selected photo{} · {pages} page{}": "Markerade foton: {n} · sidor: {pages}{:.0}{:.0}", + "{n} photo{} in view · {slides} slide{} · {time}": "Foton i vyn: {n} · bilder: {slides} · {time}{:.0}{:.0}" } diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index c61df542f..f229b9547 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -25,6 +25,7 @@ "4 MiB and connections at 16.": "4 MiB och anslutningar till 16.", "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630-drivrutiner): i Windows använder fönstret och GPU-renderingen endast DirectX 12 och läser aldrig in Vulkan-drivrutinen om inte", "= exactly": "= exakt", + "Import photos with File → Import Photos…, or drop them here": "Importera foton med Arkiv → Importera foton… eller släpp dem här", "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; på skiftlägesokänsliga volymer går ändringen av skiftläge ändå igenom.", "`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`) tillsammans med sina XMP-sidofiler; varje original lämnar kortet först efter att dess", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "En 16:9-bild med svarta fält ovanför och nedanför, titeln i hörnet, toningar via svart och ett avslutande kort.", @@ -963,7 +964,6 @@ "Import Lightroom Catalog…": "Importera Lightroom-katalog…", "Import new photos": "Importera nya foton", "Import Photos": "Importera foton", - "Import photos with File → Import Photos…, or drop them here": "Importera foton med Arkiv → Importera foton… eller släpp dem här", "Import Photos…": "Importera foton…", "Import Point Curve Presets": "Importera förinställningar för punktkurva", "Import Point Curve Presets…": "Importera förinställningar för punktkurva…", @@ -2451,5 +2451,13 @@ "Set up face recognition": "Ställ in ansiktsigenkänning", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Ansiktsigenkänning behöver en modell för att föreslå namn: en engångsnedladdning, i Inställningar.", "Turn on name suggestions": "Slå på namnförslag", - "Set up name suggestions…": "Ställ in namnförslag…" + "Set up name suggestions…": "Ställ in namnförslag…", + "Change Time": "Ändra tid", + "Cull": "Gallra", + "Add Locations": "Lägg till platser", + "Create Stacks": "Skapa staplar", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Knappen Taggar visar platshållare för mallen, till exempel {date} eller {seq}. Varje fil byter namn på disken tillsammans med sin XMP-sidofil; ett namn som redan finns får -1, -2…", + "Top bar": "Övre fältet", + "Tool strip": "Verktygsfält", + "Bottom bar": "Nedre fältet" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 233245895..e87bd0374 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -40,7 +40,7 @@ "Color space: Rec. 2020 {curve} (HDR AVIF)": "Колірний простір: Rec. 2020 {curve} (HDR AVIF)", "Couldn't open the editor: {e}": "Не вдалося відкрити редактор: {e}", "Create {n} Virtual Copies": "Створити віртуальні копії: {n}", - "Creates {} stacks from {} of {scope}": "Буде створено стосів: {} із {} ({scope})", + "Creates {stacks} stack{} from {photos} of {scope}": "Буде створено стосів: {stacks} із {photos} ({scope}){:.0}", "Decoder: {reason}": "Декодер: {reason}", "Delete \"{label}\"": "Видалити «{label}»", "Delete \"{label}\" (and the mask)": "Видалити «{label}» (і маску)", @@ -63,6 +63,7 @@ "Healed {n} dust spot{}": "Усунено пилових плям: {n}{:.0}", "Import cancelled · {} photo{} added": "Імпорт скасовано · додано фото: {}{:.0}", "Import failed: {e}": "Помилка імпорту: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Імпортуйте фото через Файл → Імпортувати фото… ({}) або перетягніть їх сюди", "Import warnings ({n}):\n{warnings}": "Попередження імпорту ({n}):\n{warnings}", "Imported {n} point curve preset{}": "Імпортовано наборів налаштувань точкової кривої: {n}{:.0}", "Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}", @@ -117,6 +118,7 @@ "Rendering on the CPU: {why}": "Рендеринг на ЦП: {why}", "Replace {} with:": "Замінити {} на:", "Reset {name}": "Скинути: {name}", + "{name} value": "{name}: значення", "Reset {} Channel": "Скинути канал: {}", "Restore {}": "Відновити {}", "Restore {} Photo{}": "Відновити фото: {}{:.0}", @@ -165,7 +167,7 @@ "{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} selected photos · {pages} pages": "Вибрано фото: {n} · сторінок: {pages}", + "{n} selected photo{} · {pages} page{}": "Вибрано фото: {n} · сторінок: {pages}{:.0}{:.0}", "{pid} (missing)": "{pid} (відсутній)", "{sel_n} selected · {counted}": "Вибрано: {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", @@ -251,6 +253,7 @@ "{what} merge cancelled": "Об’єднання {what} скасовано", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)", "New Folder in “{name}”": "Нова тека в «{name}»", + "Created folder “{name}” in “{parent}”": "Теку «{name}» створено в «{parent}»", "Moved {} photo(s)": "Переміщено фото: {}", "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", @@ -285,7 +288,7 @@ "Skipped {problem}": "Пропущено: {problem}", "…and {more} more": "…і ще {more}", "Template preview: {name}": "Перегляд шаблону: {name}", - "{n} photos in view · {slides} slides · {time}": "Фото в перегляді: {n} · слайдів: {slides} · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "Фото в перегляді: {n} · слайдів: {slides} · {time}{:.0}{:.0}", "{n} pages": "Сторінок: {n}", "{n} JPEG files": "Файлів JPEG: {n}", "{s} s of AV1 video": "{s} с відео AV1", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c0c70c21b..8c9211108 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -111,6 +111,7 @@ "Home": "Домашня тека", "Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)", "ISO Speed": "Чутливість ISO", + "Import photos with File → Import Photos…, or drop them here": "Імпортуйте фото через Файл → Імпортувати фото… або перетягніть їх сюди", "Imported": "Імпортовані", "Join the ArtCraft Discord": "Приєднатися до Discord ArtCraft", "Join the ArtCraft Discord…": "Приєднатися до Discord ArtCraft…", @@ -462,6 +463,7 @@ "Auto-Tag Photos from Tracklog": "Автоматично позначити фото за журналом маршруту", "Auto-Tag from Tracklog": "Автоматично позначити за журналом маршруту", "Auto-Tag from Tracklog…": "Автоматично позначити за журналом маршруту…", + "Add Locations": "Додати місця", "Automatically Write Changes into XMP": "Автоматично записувати зміни в XMP", "Automatically write changes into XMP sidecars": "Автоматично записувати зміни в супровідні файли XMP", "Autumn Gold": "Осіннє золото", @@ -614,6 +616,9 @@ "Crop Aspect": "Пропорції кадрування", "Crop, Rotate, Geometry": "Кадрування, обертання, геометрія", "Culling": "Відбір", + "Cull": "Відібрати", + "Create Stacks": "Створити стоси", + "Change Time": "Змінити час", "Custom template…": "Власний шаблон…", "Cycle Crop Overlay": "Перемкнути накладання кадрування", "Cycle Crop Overlay Orientation": "Перемкнути орієнтацію накладання кадрування", @@ -1014,7 +1019,6 @@ "Import Profiles & Presets…": "Імпортувати профілі й набори налаштувань…", "Import from Device": "Імпортувати з пристрою", "Import from Folder…": "Імпортувати з теки…", - "Import photos with File → Import Photos…, or drop them here": "Імпортуйте фото через Файл → Імпортувати фото… або перетягніть їх сюди", "Imported Photos": "Імпортовані фото", "Importing": "Імпорт", "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).": "Імпорт сканує джерело фото; він не додає теку до локальних (використовуйте Локальні → Оглянути теку…, щоб працювати в теці без імпорту).", @@ -1451,6 +1455,7 @@ "System default": "Типові системні налаштування", "Tags": "Теги", "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Теги: див. «Теги» біля шаблону. Файли перейменовуються на диску (разом із супровідними XMP); до наявних назв додається -1, -2…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Кнопка «Теги» показує, що можна вставити в шаблон, наприклад {date} або {seq}. Кожен файл перейменовується на диску разом із супровідним XMP-файлом; до зайнятої назви додається -1, -2…", "Targeted Adjustment": "Цільова корекція", "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": "Цільова корекція: тягніть угору/вниз на фото, щоб змінити криву в цьому місці", @@ -2377,5 +2382,8 @@ "Set up face recognition": "Налаштувати розпізнавання облич", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Для підказок імен розпізнаванню облич потрібна модель: одноразове завантаження в Налаштуваннях.", "Turn on name suggestions": "Увімкнути підказки імен", - "Set up name suggestions…": "Налаштувати підказки імен…" + "Set up name suggestions…": "Налаштувати підказки імен…", + "Top bar": "Верхня панель", + "Tool strip": "Панель інструментів", + "Bottom bar": "Нижня панель" } diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json index 4c049cd7c..dbf1d28db 100644 --- a/crates/ui-egui/locales/vi-formats.json +++ b/crates/ui-egui/locales/vi-formats.json @@ -60,7 +60,7 @@ "Create Keyword Tag Inside “{name}”…": "Tạo từ khóa trong «{name}»…", "Create the keyword “{name}”": "Create the keyword “{name}”", "Create {n} Virtual Copies": "Tạo {n} bản sao ảo", - "Creates {} stacks from {} of {scope}": "Tạo {} chồng từ {} trong {scope}", + "Creates {stacks} stack{} from {photos} of {scope}": "Tạo {stacks} chồng từ {photos} ảnh ({scope}){:.0}", "Decoder: {reason}": "Bộ giải mã: {reason}", "Delete \"{label}\"": "Xóa «{label}»", "Delete \"{label}\" (and the mask)": "Xóa «{label}» (và mặt nạ)", @@ -95,6 +95,7 @@ "Healed {n} dust spot{}": "Đã xử lý {n} vết bụi{:.0}", "Import cancelled · {} photo{} added": "Đã hủy nhập · đã thêm {} ảnh{:.0}", "Import failed: {e}": "Nhập thất bại: {e}", + "Import photos with File → Import Photos… ({}), or drop them here": "Nhập ảnh bằng Tệp → Nhập ảnh… ({}), hoặc thả vào đây", "Import warnings ({n}):\n{warnings}": "Cảnh báo khi nhập ({n}):\n{warnings}", "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", "Imported {name}": "Imported {name}", @@ -137,6 +138,7 @@ "Moved; {} photo(s) relinked": "Đã di chuyển; liên kết lại {} ảnh", "Named ({named_n})": "Có tên ({named_n})", "New Folder in “{name}”": "Thư mục mới trong «{name}»", + "Created folder “{name}” in “{parent}”": "Đã tạo thư mục «{name}» trong «{parent}»", "On {have} of {of} selected photos": "Trên {have} / {of} ảnh đã chọn", "Painting “{k}”: click photos to add or remove it · Esc stops": "Gán «{k}»: nhấp ảnh để thêm hoặc gỡ · Esc dừng", "Paste into {n} photo{} — only settings that were copied are pasted": "Dán vào {n} ảnh{:.0} — chỉ dán các thiết lập đã sao chép", @@ -168,6 +170,7 @@ "Rendering on the CPU: {why}": "Đang kết xuất trên CPU: {why}", "Replace {} with:": "Thay {} bằng:", "Reset {name}": "Đặt lại {name}", + "{name} value": "Giá trị {name}", "Reset {} Channel": "Đặt lại kênh {}", "Restore {}": "Khôi phục {}", "Restore {} Photo{}": "Khôi phục {} ảnh{:.0}", @@ -243,15 +246,15 @@ "{n} originals opened in the other editor": "Đã mở {n} bản gốc trong trình chỉnh sửa khác", "{n} pages": "{n} pages", "{n} photos": "{n} ảnh", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", "{n} photos in {} are hidden by the filters above": "{n} ảnh trong «{}» bị ẩn bởi bộ lọc phía trên", "{n} photos opened for editing; each is stacked with its original": "Đã mở {n} ảnh để chỉnh sửa; mỗi ảnh xếp chồng với bản gốc", "{n} photo{}": "{n} ảnh{:.0}", + "{n} photo{} in view · {slides} slide{} · {time}": "{n} ảnh trong chế độ xem · {slides} trang chiếu · {time}{:.0}{:.0}", "{n} photo{} match · updates automatically as photos change": "{n} ảnh{:.0} khớp · tự cập nhật khi ảnh thay đổi", "{n} photo{} now · updates automatically as photos change": "Hiện {n} ảnh{:.0} · tự cập nhật khi ảnh thay đổi", "{n} preset{}": "{n} preset{}", "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "{n} selected photo{} · {pages} page{}": "{n} ảnh đã chọn · {pages} trang{:.0}{:.0}", "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} ảnh tương tự{:.0} · Xem → Xóa bộ lọc để thấy tất cả", "{n} tasks are still running: {names}. Quitting stops them.": "Còn {n} tác vụ đang chạy: {names}. Thoát sẽ dừng chúng.", "{n} {noun}": "{noun}: {n}", diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index 4edcb6563..c1610e428 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -50,6 +50,7 @@ "Add Catchlight": "Thêm điểm sáng mắt", "Add Face Model…": "Thêm mô hình khuôn mặt…", "Add Folder to Local": "Thêm thư mục vào Cục bộ", + "Add Locations": "Thêm vị trí", "Add Mask": "Thêm mặt nạ", "Add Object Click": "Thêm cú nhấp đối tượng", "Add Point Color Sample": "Thêm mẫu Point Color", @@ -260,6 +261,7 @@ "Catchlight": "Điểm sáng mắt", "Change": "Đổi", "Change Component Mode": "Đổi chế độ thành phần", + "Change Time": "Đổi thời gian", "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Ký tự không hợp lệ trong tên tệp (/ \\ : * ? \" < > |) thành -; tên đã tồn tại thêm -1, -2….", "Charcoal": "Than", "Check All": "Chọn tất cả", @@ -398,6 +400,7 @@ "Create Smart Album from Filter…": "Tạo album thông minh từ bộ lọc…", "Create Smart Album…": "Tạo album thông minh…", "Create Stack": "Tạo chồng", + "Create Stacks": "Tạo chồng", "Create Version": "Tạo phiên bản", "Create Version from Step": "Tạo phiên bản từ bước", "Create Virtual Copy": "Tạo bản sao ảo", @@ -412,6 +415,7 @@ "Crop, Rotate, Geometry": "Cắt, xoay, hình học", "Cropped": "Đã cắt", "Cross Process": "Cross Process", + "Cull": "Sàng lọc", "Culling": "Sàng lọc", "Curve": "Đường cong", "Custom": "Tùy chỉnh", @@ -1845,6 +1849,7 @@ "That includes the photos in the folders inside it.": "Bao gồm cả ảnh trong các thư mục bên trong.", "That key can't be used as a shortcut": "Phím đó không thể dùng làm phím tắt", "The SAM 3 model is installed: Object and Describe masks are ready.": "Đã cài mô hình SAM 3: Mặt nạ đối tượng và Mô tả đã sẵn sàng.", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Nút Thẻ liệt kê các phần giữ chỗ cho mẫu, chẳng hạn {date} hoặc {seq}. Mỗi tệp được đổi tên trên đĩa cùng với tệp phụ XMP của nó; tên đã dùng sẽ thêm -1, -2…", "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Catalog có phiên bản định dạng (issue #102). Mở thư viện từ LightCraft cũ hơn sẽ nâng cấp; thư viện", "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Cổng điều khiển (`--control`) đóng kết nối ngay khi nhận bất kỳ thứ gì không phải yêu cầu JSON", "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Mô hình khử nhiễu đã cài và đang dùng: thanh AI Denoise Amount trong Chi tiết giờ hoạt động", @@ -2676,5 +2681,8 @@ "Set up face recognition": "Thiết lập nhận diện khuôn mặt", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "Nhận diện khuôn mặt cần một mô hình để gợi ý tên: tải xuống một lần, trong Cài đặt.", "Turn on name suggestions": "Bật gợi ý tên", - "Set up name suggestions…": "Thiết lập gợi ý tên…" + "Set up name suggestions…": "Thiết lập gợi ý tên…", + "Top bar": "Thanh trên cùng", + "Tool strip": "Thanh công cụ", + "Bottom bar": "Thanh dưới cùng" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 67555796c..0a8b94115 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -35,6 +35,7 @@ "Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张", "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}", "Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}", + "Import photos with File → Import Photos… ({}), or drop them here": "使用“文件 → 导入照片…”({})导入照片,或将照片拖放到此处", "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}", "Imported {n} preset{}": "已导入 {n} 个预设{0:.0}", "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}", @@ -101,7 +102,7 @@ "{n} photo{}": "{n} 张照片{0:.0}", "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}", "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}", - "{n} selected photos · {pages} pages": "已选 {n} 张照片 · {pages} 页", + "{n} selected photo{} · {pages} page{}": "已选 {n} 张照片 · {pages} 页{:.0}{:.0}", "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {0:.0}%", "{what} merge added": "已添加 {what} 合并", @@ -137,6 +138,7 @@ "Label Set: {name}": "标签集:{name}", "Quick Develop: {name}": "快速编辑:{name}", "Reset {name}": "重置{name}", + "{name} value": "{name}的值", "Updated “{name}”": "已更新“{name}”", "{verb} {n} Photo{}": "{verb} {n} 张照片{:.0}", "{year}": "{year}年", @@ -158,7 +160,7 @@ "Auto Import: added {} photo{}": "自动导入:已添加 {0} 张照片{1:.0}", "Building previews for {} photos…": "正在为 {0} 张照片生成预览…", "Couldn't open the editor: {e}": "无法打开编辑器:{e}", - "Creates {} stacks from {} of {scope}": "将从{scope}中的 {1} 张照片创建 {0} 个堆叠", + "Creates {stacks} stack{} from {photos} of {scope}": "将从{scope}中的 {photos} 张照片创建 {stacks} 个堆叠{:.0}", "Done ({what}): {done} photo{plural}": "完成({what}):{done} 张照片{plural:.0}", "Focus {:.0}": "对焦 {0:.0}", "Folder “{}” (and its subfolders)": "文件夹“{0}”(含子文件夹)", @@ -255,6 +257,7 @@ "{what} merge cancelled": "已取消 {what} 合并", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)", "New Folder in “{name}”": "在“{name}”中新建文件夹", + "Created folder “{name}” in “{parent}”": "已在“{parent}”中创建文件夹“{name}”", "Moved {} photo(s)": "已移动 {} 张照片", "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", @@ -289,7 +292,7 @@ "Skipped {problem}": "已跳过:{problem}", "…and {more} more": "…还有 {more} 个", "Template preview: {name}": "模板预览:{name}", - "{n} photos in view · {slides} slides · {time}": "视图中 {n} 张照片 · {slides} 张幻灯片 · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "视图中 {n} 张照片 · {slides} 张幻灯片 · {time}{:.0}{:.0}", "{n} pages": "{n} 页", "{n} JPEG files": "{n} 个 JPEG 文件", "{s} s of AV1 video": "{s} 秒 AV1 视频", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index f58afde11..07494e845 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -44,6 +44,7 @@ "Add to Local": "添加到本地", "Add to My Photos": "添加到我的照片", "Can't be moved here": "无法移到这里", + "Import photos with File → Import Photos…, or drop them 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": "添加到所选照片", @@ -122,6 +123,7 @@ "Auto-Tag Photos from Tracklog": "从轨迹日志自动标记照片", "Auto-Tag from Tracklog": "从轨迹日志自动标记", "Auto-Tag from Tracklog…": "从轨迹日志自动标记…", + "Add Locations": "添加位置", "Automatic": "自动", "Automatically Write Changes into XMP": "自动将更改写入 XMP", "Automatically write changes into XMP sidecars": "自动将更改写入 XMP 附属文件", @@ -338,6 +340,9 @@ "Crop, Rotate, Geometry": "裁剪、旋转、几何", "Cross Process": "交叉冲洗", "Culling": "挑片", + "Cull": "筛选", + "Create Stacks": "创建堆叠", + "Change Time": "更改时间", "Curve": "曲线", "Custom": "自定义", "Custom template…": "自定义模板…", @@ -804,7 +809,6 @@ "Import Profiles & Presets…": "导入配置文件与预设…", "Import from Device": "从设备导入", "Import from Folder…": "从文件夹导入…", - "Import photos with File → Import Photos…, or drop them here": "使用“文件 → 导入照片…”导入照片,或将照片拖放到此处", "Imported Photos": "已导入的照片", "Importing": "正在导入", "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).": "导入会扫描源中的照片,但不会将该文件夹添加到“本地”(若想在不导入的情况下处理某个文件夹,请使用“本地 → 浏览文件夹…”)。", @@ -1462,6 +1466,7 @@ "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…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "“标签”按钮会列出可用于模板的占位符,例如 {date} 或 {seq}。每个文件会连同其 XMP 附属文件在磁盘上重命名;名称已被占用时会加上 -1、-2…", "Take a snapshot": "创建快照", "Targeted Adjustment": "局部调整", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "局部调整:在照片上上下拖动以调整该处的颜色", @@ -2380,5 +2385,8 @@ "Set up face recognition": "设置人脸识别", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "人脸识别需要一个模型来建议姓名:只需在“设置”中下载一次。", "Turn on name suggestions": "开启姓名建议", - "Set up name suggestions…": "设置姓名建议…" + "Set up name suggestions…": "设置姓名建议…", + "Top bar": "顶部栏", + "Tool strip": "工具栏", + "Bottom bar": "底部栏" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 6f94892a1..178cd55a0 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -42,6 +42,7 @@ "Filter bar — {active} active filter{}": "篩選列 — 已套用 {active} 個篩選條件{:.0}", "Healed {n} dust spot{}": "已修復 {n} 個灰塵斑點{:.0}", "Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.0}", + "Import photos with File → Import Photos… ({}), or drop them here": "使用「檔案 → 匯入照片…」({}),或將照片拖放至此", "Imported {n} point curve preset{}": "已匯入 {n} 個點曲線預設集{:.0}", "Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}", "Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}", @@ -82,6 +83,7 @@ "Rename “{name}”": "重新命名「{name}」", "Rendering on the CPU: {why}": "使用 CPU 算繪:{why}", "Reset {name}": "重設{name}", + "{name} value": "{name}的數值", "Reset {} Channel": "重設 {} 通道", "Restore {}": "還原為 {}", "Restore {} Photo{}": "還原 {} 張照片{:.0}", @@ -121,7 +123,7 @@ "{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} selected photos · {pages} pages": "已選取 {n} 張照片 · {pages} 頁", + "{n} selected photo{} · {pages} page{}": "已選取 {n} 張照片 · {pages} 頁{:.0}{:.0}", "{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}", @@ -158,7 +160,7 @@ "Auto Import: added {} photo{}": "自動匯入:已新增 {} 張照片{:.0}", "Building previews for {} photos…": "正在為 {} 張照片建立預覽…", "Couldn't open the editor: {e}": "無法開啟編輯器:{e}", - "Creates {} stacks from {} of {scope}": "將從{scope}中的 {1} 張照片建立 {0} 個堆疊", + "Creates {stacks} stack{} from {photos} of {scope}": "將從{scope}中的 {photos} 張照片建立 {stacks} 個堆疊{:.0}", "Done ({what}): {done} photo{plural}": "完成({what}):{done} 張照片{plural:.0}", "Focus {:.0}": "焦點 {:.0}", "Folder “{}” (and its subfolders)": "資料夾「{}」(含子資料夾)", @@ -255,6 +257,7 @@ "{what} merge cancelled": "已取消{what}合併", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄照片庫中的照片(請先加入我的照片)", "New Folder in “{name}”": "在「{name}」中新增資料夾", + "Created folder “{name}” in “{parent}”": "已在「{parent}」中建立資料夾「{name}」", "Moved {} photo(s)": "已移動 {} 張照片", "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", @@ -289,7 +292,7 @@ "Skipped {problem}": "已略過:{problem}", "…and {more} more": "…還有 {more} 個", "Template preview: {name}": "範本預覽:{name}", - "{n} photos in view · {slides} slides · {time}": "檢視中 {n} 張照片 · {slides} 張幻燈片 · {time}", + "{n} photo{} in view · {slides} slide{} · {time}": "檢視中 {n} 張照片 · {slides} 張幻燈片 · {time}{:.0}{:.0}", "{n} pages": "{n} 頁", "{n} JPEG files": "{n} 個 JPEG 檔案", "{s} s of AV1 video": "{s} 秒 AV1 視訊", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f3ff159c9..d1956900a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -44,6 +44,7 @@ "Add to Local": "加入本機", "Add to My Photos": "加入我的照片", "Can't be moved here": "無法移到這裡", + "Import photos with File → Import Photos…, or drop them 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": "加入選取的照片", @@ -122,6 +123,7 @@ "Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置", "Auto-Tag from Tracklog": "依軌跡記錄自動加入位置標記", "Auto-Tag from Tracklog…": "依軌跡記錄自動加入位置標記…", + "Add Locations": "新增位置", "Automatic": "自動", "Automatically Write Changes into XMP": "自動將變更寫入 XMP", "Automatically write changes into XMP sidecars": "自動將變更寫入 XMP 附屬檔案", @@ -339,6 +341,9 @@ "Crop, Rotate, Geometry": "裁切、旋轉、幾何", "Cross Process": "交叉沖洗", "Culling": "挑片", + "Cull": "開始挑片", + "Create Stacks": "建立堆疊", + "Change Time": "變更時間", "Curve": "曲線", "Custom": "自訂", "Custom template…": "自訂範本…", @@ -805,7 +810,6 @@ "Import Profiles & Presets…": "匯入描述檔與預設集…", "Import from Device": "從裝置匯入", "Import from Folder…": "從資料夾匯入…", - "Import photos with File → Import Photos…, or drop them here": "使用「檔案 → 匯入照片…」,或將照片拖放至此", "Imported Photos": "已匯入的照片", "Importing": "正在匯入", "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).": "匯入會掃描來源中的照片,不會將資料夾加入本機(若要直接使用資料夾中的檔案,請選擇「本機 → 瀏覽資料夾…」)。", @@ -1468,6 +1472,7 @@ "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…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "「標籤」按鈕會列出可用於範本的佔位符,例如 {date} 或 {seq}。每個檔案會連同其 XMP 附屬檔案在磁碟上重新命名;名稱已被使用時會加上 -1、-2…", "Take a snapshot": "擷取快照", "Targeted Adjustment": "目標調整", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "目標調整:在照片上向上/下拖曳,調整該位置的色彩", @@ -2380,5 +2385,8 @@ "Set up face recognition": "設定臉部辨識", "Face recognition needs a model to suggest names: a one-time download, in Settings.": "臉部辨識需要一個模型來建議姓名:只需在「設定」中下載一次。", "Turn on name suggestions": "開啟姓名建議", - "Set up name suggestions…": "設定姓名建議…" + "Set up name suggestions…": "設定姓名建議…", + "Top bar": "頂部列", + "Tool strip": "工具列", + "Bottom bar": "底部列" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9011a4702..a7b70e314 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -430,7 +430,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest "ui.dialog.confirm" => match app.ui.dialog.take() { Some(d) => { let r = crate::panels::dialogs::confirm_dialog(app, &d); - // the import review and the rule editor stay open on an error, as with their buttons + // a refused one-line prompt says why on screen, as with its button; a folder made says where + match (&r, &d) { + (Err(e), crate::state::Dialog::TextPrompt { .. }) => app.toast_error(ctx, e.clone()), + (Ok(made), crate::state::Dialog::TextPrompt { .. }) => crate::dialogs::toast_created_folder(app, ctx, &d, made), + _ => {} + } + // the import review, the rule editor and the one-line prompts stay open on an error, as with their buttons if (r.is_err() && matches!( d, @@ -439,6 +445,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest | crate::state::Dialog::SmartRules { .. } | crate::state::Dialog::SaveEffect { .. } | crate::state::Dialog::CaptureTime { .. } + | crate::state::Dialog::TextPrompt { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) || (r.is_err() && crate::lightroom_destination::keeps_open_on_error(app, &d)) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9ef12337d..07f050aa7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -94,8 +94,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); // The backdrop is an area below the dialog window (a bare `Middle` layer painter would be - // painted after every area — i.e. over the dialog too). + // painted after every area — i.e. over the dialog too). Like a macOS sheet, the dialog leaves + // the integrated title bar lit: the window buttons the OS draws over it do not dim. + let title_bar = title_bar_band(app); egui::Area::new(egui::Id::unique("dialog-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(false).show(ctx, |ui| { + let screen = below_title_bar(screen, title_bar); ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140)); }); let mut close = false; @@ -190,7 +193,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { } if let Some(w) = &shown { // (the import review keeps its hand-picked photos however the click lands) - if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { + if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, title_bar, popup_was_open) { close = true; } else if raw_blocked { // A press made under the prompt or library window must not be remembered as a @@ -199,6 +202,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { } } if let Some(w) = shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog); ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); if let Some(body_h) = body_h { let measured = (w.response.rect.height() - body_h).max(0.0); @@ -209,6 +213,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { } ctx.move_to_top(w.response.layer_id); crate::widgets::register_response("dialog:window", w.response.rect, &w.response); + if let Some(band) = title_bar { + title_bar_handle(ctx, band, w.response.rect); + } } // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in // the dialog, such as a date picker's calendar, takes it to close itself) @@ -232,6 +239,43 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { app.ui.dialog = if close { None } else { Some(dlg) }; } +/// The band of the top bar that doubles as the window's title bar, as drawn last frame: `None` +/// without the integrated title bar (Windows, Linux, the web), in macOS full screen, and while the +/// bar isn't on screen (the full-screen photo view). +fn title_bar_band(app: &LightcraftApp) -> Option { + // (the full-screen photo view draws no top bar, so the frame it is entered on still holds last frame's region) + if !app.integrated_titlebar || app.window_is_fullscreen || app.ui.fullscreen { + return None; + } + app.widgets.iter().find(|(id, _)| id == "region:titlebar").map(|(_, r)| *r) +} + +/// `screen` less the title bar band across its top. +fn below_title_bar(screen: egui::Rect, band: Option) -> egui::Rect { + band.map_or(screen, |b| egui::Rect::from_min_max(egui::pos2(screen.min.x, b.max.y.clamp(screen.min.y, screen.max.y)), screen.max)) +} + +/// The bar's controls are disabled under a dialog, and the modal layer takes the hits of the layers +/// below it: this keeps the bar a window handle (drag to move, double-click to zoom), above the +/// modal like a sheet's title bar. A tall dialog reaching into the band keeps its own clicks: the +/// handle is the band on either side of it, and isn't run while the pointer is on the dialog (each +/// handle records whether the window may move, and one the pointer isn't in would say yes). +fn title_bar_handle(ctx: &egui::Context, band: egui::Rect, dialog: egui::Rect) { + // (egui lifts an area that wasn't there the frame before above the rest: a handle drawn again as the pointer leaves + // the dialog would sit over an open dropdown and take the press meant for its item) + if egui::Popup::is_any_open(ctx) || ctx.pointer_hover_pos().is_some_and(|p| dialog.contains(p)) { + return; + } + let sides = + if band.intersects(dialog) { [(band.min.x, dialog.min.x), (dialog.max.x, band.max.x)] } else { [(band.min.x, band.max.x), (0.0, 0.0)] }; + for (i, (from, to)) in sides.into_iter().enumerate().filter(|(_, (from, to))| to - from > 1.0) { + let part = egui::Rect::from_x_y_ranges(from..=to, band.y_range()); + egui::Area::new(egui::Id::unique("dialog-title-bar").with(i)).order(egui::Order::Foreground).fixed_pos(part.min).show(ctx, |ui| { + craft_window::title_bar::empty_area(ui, part); + }); + } +} + /// The title of a dialog. fn dialog_title(app: &LightcraftApp, dlg: &Dialog) -> String { match dlg { @@ -333,26 +377,69 @@ fn cancel_label(dlg: &Dialog, sam: &SamState) -> &'static str { /// The label of a dialog's confirm button (empty: it has none). fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamState) -> String { - let label = match dlg { - Dialog::Import { opts } => { - let n = opts.selected_paths().len(); - let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; - return crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); - } + match dlg { + Dialog::Import { opts } => import_ok_label(opts), + Dialog::Export { .. } => photos_ok_label("Export", app.session.selection.ids.len().max(1)), + Dialog::SamModel { .. } => sam_ok_label(dlg, sam).to_string(), + _ => fixed_ok_label(app, dlg, informational).to_string(), + } +} + +/// "Import 3 Photos", "Export 1 Photo": a verb and how many photos it acts on. +fn photos_ok_label(verb: &str, n: usize) -> String { + crate::i18n::tr_format!("{verb} {n} Photo{}", crate::i18n::plural_s(n), verb = crate::i18n::tr(verb), n = n) +} + +/// The import review's confirm: "Import 3 Photos", or with Move "Move 3 Photos". A checked file whose photo is in +/// Recently Deleted is restored, not moved (its files stay where they are), so Move counts those apart: +/// "Move 2 Photos · Restore 1 Photo", or "Restore 1 Photo" when nothing is left to move. +fn import_ok_label(opts: &crate::import::ImportDialog) -> String { + let n = opts.selected_paths().len(); + if !(opts.copy && opts.move_files) { + return photos_ok_label("Import", n); + } + let restored = opts.restored_count(); + match (n - restored, restored) { + (_, 0) => photos_ok_label("Move", n), + (0, r) => photos_ok_label("Restore", r), + (m, r) => format!("{} · {}", photos_ok_label("Move", m), photos_ok_label("Restore", r)), + } +} + +/// The confirm labels that are one fixed phrase. +fn fixed_ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool) -> &'static str { + match dlg { + // worded with a count or by the SAM state: `ok_label` builds them + Dialog::Import { .. } | Dialog::Export { .. } | Dialog::SamModel { .. } => "", Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", Dialog::ContactSheet { .. } => "Choose PDF…", Dialog::SlideshowExport { .. } => "Export…", Dialog::Print => "Print to PDF…", Dialog::Merge { .. } => "Merge", - Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", - Dialog::DenoiseModel { .. } => "Install", + Dialog::DenoiseModel { info, .. } if !informational && info["download"].is_string() => "Accept & Download", + Dialog::DenoiseModel { .. } if !informational => "Install", Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", - Dialog::SaveEffect { .. } => "Create", + Dialog::SaveEffect { .. } + | Dialog::NewAlbum { .. } + | Dialog::NewSmartAlbum { .. } + | Dialog::SmartRules { id: None, .. } + | Dialog::CreatePreset { .. } => "Create", + Dialog::SmartRules { .. } | Dialog::LabelNames { .. } => "Save", + Dialog::RenameKeyword { from, to } if app.session.catalog.has_keyword(to) && !lightcraft_catalog::keywords::same(from, to) => { + "Merge Keywords" + } + Dialog::RenameAlbum { .. } | Dialog::Rename { .. } | Dialog::RenameKeyword { .. } => "Rename", + Dialog::AutoStack { .. } => "Create Stacks", + Dialog::CaptureTime { .. } => "Change Time", + Dialog::Cull { .. } => "Cull", + Dialog::CopySettings { .. } => "Copy", + Dialog::PasteSettings { .. } => "Paste", + Dialog::TextPrompt { command, .. } => prompt_verb(command), Dialog::MovePhotos { .. } => "Move", // (not "Merge": that is photo merging, HDR / panorama, in some languages) - Dialog::MoveKeyword { .. } => "Merge Keywords", + Dialog::MoveKeyword { .. } | Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", Dialog::KeywordTag { editing: None, .. } => "Create", Dialog::KeywordTag { .. } => "Save", @@ -361,12 +448,21 @@ fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamSta d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d), Dialog::SynchronizeFolder { .. } if informational => "Close", Dialog::SynchronizeFolder { .. } => "Synchronize", - Dialog::SamModel { .. } => return sam_ok_label(dlg, sam).to_string(), Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", - _ if informational => "Close", - _ => "OK", - }; - label.to_string() + Dialog::RenamePerson { .. } => "Rename", + // the dialogs that only show something, or a model file that can't be used + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew + | Dialog::ManageEffects { .. } + | Dialog::ViewOptions + | Dialog::LightroomResult { .. } + | Dialog::DenoiseModel { .. } + | Dialog::FaceModel { .. } => "Close", + } } /// The label of the SAM 3 dialog's confirm button. @@ -441,6 +537,17 @@ fn button_row(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &Dialog, close: &mut } } +/// Say where a folder made on disk is: the new folder holds no photos yet, so the Folders list may not show it. +/// Shared by the dialog's button and `ui.dialog.confirm`. +pub(crate) fn toast_created_folder(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &Dialog, made: &serde_json::Value) { + if let Dialog::TextPrompt { command, params, .. } = dlg + && command == "folder.create" + { + let (path, parent) = (made["path"].as_str().unwrap_or_default(), params["parent"].as_str().unwrap_or_default()); + app.toast(ctx, crate::panels::left::created_text(path, parent)); + } +} + /// The dialog's confirm was pressed: do it. Whether the dialog closes. fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> bool { let mut closed = false; @@ -474,6 +581,13 @@ fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> *error = Some(e); } } + // a one-line prompt that is refused stays open with its text, so the name can be fixed + Err(e) if matches!(dlg, Dialog::TextPrompt { .. }) => app.toast_error(ctx, e), + // a folder made on disk says so (the new folder holds no photos yet) + Ok(r) if matches!(&dlg, Dialog::TextPrompt { command, .. } if command == "folder.create") => { + toast_created_folder(app, ctx, dlg, &r); + closed = true; + } Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { // albums hold library photos (issue #684): say which part of the selection stayed out crate::panels::grid::toast_skipped(app, ctx, &r); @@ -516,6 +630,19 @@ fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> /// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; +/// What a one-field prompt's confirm button says, by the command it runs. A command that isn't +/// listed (one opened by automation) has no verb to name and keeps "OK". +fn prompt_verb(command: &str) -> &'static str { + match command { + "album.rename" | "folder.rename" | "version.rename" | "preset.rename" | "mask.renameEffect" => "Rename", + "folder.create" | "mask.setEffectGroup" | "app.newLibrary" => "Create", + "preset.move" => "Move", + "keyword.saveSet" | "filter.savePreset" | "curve.savePreset" | "metadata.savePreset" => "Save", + "photo.tagFromTracklog" => "Add Locations", + _ => "OK", + } +} + /// Whether a dialog stays open after its action succeeded (the SAM 3 dialog while the model /// downloads). pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool { @@ -537,12 +664,16 @@ fn auto_stack_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; ui.label( - egui::RichText::new(crate::i18n::tr_format!( - "Creates {} stacks from {} of {scope}", - preview["stacks"], - preview["photos"], - scope = crate::i18n::tr(scope) - )) + egui::RichText::new({ + let (stacks, photos) = (preview["stacks"].as_u64().unwrap_or(0), preview["photos"].as_u64().unwrap_or(0)); + crate::i18n::tr_format!( + "Creates {stacks} stack{} from {photos} of {scope}", + if stacks == 1 { "" } else { "s" }, + stacks = stacks, + photos = photos, + scope = crate::i18n::tr(scope) + ) + }) .color(t.text_dim), ); } @@ -914,7 +1045,7 @@ fn rename_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); ui.label( egui::RichText::new(crate::i18n::tr( - "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…", )) .color(t.text_dim), ); @@ -1061,7 +1192,8 @@ fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dial options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); let n = app.session.selected().len(); - ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); + let pages = n.div_ceil(options.columns * options.rows); + ui.label(contact_sheet_summary(n, pages)); field(ui, "Paper", |ui| { let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { ui.selectable_value(&mut options.paper, "a4".into(), "A4"); @@ -1081,6 +1213,11 @@ fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dial ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); } +/// "N selected photos · M pages". +fn contact_sheet_summary(n: usize, pages: usize) -> String { + crate::i18n::tr_format!("{n} selected photo{} · {pages} page{}", crate::i18n::plural_s(n), crate::i18n::plural_s(pages), n = n, pages = pages) +} + /// The body of the Export dialog. fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { let Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } = dlg else { return }; diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 9c5507a5c..707c0dbec 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -56,6 +56,18 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.rename", json!({"id": id, "name": name})), Dialog::TextPrompt { value, command, params, key, .. } => { + // Rename Folder opens with the folder's own name typed: confirming it unchanged renames nothing, as it always has + // (the engine would refuse it as a name that "already exists"). A name with spaces at its ends is the same + // folder when typed back as it is, so it is compared as typed as well as trimmed. + let kept = command == "folder.rename" + && params["path"].as_str().is_some_and(|path| { + std::path::Path::new(path.trim_end_matches(['/', '\\'])) + .file_name() + .is_some_and(|f| f.to_string_lossy() == value.as_str() || f.to_string_lossy() == value.trim()) + }); + if kept { + return Ok(serde_json::Value::Null); + } let mut p = params.clone(); p[key.as_str()] = json!(value.trim()); app.run(command, p) diff --git a/crates/ui-egui/src/dialogs/frame.rs b/crates/ui-egui/src/dialogs/frame.rs index 007c733cb..ea385f433 100644 --- a/crates/ui-egui/src/dialogs/frame.rs +++ b/crates/ui-egui/src/dialogs/frame.rs @@ -8,7 +8,7 @@ use super::{BACKDROP_SHOWN, ControlSpec, Dialog, LightcraftApp, Tokens}; /// while the dialog is shown counts: in the frame a dialog appears, its input is the click that /// opened it (a quick click on a slow frame presses and releases in that one frame), so it never /// closes it. (Read from the raw events: egui forgets the press origin on release.) -pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool { +pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, title_bar: Option, popup_was_open: bool) -> bool { let press_id = egui::Id::unique("dialog-backdrop-press"); let frame = ctx.cumulative_frame_nr(); // the same frame too: a second pass of it gets no input @@ -27,12 +27,12 @@ pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_wa (at(true), at(false), i.pointer.primary_clicked()) }); if let Some(p) = press { - let outside = !window.contains(p) && !popup_was_open; + let outside = !window.contains(p) && !title_bar.is_some_and(|b| b.contains(p)) && !popup_was_open; ctx.data_mut(|d| d.insert_temp(press_id, outside)); } let Some(release) = release else { return false }; let pressed_outside = ctx.data_mut(|d| d.remove_temp::(press_id)).unwrap_or(false); - clicked && pressed_outside && !window.contains(release) + clicked && pressed_outside && !window.contains(release) && !title_bar.is_some_and(|b| b.contains(release)) } /// Room kept free above and below a dialog (#781). diff --git a/crates/ui-egui/src/dialogs/import.rs b/crates/ui-egui/src/dialogs/import.rs index e43c54280..e5bfab881 100644 --- a/crates/ui-egui/src/dialogs/import.rs +++ b/crates/ui-egui/src/dialogs/import.rs @@ -198,6 +198,13 @@ impl ImportDialog { } self.last_clicked = Some(i); } + /// How many of the checked files come back as the photo Recently Deleted holds: the import restores + /// those without copying or moving their files (only "Import as new" imports them like any other file). + pub fn restored_count(&self) -> usize { + let restoring = self.on_deleted != "fresh"; + let checked = self.checked.iter().enumerate().filter(|(i, on)| **on && self.importable(*i)); + checked.filter(|(i, _)| restoring && self.is_trashed(*i)).count() + } pub fn selected_paths(&self) -> Vec { self.candidates .iter() diff --git a/crates/ui-egui/src/dialogs/keymap.rs b/crates/ui-egui/src/dialogs/keymap.rs index 3ebe25057..d0d058afa 100644 --- a/crates/ui-egui/src/dialogs/keymap.rs +++ b/crates/ui-egui/src/dialogs/keymap.rs @@ -77,7 +77,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } -/// `Cmd+Shift+Z` as menus show it (`⌘⇧Z` on macOS). +/// `Cmd+Shift+Z` as menus show it (`⇧⌘Z` on macOS). fn menu_text(sc: &str, mac: bool) -> String { crate::menubar::shortcut_text(sc, mac) } diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs index 7b264c811..271b6e7c9 100644 --- a/crates/ui-egui/src/dialogs/settings.rs +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -106,11 +106,16 @@ const PREVIEW_CHROME: f32 = 82.0; pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_height: f32) { ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, PAGE_SIZE.x)); let top = ui.cursor().top(); + // The page drawn is the one that was open when the frame began: a tab clicked now opens on + // the next frame, so a text box on the page being left is drawn once more and saves what was + // typed in it (the click took the focus from it). + let shown = tab.clone(); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); for (id, label) in TABS { - if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { + if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, shown == *id).clicked() { *tab = id.to_string(); + ui.ctx().request_repaint(); } } }); @@ -118,7 +123,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_ // (a point less than the room left, so rounding never makes the dialog's own scroll area scroll) let room = max_height - (ui.cursor().top() - top) - 1.0; if room < PAGE_MIN_HEIGHT { - body(app, ui, tab.as_str()); + body(app, ui, shown.as_str()); register(ui, "settings:page", ui.min_rect()); return; } @@ -129,7 +134,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_ // (at least the least tall slide); where that leaves less than [`PAGE_MIN_HEIGHT`] for the // page, it scrolls with the page instead (as in the short window above). let mut pinned = false; - if tab == "slideshow" { + if shown == "slideshow" { let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(crate::panels::slide_layout::PREVIEW_MIN_H); if height - (slide_h + PREVIEW_CHROME) >= PAGE_MIN_HEIGHT { let before = ui.cursor().top(); @@ -144,7 +149,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_ // or two too long looks cut off, not scrollable. ui.spacing_mut().scroll.dormant_handle_opacity = 0.6; let page = egui::ScrollArea::vertical() - .id_salt(("settings-page", tab.as_str())) + .id_salt(("settings-page", shown.as_str())) .max_height(height) .min_scrolled_height(height) .auto_shrink([false, false]) @@ -153,7 +158,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_ if pinned { slideshow_tab(app, ui, &Tokens::get(ui.ctx()), false); } else { - body(app, ui, tab.as_str()); + body(app, ui, shown.as_str()); } // (what the page took: wider than the room for it would mean a control is cut off) register(ui, "settings:page", ui.min_rect()); diff --git a/crates/ui-egui/src/dialogs/settings/general.rs b/crates/ui-egui/src/dialogs/settings/general.rs index 85866b5fa..c4fb8b754 100644 --- a/crates/ui-egui/src/dialogs/settings/general.rs +++ b/crates/ui-egui/src/dialogs/settings/general.rs @@ -1,4 +1,4 @@ -use super::widgets::{check, choices, heading, hint, path_label, row}; +use super::widgets::{check, choices, field, heading, hint, path_label, row}; use super::{LightcraftApp, RichText, StartupView, Tokens, json, register}; // ------------------------------------------------------------------------------------- General @@ -77,10 +77,12 @@ pub(super) fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it"); 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) - .hint(crate::i18n::tr("Found automatically")) - .width(220.0) - .show(ui); + field( + ui, + crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) + .hint(crate::i18n::tr("Found automatically")) + .width(220.0), + ); }); edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); hint( @@ -91,10 +93,12 @@ pub(super) fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens ), ); row(ui, t, crate::i18n::tr("Other editor"), |ui| { - crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) - .hint(crate::i18n::tr("System default")) - .width(220.0) - .show(ui); + field( + ui, + crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) + .hint(crate::i18n::tr("System default")) + .width(220.0), + ); }); edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); diff --git a/crates/ui-egui/src/dialogs/settings/import.rs b/crates/ui-egui/src/dialogs/settings/import.rs index 18b221e7e..9e362dbef 100644 --- a/crates/ui-egui/src/dialogs/settings/import.rs +++ b/crates/ui-egui/src/dialogs/settings/import.rs @@ -1,5 +1,5 @@ use super::general::{cameras, preset_combo}; -use super::widgets::{button_width, check, choices, heading, hint, path_label, row}; +use super::widgets::{button_width, check, choices, field, heading, hint, path_label, row}; use super::{LightcraftApp, RichText, Tokens, json, register}; pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { @@ -79,12 +79,14 @@ pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) row(ui, t, label, |ui| { let id = egui::Id::unique(("settingsMeta", key)); let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0)); - register(ui, format!("field:settings.{key}"), r.rect); - if r.lost_focus() && text.trim() != value { + // (the project's text field: Esc gives the text back and stays in Settings, and with + // the text back the line below has nothing to save) + let widget = format!("field:settings.{key}"); + let shown = field(ui, crate::text_field::TextField::singleline(&widget, &mut text).hint(hint_text).width(240.0)); + if shown.committed() && text.trim() != value { let _ = app.run("library.preferences", json!({"import": {key: text.trim()}})); } - if r.has_focus() { + if shown.editing { ui.data_mut(|m| m.insert_temp(id, text)); } else { ui.data_mut(|m| m.remove::(id)); @@ -180,10 +182,14 @@ pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) } }); row(ui, t, crate::i18n::tr("Album"), |ui| { - let id = egui::Id::unique("auto-album"); + // (per library: a name left here by one library is not another's) + let id = egui::Id::unique(("auto-album", app.session.library.as_ref().map(|l| l.dir.clone()))); let mut name = ui.data(|m| m.get_temp::(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0)); - if r.lost_focus() { + let shown = field( + ui, + crate::text_field::TextField::singleline("field:settingsAutoAlbum", &mut name).hint(crate::i18n::tr("None")).width(180.0), + ); + if shown.committed() { let _ = app.run("library.autoImport", json!({"album": name.trim()})); } ui.data_mut(|m| m.insert_temp(id, name)); diff --git a/crates/ui-egui/src/dialogs/settings/widgets.rs b/crates/ui-egui/src/dialogs/settings/widgets.rs index 7607b1251..afd66068e 100644 --- a/crates/ui-egui/src/dialogs/settings/widgets.rs +++ b/crates/ui-egui/src/dialogs/settings/widgets.rs @@ -49,6 +49,16 @@ pub(super) fn button_width(ui: &egui::Ui, text: &str) -> f32 { label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x } +/// A text box of the Settings window, shown: Esc in it gives its text back and is used up by +/// that, so Settings stays open (a second Esc, with nothing being edited, closes it). +pub(super) fn field(ui: &mut egui::Ui, field: crate::text_field::TextField<'_>) -> crate::text_field::TextFieldResponse { + let shown = field.show(ui); + if shown.cancelled() { + crate::widgets::take_escape(ui.ctx()); + } + shown +} + pub(crate) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { ui.add_space(4.0); ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); diff --git a/crates/ui-egui/src/dialogs/slideshow_export.rs b/crates/ui-egui/src/dialogs/slideshow_export.rs index 0218d613e..fccd7ce72 100644 --- a/crates/ui-egui/src/dialogs/slideshow_export.rs +++ b/crates/ui-egui/src/dialogs/slideshow_export.rs @@ -79,35 +79,13 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { pub(super) 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)); + ui.label(egui::RichText::new(slideshow_summary(app)).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); - } - } + slideshow_size_rows(ui, settings); field(ui, "Quality", |ui| { let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); crate::widgets::register(ui, "slider:slideshowExportQuality", r.rect); @@ -128,6 +106,46 @@ pub(super) fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, ); } +/// How many photos are in view, how many slides they make with the intro and ending screens, and how long it plays. +fn slideshow_counts(app: &mut LightcraftApp) -> (usize, usize, f64) { + 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()); + (photos, photos + titles, photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0)) +} + +/// "N photos in view · M slides · m:ss". +fn slideshow_summary(app: &mut LightcraftApp) -> String { + let (photos, slides, seconds) = slideshow_counts(app); + let time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64); + crate::i18n::tr_format!( + "{n} photo{} in view · {slides} slide{} · {time}", + crate::i18n::plural_s(photos), + crate::i18n::plural_s(slides), + n = photos, + slides = slides, + time = time + ) +} + +/// The size choices of the chosen format, and a video's frame rate and container. +fn slideshow_size_rows(ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { + use lightcraft_engine::slideshow_export::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); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index bdd2a81eb..17b06849a 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -121,7 +121,7 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if app.updates_rt.manual_window_open { let mut open = true; - egui::Window::new(crate::i18n::tr("LightCraft Updates")) + let shown = egui::Window::new(crate::i18n::tr("LightCraft Updates")) .open(&mut open) .collapsible(false) .resizable(false) @@ -136,6 +136,9 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { app.updates_rt.manual_window_open = false; } }); + if let Some(w) = &shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Window); + } if !open { app.updates_rt.manual_window_open = false; } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 577ac4496..97cd8ac69 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -402,17 +402,24 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { panel_limits, |ui, panel| match panel { Panel::Canvas => { - canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); + let child = ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect())); + crate::widgets::announce_region(&child, egui::accesskit::Role::Main, crate::i18n::tr(Panel::Canvas.label())); + canvas = Some(child); } Panel::Library => { + crate::widgets::announce_region(ui, egui::accesskit::Role::Navigation, crate::i18n::tr(Panel::Library.label())); crate::widgets::register(ui, "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { + crate::widgets::announce_region(ui, egui::accesskit::Role::Complementary, crate::i18n::tr(Panel::Tools.label())); crate::widgets::register(ui, "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } - Panel::Presets => crate::panels::presets::show(app, ui), + Panel::Presets => { + crate::widgets::announce_region(ui, egui::accesskit::Role::Complementary, crate::i18n::tr(Panel::Presets.label())); + crate::panels::presets::show(app, ui); + } }, ); if let Some(mut canvas) = canvas { diff --git a/crates/ui-egui/src/menus/menubar.rs b/crates/ui-egui/src/menus/menubar.rs index 94a961bb6..672c7cbcc 100644 --- a/crates/ui-egui/src/menus/menubar.rs +++ b/crates/ui-egui/src/menus/menubar.rs @@ -996,20 +996,22 @@ pub fn run_native_command(app: &mut LightcraftApp, id: &str, params: Value) -> N policy } -/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere. +/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⇧⌘Z` on macOS, `Ctrl+Shift+Z` elsewhere. +/// On macOS the modifiers come in AppKit's and Apple's HIG order, ⌃⌥⇧⌘, however the binding spells them. pub fn shortcut_text(sc: &str, mac: bool) -> String { if mac { - let mut mods = String::new(); + let (mut ctrl, mut alt, mut shift, mut cmd) = (false, false, false, false); let mut key = ""; for part in sc.split('+') { match part { - "Ctrl" => mods.push('⌃'), - "Alt" => mods.push('⌥'), - "Shift" => mods.push('⇧'), - "Cmd" => mods.push('⌘'), + "Ctrl" => ctrl = true, + "Alt" => alt = true, + "Shift" => shift = true, + "Cmd" => cmd = true, k => key = k, } } + let mods: String = [(ctrl, '⌃'), (alt, '⌥'), (shift, '⇧'), (cmd, '⌘')].into_iter().filter_map(|(on, glyph)| on.then_some(glyph)).collect(); let key = match key { "Delete" => "⌫", "Escape" => "⎋", diff --git a/crates/ui-egui/src/menus/menubar/tests.rs b/crates/ui-egui/src/menus/menubar/tests.rs index 72fc077d1..cc3c5c4b0 100644 --- a/crates/ui-egui/src/menus/menubar/tests.rs +++ b/crates/ui-egui/src/menus/menubar/tests.rs @@ -43,7 +43,7 @@ fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { assert_eq!(label, "Photo Grid"); assert_eq!(accelerator, Some("G")); assert!(!native_menu_delivers(accelerator.unwrap())); - for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { + for (key, text) in [("Cmd+Shift+G", "⇧⌘G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); assert_eq!(label, format!("Photo Grid ({text})")); @@ -767,11 +767,23 @@ fn native_menu_commands_respect_the_blocking_window_policy() { #[test] fn shortcut_text_per_platform() { - assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); + assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⇧⌘Z"); assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); assert_eq!(shortcut_text("Delete", true), "⌫"); } +/// AppKit and Apple's Human Interface Guidelines write modifiers ⌃⌥⇧⌘ whatever order the binding +/// was spelled in, so the hint on a button matches the menu bar's own column. +#[test] +fn shortcut_text_orders_mac_modifiers_control_option_shift_command() { + assert_eq!(shortcut_text("Cmd+Alt+Shift+C", true), "⌥⇧⌘C"); + assert_eq!(shortcut_text("Shift+Alt+Ctrl+Cmd+K", true), "⌃⌥⇧⌘K"); + assert_eq!(shortcut_text("Cmd+Ctrl+F", true), "⌃⌘F"); + assert_eq!(shortcut_text("Shift+Cmd+Up", true), "⇧⌘↑"); + assert_eq!(shortcut_text("Shift+Alt+Y", true), "⌥⇧Y"); + assert_eq!(shortcut_text("Cmd+Alt+Shift+C", false), "Ctrl+Alt+Shift+C", "other platforms keep the binding as written"); +} + /// Every item of a menu, submenus included. fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { nodes diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 8c442555d..81ea562ea 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -25,6 +25,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .exact_size(t.bottom_bar_h) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { + crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Bottom bar")); let full = ui.max_rect(); let compact_zoom = full.width() < 360.0; let right_start = right_side(app, ui, full, compact_zoom); diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index 3858a572d..52e9c8b25 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -65,10 +65,14 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let ppp = ui.ctx().pixels_per_point(); if ids.is_empty() { let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); + // a label, not painted text, so a screen reader reads the reason; the region keeps the strip navigable + let mut empty = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::centered_and_justified(egui::Direction::TopDown))); + crate::widgets::announce_region(&empty, egui::accesskit::Role::Region, crate::i18n::tr("Filmstrip")); + empty.add(egui::Label::new(egui::RichText::new(why).font(t.font(12.5)).color(t.text_dim)).selectable(false)); return; } let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); + crate::widgets::announce_region(&child, egui::accesskit::Role::Region, crate::i18n::tr("Filmstrip")); // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) child.style_mut().always_scroll_the_only_direction = true; // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` diff --git a/crates/ui-egui/src/panels/edit/histogram.rs b/crates/ui-egui/src/panels/edit/histogram.rs index 085fbda5d..ac11e691e 100644 --- a/crates/ui-egui/src/panels/edit/histogram.rs +++ b/crates/ui-egui/src/panels/edit/histogram.rs @@ -347,7 +347,7 @@ mod tests { 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)"); + 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(); diff --git a/crates/ui-egui/src/panels/empty_library.rs b/crates/ui-egui/src/panels/empty_library.rs index d420ee1a0..c26c87f41 100644 --- a/crates/ui-egui/src/panels/empty_library.rs +++ b/crates/ui-egui/src/panels/empty_library.rs @@ -49,6 +49,18 @@ pub fn hint(keymap: &Keymap, mac: bool) -> String { } } +/// The grid's second line when a view with no photos is not the empty library (an empty album, say): +/// the way in is File → Import Photos…, with the shortcut that command is bound to now, written as +/// menus write it. With no shortcut the sentence leaves it out. +pub fn no_photos_body(keymap: &Keymap, mac: bool) -> String { + match crate::shortcuts::shortcut_of(keymap, "file.addPhotos") { + Some(sc) => { + crate::i18n::tr_format!("Import photos with File → Import Photos… ({}), or drop them here", crate::menubar::shortcut_text(sc, mac)) + } + None => crate::i18n::tr("Import photos with File → Import Photos…, or drop them here").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" }; diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 8aea4f9d0..19a9265a5 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -23,7 +23,7 @@ pub use albums::{album_drag_feedback, auto_scroll_speed}; use albums::{albums_rows, drag_auto_scroll}; pub(crate) use dates::browse_all_photos; use dates::date_rows; -pub(crate) use folders::{folder_row_of, move_photos, moved_text}; +pub(crate) use folders::{created_text, folder_row_of, move_photos, moved_text}; use folders::{folder_rows, reveal_chosen}; use keywords::keyword_rows; pub(crate) use local::{fs_cached, fs_cached_running, same_folder}; diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index c9066c6fa..0dceb0df5 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -481,6 +481,13 @@ pub(crate) fn moved_text(n: u64, name: &str) -> String { } } +/// What `folder.create` of the folder at `path` inside `parent` says when it succeeds: the folder +/// is on disk now, but it has no photos, so the Folders list may not show it yet. +pub(crate) fn created_text(path: &str, parent: &str) -> String { + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |f| f.to_string_lossy().to_string()); + crate::i18n::tr_format!("Created folder “{name}” in “{parent}”", name = name, parent = lightcraft_catalog::folders::folder_label(parent)) +} + /// [`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) { diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index c8b8aed60..c4a1bd947 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -28,6 +28,10 @@ use crate::widgets::register; /// windows and their dim. const DIM_ID: &str = "library-problem-dim"; const ERROR_WINDOW_ID: &str = "library-problem"; +/// What the error window says about the library's safety and the way on (drawn as it is, not translated). +const EXPLANATION: &str = "Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \ + or reconnecting its drive), open a different library or create a new one, or continue without a library — \ + then nothing you do is saved."; const CHOOSER_WINDOW_ID: &str = "library-chooser"; /// The named `Foreground` layer used by the dim and the two blocking library windows. @@ -1082,6 +1086,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { dim(ctx); let mut choice = None; let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); + // what the window says, in one piece, for a screen reader: the labels below are separate nodes + let description = if problem.path.is_empty() { + format!("{} {} {EXPLANATION}", crate::i18n::tr("LightCraft couldn't find where to keep your library."), problem.error) + } else { + format!("{} {}. {} {EXPLANATION}", crate::i18n::tr("LightCraft couldn't open the library at"), problem.path, problem.error) + }; let shown = egui::Window::new(crate::i18n::tr("Your library couldn't be opened")) .id(egui::Id::unique(ERROR_WINDOW_ID)) .order(egui::Order::Foreground) @@ -1101,14 +1111,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text)); } ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap()); - ui.label( - RichText::new( - "Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \ - or reconnecting its drive), open a different library or create a new one, or continue without a library — \ - then nothing you do is saved.", - ) - .color(t.text_dim), - ); + ui.label(RichText::new(EXPLANATION).color(t.text_dim)); ui.add_space(4.0); ui.horizontal(|ui| { let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { @@ -1125,6 +1128,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { }); }); if let Some(w) = &shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Alert); + crate::widgets::describe_dialog(ctx, &w.response, &description); // A press on the dim raises its area; keep this window (and its own buttons) above it. ctx.move_to_top(w.response.layer_id); } @@ -1247,6 +1252,7 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP }); }); if let Some(w) = &shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog); // A press on the dim raises its area; keep the launch list above it. ctx.move_to_top(w.response.layer_id); } diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 85061f252..cec974aa6 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -518,6 +518,8 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s }); // A backdrop press can raise its area; keep the prompt buttons above it. if let Some(window) = shown { + crate::widgets::announce_dialog(ctx, &window.response, crate::widgets::Modal::Alert); + crate::widgets::describe_dialog(ctx, &window.response, text); ctx.move_to_top(window.response.layer_id); } input.button_ids = seen; diff --git a/crates/ui-egui/src/panels/strip.rs b/crates/ui-egui/src/panels/strip.rs index 164204b86..faf527be9 100644 --- a/crates/ui-egui/src/panels/strip.rs +++ b/crates/ui-egui/src/panels/strip.rs @@ -26,6 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .stroke(egui::Stroke::new(1.0, t.divider)), ) .show(ui, |ui| { + crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Tool strip")); let has_photo = app.session.active().is_some(); ui.vertical_centered(|ui| { ui.spacing_mut().item_spacing.y = GAP; diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 5e3fea8a1..35e24a781 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -28,10 +28,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { (true, true) => 16, (false, _) => 10, }; - egui::Panel::top("top_bar") + // A dialog fades the disabled bar's controls, and its fill with them. A sheet leaves the title bar's + // color alone (the window buttons the OS draws over it stay lit), so the fill is painted unfaded + // beneath, in a slot reserved before the bar's own shapes. + let sheet = app.integrated_titlebar && !app.window_is_fullscreen && app.ui.dialog.is_some(); + let slot = sheet.then(|| ui.ctx().layer_painter(ui.layer_id()).add(egui::Shape::Noop)); + let fill = if sheet { egui::Color32::TRANSPARENT } else { t.chrome }; + let panel = 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 })) + .frame(egui::Frame::NONE.fill(fill).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { + crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Top bar")); let full = ui.max_rect(); // The whole bar, its margins included. The search field is centred on this and not on // `full`, which is only what the margins leave: on macOS the left margin holds the @@ -57,6 +64,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let x = right_icons(app, ui, &t, full); appearance_button(app, ui, &t, full, x); }); + if let Some(slot) = slot { + ui.ctx().layer_painter(ui.layer_id()).set(slot, egui::Shape::rect_filled(panel.response.rect, 0.0, t.chrome)); + } } /// Sidebar toggle, back/forward and, without a native menu bar, the in-window menus. @@ -180,8 +190,11 @@ fn right_icons(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rec ("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::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); - crate::widgets::announce(&resp, crate::i18n::tr(tip)); + let tip = crate::i18n::tr(tip); + let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()); + let name = crate::widgets::accessible_label(&resp, tip); + let resp = resp.on_hover_text(tip); + crate::widgets::announce(&resp, name); register(ui, format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 9db6e38cf..aa535e377 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -16,9 +16,11 @@ mod crop_rotate; mod crop_tool; mod curve; mod date_picker; +mod dialog_copy; mod dialog_fit; mod dialog_primary; mod dialog_settings; +mod dialog_settings_text; mod dialog_tab; mod dialogs; mod docking_acceptance; @@ -83,6 +85,7 @@ mod switch_library; mod sync; mod tallies; mod temp_slider; +mod text_prompt; mod titlebar; mod tool_overlay; mod unsaved; diff --git a/crates/ui-egui/src/tests/dialog_copy.rs b/crates/ui-egui/src/tests/dialog_copy.rs new file mode 100644 index 000000000..7983c3052 --- /dev/null +++ b/crates/ui-egui/src/tests/dialog_copy.rs @@ -0,0 +1,236 @@ +//! A dialog's confirm button names what it does ("Rename", "Create", "Create Stacks"…), not "OK": +//! the button is the last thing read before a change is made. Every check reads the text painted +//! inside the `button:dialogOk` widget itself, not just any text in the window. + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; +use serde_json::json; +use std::time::Duration; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + h.settle(T); + h +} + +fn show(h: &mut Headless, dialog: Dialog) { + h.app.ui.dialog = Some(dialog); + h.step(); + h.step(); +} + +fn dialog(value: serde_json::Value) -> Dialog { + serde_json::from_value(value).unwrap_or_else(|e| panic!("bad dialog json: {e}")) +} + +/// The text painted inside the confirm button. +fn confirm_label(h: &Headless) -> String { + let rect = h.app.widgets.iter().find(|(w, _)| w == "button:dialogOk").map(|(_, r)| *r).unwrap_or_else(|| panic!("no confirm button")); + h.view + .painted_shapes() + .into_iter() + .filter_map(|s| match s { + egui::epaint::Shape::Text(t) if rect.contains(t.pos + t.galley.rect.center().to_vec2()) => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect::>() + .join("") +} + +fn select(h: &mut Headless, n: usize) { + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(n).collect(); + h.app.session.selection.ids = ids; +} + +fn text_with(h: &Headless, start: &str) -> String { + h.painted_text().into_iter().find(|t| t.starts_with(start)).unwrap_or_else(|| panic!("no text starting {start:?}")) +} + +#[test] +fn confirm_buttons_name_their_action() { + let mut h = demo(); + h.app + .run("photo.setMeta", json!({"ids": [h.app.session.catalog.photos().next().map(|p| p.id.0).unwrap_or(0)], "addKeywords": ["taken"]})) + .unwrap(); + let cases: Vec<(&str, serde_json::Value, &str)> = vec![ + ("new album", json!({"kind": "newAlbum", "name": "", "folder": false}), "Create"), + ("new folder", json!({"kind": "newAlbum", "name": "", "folder": true}), "Create"), + ("rename album", json!({"kind": "renameAlbum", "id": 1, "name": "A"}), "Rename"), + ("new smart album", json!({"kind": "newSmartAlbum", "name": ""}), "Create"), + ("auto-stack", json!({"kind": "autoStack", "gap": 60.0}), "Create Stacks"), + ("capture time", json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}), "Change Time"), + ("label names", json!({"kind": "labelNames", "names": [], "save_as": ""}), "Save"), + ("rename photos", json!({"kind": "rename", "template": "{name}", "start": 1}), "Rename"), + ("rename keyword", json!({"kind": "renameKeyword", "from": "a", "to": "b"}), "Rename"), + ("rename keyword onto an existing one", json!({"kind": "renameKeyword", "from": "a", "to": "taken"}), "Merge Keywords"), + ("rename person", json!({"kind": "renamePerson", "from": "Ann", "to": "Anna", "merge": false}), "Rename"), + ("merge keywords", json!({"kind": "mergeKeywords", "from": ["a"], "into": "b"}), "Merge Keywords"), + ("assisted culling", json!({"kind": "cull", "reject_below": 0.0, "pick_best": true, "burst_gap": 10.0, "similarity": 0.93}), "Cull"), + ("new preset", json!({"kind": "createPreset", "name": "", "group": ""}), "Create"), + ("copy settings", json!({"kind": "copySettings", "groups": []}), "Copy"), + ("paste settings", json!({"kind": "pasteSettings", "groups": []}), "Paste"), + ]; + for (what, value, want) in cases { + show(&mut h, dialog(value)); + assert_eq!(confirm_label(&h), want, "{what}"); + } + // a new smart album's rules are created; an existing one's are saved + for (id, want) in [(None, "Create"), (Some(1), "Save")] { + let d = Dialog::SmartRules { id, name: "A".into(), rules: Default::default(), parent: None }; + show(&mut h, d); + assert_eq!(confirm_label(&h), want, "smart album rules {id:?}"); + } +} + +#[test] +fn one_field_prompts_name_what_they_do() { + let mut h = demo(); + let prompts = [ + ("album.rename", "Rename"), + ("folder.rename", "Rename"), + ("folder.create", "Create"), + ("version.rename", "Rename"), + ("preset.rename", "Rename"), + ("mask.renameEffect", "Rename"), + ("mask.setEffectGroup", "Create"), + ("preset.move", "Move"), + ("app.newLibrary", "Create"), + ("keyword.saveSet", "Save"), + ("filter.savePreset", "Save"), + ("curve.savePreset", "Save"), + ("metadata.savePreset", "Save"), + ("photo.tagFromTracklog", "Add Locations"), + ]; + for (command, want) in prompts { + let prompt = Dialog::TextPrompt { + title: "Title".into(), + hint: "Hint".into(), + value: String::new(), + command: command.into(), + params: json!({}), + key: "name".into(), + }; + show(&mut h, prompt); + assert_eq!(confirm_label(&h), want, "{command}"); + } +} + +#[test] +fn export_confirm_says_how_many_photos() { + let mut h = demo(); + for (n, want) in [(1, "Export 1 Photo"), (3, "Export 3 Photos")] { + select(&mut h, n); + h.app.run("dialog.export", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(confirm_label(&h), want); + } +} + +/// With Move, a checked file whose photo is in Recently Deleted is restored, not moved: the confirm counts it apart. +#[test] +fn the_move_confirm_counts_restored_photos_apart_from_moved_ones() { + let mut h = demo(); + let review = |copy: bool, move_files: bool, on_deleted: &str, checked: [bool; 3]| { + let candidates = (0..3).map(|i| lightcraft_engine::import::ImportCandidate { path: format!("img{i}.jpg"), ..Default::default() }).collect(); + let mut opts = crate::import::ImportDialog::new(candidates); + opts.copy = copy; + opts.move_files = move_files; + opts.trashed = vec![true, false, false]; + opts.on_deleted = on_deleted.into(); + opts.checked = checked.to_vec(); + Dialog::Import { opts: Box::new(opts) } + }; + for (copy, move_files, on_deleted, checked, want) in [ + (true, true, "restore", [true, true, true], "Move 2 Photos · Restore 1 Photo"), + (true, true, "restore", [true, true, false], "Move 1 Photo · Restore 1 Photo"), + (true, true, "restore", [true, false, false], "Restore 1 Photo"), + (true, true, "restore", [false, true, true], "Move 2 Photos"), + (true, true, "fresh", [true, true, true], "Move 3 Photos"), + // "Leave them" still restores a file that was checked by hand: the engine's default + (true, true, "", [true, true, true], "Move 2 Photos · Restore 1 Photo"), + (true, false, "restore", [true, true, true], "Import 3 Photos"), + ] { + show(&mut h, review(copy, move_files, on_deleted, checked)); + // the wide review sits over the photo grid: its text (view tab, file names) is painted inside the button's rectangle too, + // so the label is what the rectangle's text ends with, and it names each verb as often as `want` does + let label = confirm_label(&h); + let said = |verb: &str| (label.matches(verb).count(), want.matches(verb).count()); + let what = format!("copy {copy}, move {move_files}, {on_deleted}, {checked:?}: {label:?}"); + assert!(label.ends_with(want), "{what}"); + assert_eq!(said("Move").0, said("Move").1, "{what}"); + assert_eq!(said("Restore").0, said("Restore").1, "{what}"); + } +} + +#[test] +fn an_unusable_model_file_only_closes() { + let mut h = demo(); + for kind in ["denoiseModel", "faceModel"] { + let mut d = json!({"kind": kind, "info": {"kind": "unsupported", "fileName": "x.onnx", "sizeBytes": 1}, "accepted": false}); + d["path"] = json!("x.onnx"); + show(&mut h, dialog(d)); + assert_eq!(confirm_label(&h), "Close", "{kind}"); + } +} + +#[test] +fn the_confirm_is_the_verb_in_another_language() { + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::i18n::set_language(crate::i18n::Locale::En); + } + } + let _restore = Restore; + let mut h = demo(); + h.app.ui.language = crate::i18n::Locale::De; + show(&mut h, dialog(json!({"kind": "autoStack", "gap": 60.0}))); + assert_eq!(confirm_label(&h), "Stapel erstellen"); + show(&mut h, dialog(json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}))); + assert_eq!(confirm_label(&h), "Zeit ändern"); + show(&mut h, dialog(json!({"kind": "renameAlbum", "id": 1, "name": "A"}))); + assert_eq!(confirm_label(&h), "Umbenennen"); +} + +/// "N noun(s)" in `text`: the number before `noun` and whether `noun` is written in the plural. +fn count_before(text: &str, noun: &str) -> (u64, bool) { + let at = text.find(noun).unwrap_or_else(|| panic!("{text:?} has no {noun:?}")); + let n = text[..at].trim_end().rsplit(' ').next().and_then(|w| w.parse().ok()).unwrap_or_else(|| panic!("no number before {noun:?} in {text:?}")); + (n, text[at + noun.len()..].starts_with('s')) +} + +#[test] +fn counts_agree_with_their_nouns() { + let mut h = demo(); + // contact sheet: one photo, then two + for n in [1usize, 2] { + select(&mut h, n); + let options = serde_json::to_value(lightcraft_engine::contact_sheet::Options::default()).unwrap(); + show(&mut h, dialog(json!({"kind": "contactSheet", "options": options}))); + let text = text_with(&h, &format!("{n} selected photo")); + assert_eq!(text, if n == 1 { "1 selected photo · 1 page" } else { "2 selected photos · 1 page" }); + } + // auto-stack: a tiny gap and a huge one give different counts; each agrees with its noun + for gap in [1.0, 86400.0] { + show(&mut h, dialog(json!({"kind": "autoStack", "gap": gap}))); + let text = text_with(&h, "Creates "); + let (stacks, plural) = count_before(&text, " stack"); + assert_eq!(plural, stacks != 1, "{text:?}"); + } + // slideshow export: photos and slides + show( + &mut h, + dialog( + json!({"kind": "slideshowExport", "settings": serde_json::to_value(lightcraft_engine::slideshow_export::ExportSettings::default()).unwrap()}), + ), + ); + let text = h.painted_text().into_iter().find(|t| t.contains("in view")).unwrap_or_default(); + let (photos, p1) = count_before(&text, " photo"); + let (slides, p2) = count_before(&text, " slide"); + assert_eq!((p1, p2), (photos != 1, slides != 1), "{text:?}"); +} diff --git a/crates/ui-egui/src/tests/dialog_settings_text.rs b/crates/ui-egui/src/tests/dialog_settings_text.rs new file mode 100644 index 000000000..fca63500a --- /dev/null +++ b/crates/ui-egui/src/tests/dialog_settings_text.rs @@ -0,0 +1,146 @@ +//! Esc in a text box of Settings gives the text back and leaves Settings open, and text typed in a +//! box is saved when another tab is clicked. +//! +//! Before: Esc in Copyright or Creator saved what had been typed and closed the window, and text +//! typed there was dropped when another tab was clicked. + +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(20); + +fn demo() -> Headless { + let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()), [1200.0, 900.0], 1.0); + // let the first renders finish (a render still running at exit can crash in GPU teardown) + h.settle(T); + h +} + +fn steps(h: &mut Headless) { + for _ in 0..4 { + h.step(); + } +} + +fn show(h: &mut Headless, tab: &str) { + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": tab}}), T); + assert_eq!(r["ok"], true, "app.settings {tab}: {r}"); + h.settle(T); + steps(h); +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "click {id}: {r}"); + steps(h); +} + +fn key(h: &mut Headless, key: &str) { + assert_eq!(h.request("ui.key", json!({"key": key}), T)["ok"], true, "{key}"); + steps(h); +} + +fn says(h: &Headless, part: &str) -> bool { + h.painted_text().iter().any(|text| text.contains(part)) +} + +fn settings_open(h: &Headless, tab: &str) -> bool { + h.app.ui.dialog == Some(Dialog::Settings { tab: tab.into() }) +} + +fn copyright(h: &mut Headless) -> Value { + h.app.run("library.preferences", json!({})).unwrap()["import"]["copyright"].clone() +} + +#[test] +fn esc_in_copyright_gives_the_text_back_and_leaves_settings_open() { + let mut h = demo(); + show(&mut h, "import"); + let before = copyright(&mut h); + // Given a name typed into Copyright + click(&mut h, "field:settings.copyright"); + h.request("ui.text", json!({"text": "Example Name"}), T); + steps(&mut h); + assert!(says(&h, "Example Name"), "the typing shows"); + // When Esc is pressed + key(&mut h, "Escape"); + // Then the name is gone again, nothing was saved, and Settings is still open + assert!(!says(&h, "Example Name"), "Esc gives the text back"); + assert_eq!(copyright(&mut h), before, "nothing was saved"); + assert!(settings_open(&h, "import"), "Settings stays open: {:?}", h.app.ui.dialog); + // And a second Esc, with no edit under way, closes Settings as it always did + key(&mut h, "Escape"); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn return_in_copyright_saves_the_text() { + let mut h = demo(); + show(&mut h, "import"); + click(&mut h, "field:settings.copyright"); + h.request("ui.text", json!({"text": "Example Name"}), T); + steps(&mut h); + key(&mut h, "Enter"); + assert_eq!(copyright(&mut h), json!("Example Name")); + assert!(settings_open(&h, "import")); +} + +#[test] +fn text_typed_in_copyright_is_saved_when_another_tab_is_opened() { + let mut h = demo(); + show(&mut h, "import"); + click(&mut h, "field:settings.copyright"); + h.request("ui.text", json!({"text": "Example Name"}), T); + steps(&mut h); + // When another tab is clicked with the text still being typed + click(&mut h, "button:settingsTab-general"); + // Then that tab opens and the text was saved, as leaving the box any other way saves it + assert!(settings_open(&h, "general")); + assert_eq!(copyright(&mut h), json!("Example Name")); +} + +#[test] +fn esc_in_an_external_editing_box_leaves_settings_open_too() { + let mut h = demo(); + show(&mut h, "general"); + // (External Editing is far down the General page: scroll it into view) + assert_eq!(h.request("ui.hoverWidget", json!({"id": "settings:pageRoom"}), T)["ok"], true); + h.request("ui.scroll", json!({"dy": -3000.0}), T); + steps(&mut h); + let before = h.app.ui.settings.external_editor.clone(); + click(&mut h, "field:externalEditor"); + h.request("ui.text", json!({"text": "Example Editor"}), T); + steps(&mut h); + key(&mut h, "Escape"); + assert_eq!(h.app.ui.settings.external_editor, before, "Esc gives the text back"); + assert!(settings_open(&h, "general"), "and Settings stays open: {:?}", h.app.ui.dialog); +} + +#[test] +fn using_the_edit_menu_does_not_save_a_half_typed_text() { + let mut h = demo(); + show(&mut h, "import"); + let before = copyright(&mut h); + click(&mut h, "field:settings.copyright"); + h.request("ui.text", json!({"text": "Example Name"}), T); + steps(&mut h); + // When the field's Cut / Copy / Paste menu is opened, which takes the focus off the box + let r = h.request("ui.clickWidget", json!({"id": "field:settings.copyright", "button": "right"}), T); + assert_eq!(r["ok"], true, "right click: {r}"); + steps(&mut h); + assert!(h.app.widgets.iter().any(|(w, _)| w == "field:settings.copyright:copy"), "the menu is open"); + // Then the text typed so far is still there, and nothing was saved + assert!(says(&h, "Example Name"), "the typing stays in the box"); + assert_eq!(copyright(&mut h), before, "an unfinished edit is not saved"); + // And Esc closes only the menu: the edit goes on, and Return then saves it + key(&mut h, "Escape"); + assert_eq!(copyright(&mut h), before, "closing the menu does not end the edit"); + assert!(settings_open(&h, "import"), "Esc closed the menu only"); + key(&mut h, "Enter"); + assert_eq!(copyright(&mut h), json!("Example Name")); +} diff --git a/crates/ui-egui/src/tests/empty_library.rs b/crates/ui-egui/src/tests/empty_library.rs index 2207875b9..16cd52cbb 100644 --- a/crates/ui-egui/src/tests/empty_library.rs +++ b/crates/ui-egui/src/tests/empty_library.rs @@ -132,16 +132,33 @@ fn the_top_bar_imports_in_every_library() { #[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, 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"); + 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"); } +/// The grid's line for a view with no photos that is not the empty library names the shortcut as +/// the menu bar lists it, in the platform's notation, and follows a rebinding. +#[test] +fn the_no_photos_line_lists_modifiers_in_the_menu_order() { + let mut keymap = crate::state::AppSettings::default().keymap; + let mac = empty_library::no_photos_body(&keymap, true); + assert!(mac.contains("⇧⌘I") && !mac.contains("⌘⇧"), "{mac}"); + let other = empty_library::no_photos_body(&keymap, false); + assert!(other.contains("Ctrl+Shift+I") && !other.contains("Cmd"), "{other}"); + crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+Shift+I")).unwrap(); + let rebound = empty_library::no_photos_body(&keymap, true); + assert!(rebound.contains("⌥⇧⌘I"), "{rebound}"); + crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap(); + let unbound = empty_library::no_photos_body(&keymap, true); + assert!(!unbound.contains('⌘') && !unbound.contains("Cmd") && !unbound.starts_with("or "), "{unbound}"); +} + #[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 diff --git a/crates/ui-egui/src/tests/text_prompt.rs b/crates/ui-egui/src/tests/text_prompt.rs new file mode 100644 index 000000000..81c383fd1 --- /dev/null +++ b/crates/ui-egui/src/tests/text_prompt.rs @@ -0,0 +1,168 @@ +//! A one-line prompt that the engine refuses stays open with its text and says why, so the name can +//! be fixed; a folder made on disk says it was made. + +use std::path::PathBuf; +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); +const SETTLE: Duration = Duration::from_secs(60); +const CREATED: &str = "Created folder “{name}” in “{parent}”"; + +/// A fresh `Shoot` folder holding `Taken` and `Other`, in a temporary folder of its own. +fn shoot(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("lc-text-prompt-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let shoot = root.join("Shoot"); + for name in ["Taken", "Other"] { + std::fs::create_dir_all(shoot.join(name)).unwrap(); + } + shoot +} + +/// Remove the temporary folder `shoot` made, which holds `dir`. +fn clean(dir: &std::path::Path) { + let _ = std::fs::remove_dir_all(dir.parent().unwrap()); +} + +fn app() -> Headless { + let session = lightcraft_engine::Session::new().with_fs(); + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0); + h.settle(SETTLE); + h +} + +fn prompt(command: &str, value: &str, params: serde_json::Value, key: &str) -> Dialog { + Dialog::TextPrompt { title: "Title".into(), hint: "Name".into(), value: value.into(), command: command.into(), params, key: key.into() } +} + +/// Open `dialog`, press its OK button, and let the frame settle. +fn confirm(h: &mut Headless, dialog: Dialog) { + h.app.ui.dialog = Some(dialog); + for _ in 0..4 { + h.step(); + } + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default() +} + +fn assert_stays_open(h: &Headless, value: &str, reason: &str) { + match &h.app.ui.dialog { + Some(Dialog::TextPrompt { value: kept, .. }) => assert_eq!(kept, value, "the typed text is kept"), + other => panic!("the prompt stays open: {other:?}"), + } + assert!(toast(h).contains(reason), "the reason is shown: {:?}", toast(h)); +} + +#[test] +fn a_new_folder_whose_name_is_taken_keeps_the_prompt_open_and_says_why() { + let dir = shoot("create-taken"); + let mut h = app(); + confirm(&mut h, prompt("folder.create", "Taken", json!({"parent": dir.to_string_lossy()}), "name")); + assert_stays_open(&h, "Taken", "already exists"); + clean(&dir); +} + +#[test] +fn a_rename_to_a_taken_name_keeps_the_prompt_open_and_says_why() { + let dir = shoot("rename-taken"); + let mut h = app(); + confirm(&mut h, prompt("folder.rename", "Taken", json!({"path": dir.join("Other").to_string_lossy()}), "name")); + assert_stays_open(&h, "Taken", "already exists"); + assert!(dir.join("Other").is_dir(), "nothing was renamed"); + clean(&dir); +} + +#[test] +fn a_rename_that_keeps_the_name_closes_the_prompt_and_changes_nothing() { + let dir = shoot("rename-same"); + let mut h = app(); + // Rename Folder opens with the folder's own name typed + confirm(&mut h, prompt("folder.rename", "Other", json!({"path": dir.join("Other").to_string_lossy()}), "name")); + assert_eq!(h.app.ui.dialog, None, "the prompt closes"); + assert!(toast(&h).is_empty(), "no error is shown: {:?}", toast(&h)); + assert!(dir.join("Other").is_dir(), "nothing was renamed"); + clean(&dir); +} + +#[test] +fn the_control_channel_keeps_a_refused_prompt_open_too() { + let dir = shoot("control-taken"); + let mut h = app(); + h.app.ui.dialog = Some(prompt("folder.create", "Taken", json!({"parent": dir.to_string_lossy()}), "name")); + assert!(h.settle(SETTLE), "the prompt renders settled"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_ne!(r["ok"], true, "the name is refused: {r}"); + assert!(r.to_string().contains("already exists"), "{r}"); + match &h.app.ui.dialog { + Some(Dialog::TextPrompt { value, .. }) => assert_eq!(value, "Taken", "the typed text is kept"), + other => panic!("the prompt stays open: {other:?}"), + } + assert!(toast(&h).contains("already exists"), "the reason is on screen too, as with the button: {:?}", toast(&h)); + clean(&dir); +} + +#[test] +fn the_control_channel_says_where_a_new_folder_was_made() { + let dir = shoot("control-ok"); + let mut h = app(); + h.app.ui.dialog = Some(prompt("folder.create", "Selects", json!({"parent": dir.to_string_lossy()}), "name")); + assert!(h.settle(SETTLE), "the prompt renders settled"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(dir.join("Selects").is_dir(), "made on disk"); + let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy()); + assert_eq!(toast(&h), CREATED.replace("{name}", "Selects").replace("{parent}", &parent)); + clean(&dir); +} + +#[test] +fn a_rename_that_keeps_a_name_with_spaces_at_its_ends_changes_nothing() { + let dir = shoot("rename-padded"); + std::fs::create_dir_all(dir.join(" Pad ")).unwrap(); + let mut h = app(); + // Rename Folder opens with the folder's own name typed, spaces and all + confirm(&mut h, prompt("folder.rename", " Pad ", json!({"path": dir.join(" Pad ").to_string_lossy()}), "name")); + assert_eq!(h.app.ui.dialog, None, "the prompt closes"); + assert!(toast(&h).is_empty(), "no error is shown: {:?}", toast(&h)); + assert!(dir.join(" Pad ").is_dir() && !dir.join("Pad").exists(), "the folder keeps its name"); + clean(&dir); +} + +#[test] +fn a_new_folder_closes_the_prompt_and_says_where_it_is() { + let dir = shoot("create-ok"); + let mut h = app(); + confirm(&mut h, prompt("folder.create", " Selects ", json!({"parent": dir.to_string_lossy()}), "name")); + assert!(dir.join("Selects").is_dir(), "made on disk"); + assert_eq!(h.app.ui.dialog, None); + let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy()); + assert_eq!(toast(&h), CREATED.replace("{name}", "Selects").replace("{parent}", &parent)); + clean(&dir); +} + +#[test] +fn the_created_folder_toast_comes_through_the_catalog() { + let dir = shoot("create-de"); + let path = dir.join("Selects"); + let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy()); + // the catalog's own value (formats have no `Locale` accessor); a language without one shows the English wording + let catalog: std::collections::BTreeMap = serde_json::from_str(include_str!("../../locales/de-formats.json")).unwrap(); + let own = catalog.get(CREATED).filter(|text| !text.is_empty()).map_or(CREATED, String::as_str); + let want = own.replace("{name}", "Selects").replace("{parent}", &parent); + crate::i18n::set_language(crate::i18n::Locale::De); + let text = crate::panels::left::created_text(&path.to_string_lossy(), &dir.to_string_lossy()); + crate::i18n::set_language(crate::i18n::Locale::En); + assert_eq!(text, want); + clean(&dir); +} diff --git a/crates/ui-egui/src/tests/titlebar.rs b/crates/ui-egui/src/tests/titlebar.rs index 8d40f212e..d207fa81d 100644 --- a/crates/ui-egui/src/tests/titlebar.rs +++ b/crates/ui-egui/src/tests/titlebar.rs @@ -191,3 +191,169 @@ fn a_panel_floating_over_the_bar_keeps_the_window_from_moving() { play(&mut h); assert!(!hover(&mut h, empty), "the floating panel covers the empty bar there"); } + +// A dialog on the integrated title bar behaves like a macOS sheet: the backdrop leaves the bar +// undimmed (the window buttons AppKit draws over it stay lit beside a bar that looks live), while +// the bar's own controls stay inactive. + +fn pixel(img: &egui::ColorImage, p: egui::Pos2) -> egui::Color32 { + img.pixels[p.y as usize * img.size[0] + p.x as usize] +} + +fn with_export_dialog(mut h: Headless) -> Headless { + h.app.run("dialog.export", json!({})).unwrap(); + h.step(); + h.step(); + h.settle(T); + h +} + +// Scenario: the dialog's backdrop does not dim the integrated title bar +#[test] +fn a_dialog_leaves_the_integrated_title_bar_undimmed() { + let mut h = integrated(); + let spot = empty_spot(&h); + let below = egui::pos2(spot.x, widget(&h, "region:titlebar").bottom() + 60.0); + let closed = h.snapshot(T); + let mut h = with_export_dialog(h); + let open = h.snapshot(T); + assert_eq!(pixel(&open, spot), pixel(&closed, spot), "the bar keeps its color under a dialog"); + assert_ne!(pixel(&open, below), pixel(&closed, below), "the window below the bar is dimmed"); +} + +// Scenario: a window with its native title bar dims everything, the top bar included +#[test] +fn a_dialog_dims_the_top_bar_without_the_integrated_title_bar() { + 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); + h.settle(Duration::from_secs(120)); + let spot = egui::pos2(20.0, 20.0); + let closed = h.snapshot(T); + let mut h = with_export_dialog(h); + let open = h.snapshot(T); + assert_ne!(pixel(&open, spot), pixel(&closed, spot), "the top bar is dimmed"); +} + +// Scenario: the full-screen photo view draws no top bar, so a dialog opened there has no title bar band to spare: the +// frame the view is entered on still holds the bar's region from the frame before, and must not lay a window handle +// over the backdrop (the band that handle covers is the strip the backdrop would leave undimmed) +#[test] +fn a_dialog_in_the_full_screen_photo_view_has_no_title_bar_handle() { + let mut h = integrated(); + assert!(h.app.widgets.iter().any(|(w, _)| w == "region:titlebar"), "the bar was drawn the frame before"); + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + h.app.run("dialog.export", json!({})).unwrap(); + h.step(); + assert!(h.app.ui.fullscreen && h.app.ui.dialog.is_some()); + let handles = [0usize, 1].map(|i| egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dialog-title-bar").with(i))); + let handle = h.view.ctx.memory(|m| handles.iter().any(|layer| m.areas().visible_last_frame(layer))); + assert!(!handle, "no title bar handle over the photo"); +} + +// Scenario: a press on the backdrop just below the bar, released on the bar, is not a click outside the dialog +#[test] +fn a_press_below_the_title_bar_released_on_it_keeps_the_dialog_open() { + let mut h = integrated(); + h.app.ui.settings.close_dialogs_outside = true; + let mut h = with_export_dialog(h); + let spot = empty_spot(&h); + let bar = widget(&h, "region:titlebar"); + let below = egui::pos2(spot.x, bar.bottom() + 1.0); + let on_bar = egui::pos2(spot.x, bar.bottom() - 1.0); + h.request("ui.drag", json!({"x": below.x, "y": below.y, "toX": on_bar.x, "toY": on_bar.y}), T); + play(&mut h); + assert!(h.app.ui.dialog.is_some(), "the release on the bar is not a click on the backdrop"); +} + +// Scenario: a click on the undimmed bar is not a click outside the dialog +#[test] +fn a_click_on_the_title_bar_keeps_the_dialog_open() { + let mut h = integrated(); + h.app.ui.settings.close_dialogs_outside = true; + let mut h = with_export_dialog(h); + let at = empty_spot(&h); + h.request("ui.click", json!({"x": at.x, "y": at.y}), T); + play(&mut h); + assert!(h.app.ui.dialog.is_some(), "the bar is not the backdrop"); + let below = egui::pos2(at.x, widget(&h, "region:titlebar").bottom() + 60.0); + h.request("ui.click", json!({"x": below.x, "y": below.y}), T); + play(&mut h); + assert!(h.app.ui.dialog.is_none(), "the backdrop below it still closes the dialog"); +} + +// Scenario: the bar's controls take no clicks under a dialog +#[test] +fn the_title_bar_buttons_are_inactive_under_a_dialog() { + let mut h = with_export_dialog(integrated()); + let left = h.app.ui.left_panel; + let at = widget(&h, "icon:sidebar").center(); + h.request("ui.click", json!({"x": at.x, "y": at.y}), T); + play(&mut h); + assert_eq!(h.app.ui.left_panel, left, "the sidebar button does nothing"); + assert!(h.app.ui.dialog.is_some()); +} + +// Scenario: dragging the empty bar still moves the window under a dialog, whether the dialog is +// tall enough to reach into the bar (Export) or not (Copy Settings) +#[test] +fn dragging_the_empty_bar_moves_the_window_under_a_dialog() { + for command in ["dialog.export", "dialog.copySettings"] { + let mut h = integrated(); + h.app.run(command, json!({})).unwrap(); + h.step(); + h.step(); + h.settle(T); + let p = empty_spot(&h); + h.window_commands.clear(); + h.request("ui.drag", json!({"x": p.x, "y": p.y, "toX": p.x + 120.0, "toY": p.y + 40.0}), T); + play(&mut h); + assert_eq!(h.window_commands, vec![ViewportCommand::StartDrag], "{command}"); + } +} + +// Scenario: a tall dialog reaching into the bar keeps a press on itself from moving the window +// (AppKit's `movable` is off while the pointer is over it), the bar beside it still moves it +#[test] +fn a_dialog_inside_the_bar_keeps_the_window_from_moving() { + let mut h = with_export_dialog(integrated()); + let bar = widget(&h, "region:titlebar"); + let window = widget(&h, "dialog:window"); + assert!(window.top() < bar.bottom(), "the dialog reaches into the bar"); + let hover = |h: &mut Headless, p: egui::Pos2| { + h.events.push(egui::Event::PointerMoved(p)); + play(h); + craft_window::title_bar::window_movable(&h.view.ctx) + }; + let spot = empty_spot(&h); + assert!(hover(&mut h, spot), "the bar beside the dialog moves the window"); + assert!(!hover(&mut h, egui::pos2(window.center().x, bar.center().y)), "the dialog over the bar does not"); +} + +// Scenario: with a dropdown open in the dialog, the bar's handle stays out of the way: egui lifts an area that was not +// there the frame before above the others, so a handle drawn again as the pointer leaves the dialog would take the +// press meant for the dropdown's item +#[test] +fn the_title_bar_handle_stays_below_a_dropdown_open_in_the_dialog() { + let mut h = with_export_dialog(integrated()); + let spot = empty_spot(&h); + let handle_at = |h: &mut Headless| { + h.events.push(egui::Event::PointerMoved(spot)); + play(h); + let handles = [0usize, 1].map(|i| egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dialog-title-bar").with(i))); + h.view.ctx.memory(|m| handles.iter().any(|layer| m.areas().visible_last_frame(layer))) + }; + assert!(handle_at(&mut h), "without a dropdown the bar beside the dialog is the handle's"); + let r = h.request("ui.clickWidget", json!({"id": "combo:exportPreset"}), T); + assert_eq!(r["ok"], true, "{r}"); + play(&mut h); + assert!(egui::Popup::is_any_open(&h.view.ctx), "the preset dropdown is open"); + assert!(!handle_at(&mut h), "with one open, no handle sits above it"); + assert!(egui::Popup::is_any_open(&h.view.ctx), "(and the pointer leaving the dialog did not close it)"); + // once the dropdown is closed the handle is back, so the bar beside the dialog moves the window again + h.request("ui.key", json!({"key": "Escape"}), T); + play(&mut h); + assert!(!egui::Popup::is_any_open(&h.view.ctx), "Escape closes the dropdown"); + assert!(h.app.ui.dialog.is_some(), "and leaves the dialog"); + assert!(handle_at(&mut h), "the handle is back"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a9b2b4a6e..81997de3b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -446,7 +446,8 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it // takes the click) let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); - crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it"))); + // the name is short ("Exposure value"); how to use it is the description the hover text below gives + crate::widgets::announce(&value_resp, crate::i18n::tr_format!("{name} value", name = label_text.clone())); register(ui, 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, drag to change it")) } else { value_resp }; @@ -977,12 +978,37 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option (&str, Option<&str>) { + let split = + tip.strip_suffix(')').and_then(|body| body.rsplit_once(" (")).or_else(|| tip.strip_suffix(')').and_then(|body| body.rsplit_once('('))); + let Some((label, hint)) = split else { return (tip, None) }; + let label = label.trim_end(); + if label.is_empty() || hint.is_empty() { (tip, None) } else { (label, Some(hint)) } +} + +/// The accessible name for an icon-only control whose tooltip is `tip`, and the one place the key +/// hint leaves it. The name is the bare label, because a screen reader reads it every time it +/// lands on the control; the hint goes into the description (macOS `AXHelp`, read after a pause). +/// Sighted users keep the whole tooltip. Call it before `on_hover_text`, which would otherwise make +/// the description the whole tooltip. +pub(crate) fn accessible_label<'a>(resp: &egui::Response, tip: &'a str) -> &'a str { + let (label, hint) = split_key_hint(tip); + if let Some(hint) = hint { + resp.ctx.accesskit_node_builder(resp.id, |node| node.set_description(hint.to_string())); + } + label +} + /// An icon-only button. `active` draws the selected background (tool strip). pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: bool, enabled: bool, tooltip: &str) -> Response { let tooltip = crate::i18n::tr(tooltip); let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, active, tooltip)); + let name = accessible_label(&resp, tooltip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, active, name)); register(ui, format!("icon:{id}"), r); let p = ui.painter(); let sq = Rect::from_center_size(r.center(), vec2(32.0f32.min(size.x), 32.0f32.min(size.y))); @@ -1043,6 +1069,34 @@ mod tests { assert!(preview_only_explanation("unsupported CR3").contains("unsupported CR3")); } + #[test] + fn a_trailing_key_hint_leaves_the_name() { + assert_eq!(split_key_hint("Edit (E)"), ("Edit", Some("E"))); + assert_eq!(split_key_hint("Presets (Shift+P)"), ("Presets", Some("Shift+P"))); + assert_eq!(split_key_hint("Import Photos… (⌘⇧I)"), ("Import Photos…", Some("⌘⇧I"))); + assert_eq!(split_key_hint("Filmstrip (/)"), ("Filmstrip", Some("/"))); + assert_eq!(split_key_hint("Square Grid (G toggles)"), ("Square Grid", Some("G toggles"))); + // nothing to split: no hint, an empty hint, no label before it, the hint not at the end + for tip in ["Export", "Edit ()", "(E)", " (E)", "Edit (E) now", "Edit(E)", ""] { + assert_eq!(split_key_hint(tip), (tip, None), "{tip:?}"); + } + } + + /// Every language's tooltip for an icon-only button splits into the bare label and its hint, + /// whatever brackets the catalog writes it in (Japanese and Traditional Chinese use full-width ones). + #[test] + fn every_language_names_an_icon_button_by_its_label() { + assert_eq!(split_key_hint("編集(E)"), ("編集", Some("E"))); + for locale in crate::i18n::Locale::ALL { + for tip in ["Edit (E)", "Presets (Shift+P)", "Photo Grid (G)", "Filmstrip (/)"] { + let translated = locale.tr(tip); + let (label, hint) = split_key_hint(translated); + assert!(hint.is_some_and(|h| !h.is_empty()), "{locale:?}: {translated:?} keeps its hint in the name"); + assert!(!label.contains(['(', ')', '(', ')']), "{locale:?}: {label:?} is the bare label"); + } + } + } + #[test] fn nudges_are_a_sensible_step() { let spec = |id, min, max, step| ControlSpec { @@ -1185,12 +1239,59 @@ 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())); } +/// Names the part of the window `ui` draws for screen readers: a toolbar, or a landmark (VoiceOver's +/// rotor lists the landmarks and jumps to one). Set on the node the `Ui` already has, so nothing is +/// laid out differently. The node gets the `Ui`'s area as its bounds, so a screen reader's cursor +/// lands on the part and not on an empty spot. +pub(crate) fn announce_region(ui: &Ui, role: egui::accesskit::Role, name: impl Into) { + let name = name.into(); + let r = ui.max_rect(); + ui.ctx().accesskit_node_builder(ui.unique_id(), |node| { + node.set_role(role); + node.set_label(name); + node.set_bounds(egui::accesskit::Rect { x0: f64::from(r.min.x), y0: f64::from(r.min.y), x1: f64::from(r.max.x), y1: f64::from(r.max.y) }); + }); +} + /// 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) { let name = name.into(); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), name.clone())); } +/// What a window with its own title and buttons is, to a screen reader. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Modal { + /// A dialog with a task (Export, Settings, a name prompt) that stops the rest of the application. + Dialog, + /// One that interrupts to say something that needs an answer (the quit prompt, a library that + /// couldn't be opened) and stops the rest of the application: an alert dialog (AT-SPI and + /// Windows tell it from a dialog; macOS reads both alike). + Alert, + /// A dialog that leaves the rest of the application reachable (the update check's answer). + Window, +} + +/// Announces the window `response` drew as a dialog (an alert dialog for [`Modal::Alert`]), and for +/// [`Modal::Dialog`] and [`Modal::Alert`] as modal, instead of as a group that carries the title: +/// the platform's accessibility layer then sees a dialog and that the rest of the application is +/// blocked while it is open. +pub(crate) fn announce_dialog(ctx: &egui::Context, response: &Response, kind: Modal) { + ctx.accesskit_node_builder(response.id, |node| { + node.set_role(if kind == Modal::Alert { egui::accesskit::Role::AlertDialog } else { egui::accesskit::Role::Dialog }); + if kind != Modal::Window { + node.set_modal(); + } + }); +} + +/// Gives the dialog `response` drew the words it says as its accessible description, for an alert +/// whose content (the task names, the error) is drawn as separate labels: a screen reader then +/// has what the alert is about when it announces the alert, not only its title. +pub(crate) fn describe_dialog(ctx: &egui::Context, response: &Response, text: &str) { + ctx.accesskit_node_builder(response.id, |node| node.set_description(text)); +} + /// 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, selected: bool) { let name = name.into(); diff --git a/crates/ui-egui/tests/it/accessibility.rs b/crates/ui-egui/tests/it/accessibility.rs index 39b707cd3..d86aa9487 100644 --- a/crates/ui-egui/tests/it/accessibility.rs +++ b/crates/ui-egui/tests/it/accessibility.rs @@ -133,12 +133,13 @@ fn every_interactive_node_has_a_name_in_every_view_and_tool() { } h.run_steps(6); problems.extend(audit(&h, dialog)); - let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count(); + let is_window = |role| matches!(role, Role::Window | Role::Dialog | Role::AlertDialog); + let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| is_window(n.role())).count(); let untitled = h .root() .children_recursive() .map(|n| n.accesskit_node()) - .filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty()) + .filter(|n| is_window(n.role()) && n.label().unwrap_or_default().trim().is_empty()) .count(); if untitled > 0 { problems.push(format!("{dialog}: {untitled} of {windows} windows have no title")); @@ -234,7 +235,7 @@ fn tab_walks_the_right_panels_top_to_bottom() { .root() .children_recursive() .map(|n| n.accesskit_node()) - .find(|n| n.label().as_deref() == Some("Edit (E)")) + .find(|n| n.label().as_deref() == Some("Edit") && n.description().as_deref() == Some("E")) .and_then(|n| n.bounding_box()) .map(|b| b.x0 as f32) .unwrap_or(f32::INFINITY); @@ -259,3 +260,73 @@ fn tab_walks_the_right_panels_top_to_bottom() { } } } + +/// A slider's value field is " value" (the instruction to click, type or drag is its +/// description, not part of the name VoiceOver reads on every visit). +#[test] +fn a_slider_value_field_is_named_value_and_describes_how_to_use_it() { + const INSTRUCTION: &str = "Click to type a value, drag to change it"; + let h = harness(ViewMode::Detail, RightPanel::Edit); + let buttons: Vec<_> = h.root().children_recursive().filter(|n| n.accesskit_node().role() == Role::Button).collect(); + let names: Vec = buttons.iter().filter_map(|n| n.accesskit_node().label()).collect(); + let field = buttons + .iter() + .find(|n| n.accesskit_node().label().as_deref() == Some("Exposure value")) + .unwrap_or_else(|| panic!("a value field named \"Exposure value\": {names:?}")); + assert_eq!(field.accesskit_node().description().as_deref(), Some(INSTRUCTION), "the instruction is its description"); + assert!(!names.iter().any(|n| n.contains(INSTRUCTION)), "no name carries the instruction: {names:?}"); +} + +/// Whether a button named `name` has `help` as its accessible description (the menu bar has an +/// "Edit" menu too, so the name alone doesn't pick the tool-strip button). +fn button_described(h: &Harness<'_, LightcraftApp>, name: &str, help: &str) -> bool { + h.root() + .children_recursive() + .map(|n| n.accesskit_node()) + .any(|n| n.role() == Role::Button && n.label().as_deref() == Some(name) && n.description().as_deref() == Some(help)) +} + +/// A screen reader reads a control's name every time it lands on it: the shortcut belongs in the +/// description (macOS AXHelp, read after a pause), not in the name. +#[test] +fn tool_strip_and_toolbar_buttons_are_named_by_label_with_the_shortcut_as_description() { + let h = harness(ViewMode::Detail, RightPanel::Edit); + for (name, help) in [ + ("Presets", "Shift+P"), + ("Edit", "E"), + ("Crop & Rotate", "C"), + ("Masking", "M"), + ("Versions", "Shift+V"), + ("History & Activity", "Y"), + ("Keywords", "K"), + ("Info", "I"), + ("Photo Grid", "G"), + ("Square Grid", "G toggles"), + ("Detail", "D"), + ("Compare", "Shift+C"), + ("Survey", "N"), + ("Before/After", "Y"), + ("Filmstrip", "/"), + ] { + assert!(button_described(&h, name, help), "a button named {name:?} describing {help:?}"); + } + let leaked: Vec = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::Button) + .filter_map(|n| n.label()) + .filter(|l| ["Edit (E)", "Presets (Shift+P)", "Photo Grid (G)", "Filmstrip (/)"].contains(&l.as_str())) + .collect(); + assert!(leaked.is_empty(), "shortcuts left in names: {leaked:?}"); +} + +/// The top bar's Import button carries the key it is bound to now, written for this platform. +#[test] +fn import_button_describes_its_shortcut_as_this_platform_writes_it() { + let h = harness(ViewMode::PhotoGrid, RightPanel::Edit); + let mac = h.ctx.os() == egui::os::OperatingSystem::Mac; + let sc = lightcraft_ui_egui::shortcuts::shortcut_of(&h.state().ui.settings.keymap, "file.addPhotos").expect("Import Photos… has a shortcut"); + let help = lightcraft_ui_egui::menubar::shortcut_text(sc, mac); + assert!(button_described(&h, "Import Photos…", &help), "a button named \"Import Photos…\" describing {help:?}"); +} diff --git a/crates/ui-egui/tests/it/dialog_role.rs b/crates/ui-egui/tests/it/dialog_role.rs new file mode 100644 index 000000000..a36cc96fe --- /dev/null +++ b/crates/ui-egui/tests/it/dialog_role.rs @@ -0,0 +1,114 @@ +//! An open dialog is exposed to a screen reader as a dialog (a modal one, an alert dialog for the +//! prompts), not as a group that happens to carry the dialog's title. +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::{Harness, kittest::NodeT}; +use lightcraft_ui_egui::{LightcraftApp, Services}; + +fn harness() -> Harness<'static, LightcraftApp> { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + h +} + +/// `(role, title, modal)` of every window-like node in the tree. +fn windows(h: &Harness<'_, LightcraftApp>) -> Vec<(Role, String, bool)> { + h.root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| matches!(n.role(), Role::Window | Role::Dialog | Role::AlertDialog)) + .map(|n| (n.role(), n.label().unwrap_or_default(), n.data().is_modal())) + .collect() +} + +#[test] +fn every_dialog_is_a_modal_dialog_with_its_title() { + for command in ["dialog.export", "dialog.rename", "dialog.newAlbum", "dialog.createPreset", "dialog.contactSheet", "dialog.allMetadata"] { + let mut h = harness(); + h.state_mut().run(command, serde_json::json!({})).unwrap_or_else(|e| panic!("{command}: {e}")); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "{command}: the dialog is in the tree"); + for (role, title, modal) in found { + assert_eq!(role, Role::Dialog, "{command}: {title:?} is announced as a dialog"); + assert!(!title.trim().is_empty(), "{command}: the dialog has its title"); + assert!(modal, "{command}: {title:?} is modal, so the rest of the window is not read"); + } + } +} + +#[test] +fn settings_is_a_dialog_too() { + let mut h = harness(); + h.state_mut().run("app.settings", serde_json::json!({})).expect("Settings opens"); + h.run_steps(6); + let found = windows(&h); + assert!(found.iter().any(|(role, title, _)| *role == Role::Dialog && title.as_str() == "Settings"), "{found:?}"); + assert!(found.iter().all(|(role, ..)| *role != Role::Window), "{found:?}"); +} + +#[test] +fn the_quit_prompt_and_a_settings_notice_are_modal_alert_dialogs() { + let mut h = harness(); + h.state_mut().quit_prompt = Some(lightcraft_ui_egui::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "the quit prompt is in the tree"); + for (role, title, modal) in found { + assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} interrupts with a question: a modal alert dialog"); + } + let mut h = harness(); + h.state_mut().notices.push("Settings could not be saved.".into()); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "the notice is in the tree"); + for (role, title, modal) in found { + assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog"); + } +} + +#[test] +fn a_library_that_could_not_be_opened_is_a_modal_alert_dialog() { + let mut h = harness(); + h.state_mut().library_problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem::new("/no/such/library", "No catalog here.")); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "the library problem is in the tree"); + for (role, title, modal) in found { + assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog"); + } +} + +/// The description of every alert dialog in the tree. +fn alert_descriptions(h: &Harness<'_, LightcraftApp>) -> Vec { + h.root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::AlertDialog) + .map(|n| n.data().description().unwrap_or_default().to_string()) + .collect() +} + +#[test] +fn an_alert_carries_what_it_says_as_its_description() { + let mut h = harness(); + h.state_mut().quit_prompt = Some(lightcraft_ui_egui::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.run_steps(6); + let said = alert_descriptions(&h); + assert!(said.iter().any(|d| d.contains("1 task is still running: Import")), "the quit prompt names the task: {said:?}"); + let mut h = harness(); + h.state_mut().library_problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem::new("/no/such/library", "No catalog here.")); + h.run_steps(6); + let said = alert_descriptions(&h); + assert!( + said.iter().any(|d| d.contains("/no/such/library") && d.contains("No catalog here.") && d.contains("untouched")), + "the library alert has its path, error and explanation: {said:?}" + ); +} diff --git a/crates/ui-egui/tests/it/landmarks.rs b/crates/ui-egui/tests/it/landmarks.rs new file mode 100644 index 000000000..dd98e4c88 --- /dev/null +++ b/crates/ui-egui/tests/it/landmarks.rs @@ -0,0 +1,90 @@ +//! The window's parts are named for screen readers: the three bars are toolbars, and the sidebar, the +//! photos, the tool panel and the filmstrip are landmarks, so VoiceOver's rotor lists them and a +//! person can jump to one instead of walking every control. +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::{Harness, kittest::NodeT}; +use lightcraft_ui_egui::{ + LightcraftApp, Services, + state::{RightPanel, ViewMode}, +}; + +fn harness(view: ViewMode) -> Harness<'static, LightcraftApp> { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.left_panel = true; + app.ui.view = view; + app.ui.right = RightPanel::Edit; + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + h +} + +/// The area each node with this role and this name covers (`None`: it reports none). +fn areas(h: &Harness<'_, LightcraftApp>, role: Role, name: &str) -> Vec> { + let nodes = h.root().children_recursive().map(|n| n.accesskit_node()); + nodes.filter(|n| n.role() == role && n.label().as_deref() == Some(name)).map(|n| n.bounding_box()).collect() +} + +/// How many nodes have this role and this name. +fn count(h: &Harness<'_, LightcraftApp>, role: Role, name: &str) -> usize { + areas(h, role, name).len() +} + +#[test] +fn the_bars_are_toolbars_and_the_panels_are_landmarks_in_detail() { + let h = harness(ViewMode::Detail); + for (role, name) in [ + (Role::Toolbar, "Top bar"), + (Role::Toolbar, "Tool strip"), + (Role::Toolbar, "Bottom bar"), + (Role::Navigation, "Library"), + (Role::Main, "Photos"), + (Role::Complementary, "Tools"), + (Role::Region, "Filmstrip"), + ] { + assert_eq!(count(&h, role, name), 1, "one {role:?} named {name:?}"); + let area = areas(&h, role, name).into_iter().next().flatten(); + assert!(area.is_some_and(|b| b.width() > 0.0 && b.height() > 0.0), "{name:?} reports the area it covers: {area:?}"); + } +} + +#[test] +fn the_grid_is_the_main_landmark_in_the_photo_grid() { + let h = harness(ViewMode::PhotoGrid); + assert_eq!(count(&h, Role::Main, "Photos"), 1); + assert_eq!(count(&h, Role::Region, "Filmstrip"), 0, "the grid has no filmstrip"); +} + +/// A filter that hides every photo leaves the filmstrip drawn with its reason: it stays a landmark +/// and the reason is text a screen reader reads, not paint. +#[test] +fn an_empty_filmstrip_is_still_a_landmark_and_says_why() { + let mut session = lightcraft_engine::Session::with_demo(); + session.execute("library.filter", &serde_json::json!({"keyword": "no-such-keyword"})).expect("the filter is set"); + assert!(session.visible_cloned().is_empty(), "the filter hides every photo"); + let mut app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + app.ui.left_panel = true; + app.ui.view = ViewMode::Detail; + app.ui.right = RightPanel::Edit; + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + assert_eq!(count(&h, Role::Region, "Filmstrip"), 1, "the empty strip is still a landmark"); + let said = h + .root() + .children_recursive() + .filter_map(|n| n.accesskit_node().value().or_else(|| n.accesskit_node().label())) + .any(|t| t.starts_with("No photos match the filters")); + assert!(said, "the reason is read, not only painted"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 61248f360..725127a91 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -2,9 +2,11 @@ //! `tests/shutdown.rs` stays a binary of its own: it closes the GPU for the whole process. mod accessibility; +mod dialog_role; mod docking_workflows; mod grid_names; mod photo_name; mod sidebar_names; mod thumb_size_slider; mod local_places; +mod landmarks; diff --git a/docs/apple-photos.md b/docs/apple-photos.md index dfc0f4389..b95db2e25 100644 --- a/docs/apple-photos.md +++ b/docs/apple-photos.md @@ -130,7 +130,7 @@ new empty library first: hold ⌥ while opening Photos ▸ Create New). 2. Open it from the Finder, or with `open target/macos-package/LightCraft.app --args --demo` (not by running the binary in a terminal, which makes the terminal the app macOS asks about). 3. Select three photos, choose File ▸ Export…, set a folder (e.g. `~/Pictures/LightCraft Exports`), tick **Add - to Apple Photos**, type the album `LightCraft test "1"` and press OK. + to Apple Photos**, type the album `LightCraft test "1"` and press Export. 4. macOS asks whether **LightCraft** may control Photos, showing the usage text from `Info.plist`: allow it. Expected: the toast reads "Exported 3 of 3 photos · 3 added to Apple Photos album “LightCraft test "1"”", and a top-level album of that name holds the three images. diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index a526f585a..be50d23f8 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -54,6 +54,8 @@ Vendor RGB WB multipliers alone do **not** identify an absolute illuminant witho Canon CR2s list their presets by name in the maker note's `ColorData` (`0x4001`), each with Canon's colour temperature for it: for versions -4 and 13–15 the curve is built the same way from the Daylight, Shade, Cloudy, Tungsten and Flash rows (sorted by temperature; Fluorescent, a green light off the locus, and the Kelvin row, which holds the dial's last setting, are left out). Only the PowerShot G1 X Mark III is enabled, checked on a reporter's nine photos, one per white-balance setting: the as-shot levels read 4160 / 4580 / 5995 / 5215 / 2816 / 3703 / 7162 / 5368 / 4813 K (Auto, Daylight, Shade, Cloudy, Tungsten, Fluorescent at tint +31, Flash, Underwater, Kelvin 5500) where the camera's own `ColorTempAsShot` says 4306 / 4703 / 6005 / 5272 / 2770 / 3533 / 7100 / 5347 / 4925 K. Those photos were shot with the white-balance shift 8 steps (of 9) towards blue (`WBShiftAB` −8), which is why a Daylight shot reads about 4600 K and not the Daylight preset's 5651 K: the levels the camera applied are bluer than the preset's, and the camera's own number agrees. +**Kelvin through a Fujifilm body's stored neutral pair (issue #730).** Fujifilm RAFs carry, in tag `0xf00c` of the raw IFD (`WB_GRBLevelsStandard`; tag number from `exiftool -v3` run as a black-box tool on the files), the camera's own neutral multipliers for Illuminant A (2856 K) and D65 (6504 K): `G R B 17 G R B 21`. For the X-Trans III bodies without measured matrices (`lightcraft_raw::kelvin_presets`: X-T20, X-T2, X-Pro2, X-E3, X-H1, X100F) they take the matrices' place for the Kelvin scale only, with the same decision, adaptation, pixel-identical upgrade and `Op::SetAsShotWb` as above. Two whites fix a camera's response only on the line between them, so nothing but the colorimetry of the two lights places a white off it (`KelvinPresets::from_neutral_pair`): in log red/green and blue/green mired is linear along the line, extended past both ends; the isotherms are perpendicular to it; the tint is the Planckian locus's own CIE 1960 spacing per unit length, plus the tint the two lights have (A on the locus, D65 about +9.5). No other camera's data is used, and the colour look stays the file's JPEG fit. Scored (never fitted) against the three bodies in the spectral table that have a spectral response (X-T3, X-T4, GFX 100), on a grid of 3200 to 9000 K and tints of ±20, this plain line reads whites to 6.8 mired rms (21 at worst) and 3.0 tint rms (8 at worst); beyond that grid, to 2500 to 12000 K, 11 mired rms. That is the accepted accuracy of a two-point Kelvin scale: As Shot and any white near the line are right, a strongly green or magenta light is read a few mired and a few tint off. Reading `DSCF2947.raf` (X-T20): 6193 K / +16 (a Lightroom reading of the same file, 6050 K / +22, is a sanity check, not an input). + What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust. @@ -127,11 +129,33 @@ still has lens corrections removed, so the pipeline applies them once through En Corrections. Developing this proxy requires a full-resolution demosaic; files without such opcodes keep their binned proxy. +## Embedded vignetting correction (Fujifilm RAF) + +The RAF decoder turns the maker note's vignetting data into a `FixVignetteRadial` correction, and the render applies +it (Enable Profile Corrections). The camera applied the same correction to its JPEG. Every per-file fit +(`fit_preview`, `monochrome_look`, the near-black tone fallback) therefore takes its sensor proxy through +`look_proxies`, which multiplies it by that gain as `fit_dng_tone` already did (`correct_vignetting`). Before +`LOOK_VERSION` 13 the RAF fit saw the uncorrected sensor: the tone curve learned the JPEG's corrected brightness, and +the render added the correction again. Lens-database corrections (`LIGHTCRAFT_LENSFUN_DIR`) reach the fit the same way. +`proxies` applies the gain before it moves the sensor into the JPEG's lens geometry, so each pixel gets the gain of the +position it samples, as in the render. The choice between the warped and the plain sensor (`alignment`) leaves out +the pixels the warp finds no sensor for. It used to read them as black, a false edge along the border that made the +warped candidate lose on some wide-angle shots with barrel distortion; neither change moved ΔE00 by more than 0.04 on +the photos below. + +On 21 X-S10 photos the render was up to 0.3 EV brighter than the camera JPEG in the midtones (a wide-open blue-hour +shot) and up to 0.6 EV in the darkest 3 % of the pixels (ISO 12800 indoors). On 60 other X-S10 photos, picked at +random from one photographer's library and rendered without their sidecars, mean ΔE00 against the camera JPEG went +from 2.71 to 2.55: 32 improved by more than 0.1, none got worse by more than 0.05. The median luminance error per +quantile is now within ±0.04 EV, except +0.09 EV in the darkest 3 %. That rest comes from fitting on a 96 px proxy: +each proxy pixel averages a shadow's texture before the curve, while the render applies the curve per pixel. A fit +on a 4× finer proxy roughly halves it in a simulation, but the acceptance gates were set at 96 px. + ## Camera profiles (pooled per model) 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 `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/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; 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. +Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/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); ILCE-7RM5, 220 photos (Standard creative style); 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 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bf8ecb56e..9c086e5c6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -127,9 +127,19 @@ raws with colour matrices — in the file (DNG) or measured for the camera model #730) — and on raws whose white-balance presets give the scale (`lightcraft_raw::kelvin_presets`), where As Shot reads the file's as-shot white; rendered files and raws without matrices use a relative scale whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw -says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge` or a preset whose `wb` sets `temp` / `tint` -**without** `scale` keeps the relative meaning it always had (6500 = as shot) on every raw, so agents that mean -Kelvin on a Kelvin raw pass `"scale": "kelvin"` or use `develop.set` / `develop.wb`. +says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge`, to a preset's `wb`, or to +`lightcraft-cli render --settings` and which sets `temp` / `tint` **without** `scale` keeps the relative meaning it +always had (6500 = as shot) on every raw, so agents that mean Kelvin on a Kelvin raw pass `"scale": "kelvin"` or use +`develop.set` / `develop.wb`. A partial that names only one of `temp` / `tint` is resolved against the photo +(issue #2910): the photo's white balance is first brought onto the partial's scale with the photo's own as-shot +white, so the field the partial does not name keeps the look it had instead of being re-read on the partial's scale, +and the stored result is back on the scale the photo is edited on (`"scale": "kelvin"` on a Kelvin raw). The same +holds for `preset.apply` when the preset names only one of the two numbers; a preset written by the app, or imported +from crs / XMP, names `mode`, `temp`, `tint` and `scale` together and behaves exactly as before. A photo whose white +balance mode answers its own white — `As Shot`, or a named preset on a raw edited in Kelvin — has that white written +out as `Custom` values first, because a number merged into such a mode would otherwise be stored where no render +reads it (the panel would show 5800 while the photo stayed at as shot); a partial that names a `mode` itself stays +authoritative. Partials naming both fields mean the same as before. Image navigation is also available directly as the UI command `view.navigate`, with `{zoom?: "fit" | "fill" | {"percent": number}, pan?: [x, y]}`. Percentage zoom accepts fractional diff --git a/docs/gaps.md b/docs/gaps.md index a06d85b2b..a33abb7c4 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-29 · **Change:** minor (LR-LIB-VIRTUALCOPY: default number exhaustion is validated before copy creation; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; typed GPS input now rejects malformed coordinates before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in coordinates, elevations and timestamps; malformed references error; CDATA stays literal; ranked gaps unchanged; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; import: checked rename-sequence exhaustion; import review: remembered thumbnail size and density-aware previews (#2237, part of #2056); pre-import loupe/keyboard culling remains open; no ranked gap closes; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; gap 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; gap 8: Sony ILCE-7RM6 in-file distortion conversion extends to the APS-C ILCE-6700; LR-META-EXIF-FULL rejects extra EXIF clock components and preserves date fallbacks; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (LR-LIB-VIRTUALCOPY: default number exhaustion is validated before copy creation; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; typed GPS input now rejects malformed coordinates before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in coordinates, elevations and timestamps; malformed references error; CDATA stays literal; ranked gaps unchanged; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; import: checked rename-sequence exhaustion; import review: remembered thumbnail size and density-aware previews (#2237, part of #2056); pre-import loupe/keyboard culling remains open; no ranked gap closes; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; gap 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; gap 8: Sony ILCE-7RM6 in-file distortion conversion extends to the APS-C ILCE-6700; LR-META-EXIF-FULL rejects extra EXIF clock components and preserves date fallbacks; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; JPEG ICC APP2 sequence validation; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) 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 @@ -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 (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) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 9 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) | @@ -98,6 +98,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-25 | patch | ISO timestamp parsing rejects incomplete or trailing clock/fraction text; XMP falls back to a valid capture-time property and GPX counts malformed times as untimed; ranked gaps and readiness unchanged | | 2026-10-24 | minor | Gap 8: lens profile work is tracked in #329; hours unchanged | | 2026-10-25 | patch | LR-META-EXIF-FULL rejects extra clock components rather than projecting a plausible prefix; valid DateTimeDigitized, IFD0 DateTime and XMP fallbacks remain available, raw tag values stay visible, and the status / feature counts do not change | +| 2026-10-25 | patch | JPEG ICC APP2 chunks are validated as a complete, uniquely numbered sequence before colour conversion; ranked gaps and readiness counts unchanged | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-24 | patch | Typed GPS input now validates the complete coordinate expression, rejecting unknown text, wrong-axis or conflicting hemispheres, and invalid DMS minutes/seconds before changing metadata; ranked gaps, estimates and readiness counts unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 4df3e0eaf..6801f2d6d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -177,7 +177,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re | `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | | `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) | -| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted). White balance: `values` with `wb.temp` / `wb.tint` is Kelvin on raws with colour matrices (DNG, and cameras LightCraft has matrices for) and relative (6500 = as shot) elsewhere; a `settings.wb` with `temp` / `tint` and no `"scale": "kelvin"` is read on the relative scale on every raw (see [control-protocol.md](control-protocol.md)) | +| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted). White balance: `values` with `wb.temp` / `wb.tint` is Kelvin on raws with colour matrices (DNG, and cameras LightCraft has matrices for) and relative (6500 = as shot) elsewhere; a `settings.wb` with `temp` / `tint` and no `"scale": "kelvin"` is read on the relative scale on every raw, and one that names only `temp` or only `tint` resolves the field it leaves out against the photo, so that field keeps its look; on a photo still on As Shot (or on a named white balance preset, on a raw with matrices) the white it really renders is written out as Custom values first, so the number named takes effect instead of being stored unread (#2910, and its review); `apply_preset` resolves a preset that names one number the same way (see [control-protocol.md](control-protocol.md)) | | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0597348d2..7f3af507c 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-29 · **Change:** minor (LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-MISSING: `library.missing` from the control channel checks the files on a worker thread instead of the UI thread; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon; LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-MISSING: `library.missing` from the control channel checks the files on a worker thread instead of the UI thread; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon; LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched; LR-IMP-FORMATS: JPEG ICC APP2 chunk validation; LR-BEHAV-TITLEBAR: a dialog leaves the title bar undimmed, as a macOS sheet does; LR-BEHAV-ACCESS: the bars are toolbars and the panels landmarks for screen readers; LR-BEHAV-ACCESS: every dialog and prompt is exposed as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-ACCESS: icon-only buttons are named by their label, with the shortcut as the description; MENU-HELP-HELP: on macOS the Help menu is registered with the application only once the menu bar is installed (muda ignores the call before), so AppKit knows which menu is Help; LR-BEHAV-ACCESS: a slider's value field is named ` value`, with how to use it as its description; MENU-BAR: every shortcut the app shows on macOS lists its modifiers ⌃⌥⇧⌘, as the menu bar does; MENU-BAR: on macOS two menu rows that run the same command each get their own native row and label (File ▸ Export… and File ▸ Export with Preset ▸ Custom…; File ▸ New Album… and Photo ▸ Add to Album ▸ New Album…); LR-BEHAV-DIALOGS: a one-line prompt that is refused stays open and says why, and a New Folder on Disk says it was made; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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,6 +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, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); 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 (a Samsung panorama whose scan data runs into its SEF trailer with no end-of-image marker reads as the image before the trailer, while a JPEG cut short is still refused, issue #3350, `crates/codecs/src/jpeg.rs`), 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 primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, 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, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), 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/it/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), 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 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); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; 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 Nikon 12-bit small RAW (D810 / D4S): packed Y0/Y1/Cb/Cr rows decode to linear RGB; header probes retain geometry without decoding pixels, and truncated strips are refused. Chroma gains are approximate. | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG (ICC APP2 sequences require unique complete chunks with consistent totals; malformed profiles use the existing unsupported-profile sRGB fallback), 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 primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, 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, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), 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/it/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), 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 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); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; 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 Nikon 12-bit small RAW (D810 / D4S): packed Y0/Y1/Cb/Cr rows decode to linear RGB; header probes retain geometry without decoding pixels, and truncated strips are refused. Chroma gains are approximate. | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/it/corpus.rs`, `crates/raw/tests/it/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/it/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`), `crates/raw/src/dng.rs` (`decode_reduced`) | 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; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | 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 | @@ -195,6 +196,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests/wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | | LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). | +| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). A partial white balance naming only `temp` or only `tint` (`develop.merge`, `render --settings`, MCP `set_develop`, or `preset.apply` of a hand-written preset naming one of them) is resolved against the photo, so the field it leaves out keeps the look it had, and a photo whose mode answers its own white (`As Shot`, or a named preset on a raw with a Kelvin scale) has the white it really renders written out as Custom values the partial can move: `prepare_photo_for_wb_partial` and `merge_settings_for_photo` in `crates/engine/src/cmd/develop.rs` and `kelvin_to_legacy` in `crates/pipeline/src/local.rs`, issue #2910, tests in `crates/engine/src/tests_color.rs`. Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | @@ -392,7 +395,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/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-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/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 / Export 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; 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. 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`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); 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`), `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); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | @@ -474,7 +477,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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`), `crates/ui-egui/src/widgets.rs` (`context_menu`), `crates/ui-egui/src/tests/carried_right_click.rs` | a right-click while photos are carried opens no context menu (every menu opened through `widgets::context_menu`: sidebar rows, grid cells, lists; a text field's own Cut / Copy / Paste menu still opens); files → app (a drop-target card while they hover, then the import review); 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-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`), `crates/ui-egui/src/widgets/text_field.rs`, `crates/ui-egui/src/tests/carried_text_field_menu.rs` | a right-click on a text field while photos are carried opens no menu; files → app (a drop-target card while they hover, then the import review); 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.rs` | | -| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`) | +| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialog_copy.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / confirm row stay on screen (the confirm button names its action: Create, Rename, Create Stacks, Export 3 Photos…, with "OK" kept only for a one-field prompt opened with a command that has no verb, `crates/ui-egui/src/tests/dialog_copy.rs`); lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`); a one-line prompt (Rename Folder, New Folder on Disk, the preset and version names, New Library when its name can't be used, Auto-Tag from Tracklog) the engine refuses stays open with its text and shows the reason in a toast, so the name can be corrected, where it used to close without a word; a New Folder on Disk that works says so (“Created folder “Selects” in “2026/Shoot””, naming the parent by its last two folders as dialogs do) | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests/panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; photos dropped on a folded Albums section have no target | | LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests/panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked; once made, Keywords is shown and opened down to it). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top | | LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset`, `crates/ui-egui/src/sidebar.rs`, `crates/ui-egui/src/panels/left.rs` (`sections_menu`), `crates/ui-egui/src/menus/menubar.rs`, `crates/ui-egui/src/tests/panels.rs` | View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets `sidebarMenu:hide`, `sidebarMenu:section:`, `sidebarMenu:reset`) and on the My Photos title (`sidebarTitle`), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. `view.sidebarSection` `{section, show?, collapsed?}` flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (`sidebar`). My Photos itself can't be hidden | @@ -486,12 +489,15 @@ 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/widgets/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/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/widgets/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/it/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/it/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). Dialogs with one name field (New Album, New Smart Album, Save Metadata Preset, the renames) take the focus once when they open, so Tab and Shift+Tab reach their check box, Cancel and OK, and Return and Esc still confirm and cancel (`crates/ui-egui/src/tests/dialog_tab.rs`). The photo in Detail is named like its grid cell, `Photo: , , ,