From 5bf0d45ba87be9e99cfaaf14dd1a1b41d04e14da Mon Sep 17 00:00:00 2001 From: fanyzp Date: Fri, 16 Oct 2026 02:20:40 +0800 Subject: [PATCH] Do not read a sibling's shared stem sidecar on import (#1905) With stem naming, `IMG_0001.xmp` is the sidecar of every file of that stem and, in a folder Lightroom has worked on, it is the raw's. On import the packet was looked up with the library naming alone, so a TIFF or JPEG arriving beside a raw of the same name read the raw's packet and took its rating, label, keywords and `crs:` develop settings (the case radialmonster reported in #92; the read side of a catalogued photo is #1523). `ImportStems` ranks the files an import is adding as if they were catalogued already, in the order `StemOwners` uses for writing: the library's files and the arriving ones together, a raw first, then the file name in lower case. A file that does not own the stem sidecar reads only its own `.xmp`, never the sibling's packet. A file that is not catalogued yet has no id, and the id it draws when the import commits is above every id in the library, so it loses a tie against a catalogued file; between arriving files the order they arrive in stands for it. Folder synchronize and the Lightroom catalog import go through the same function, so a file arriving there is ranked the same way. --- crates/engine/src/import.rs | 21 +++- crates/engine/src/sidecar.rs | 101 ++++++++++++++++-- .../engine/src/sidecar/tests_stem_owners.rs | 78 +++++++++++++- crates/engine/src/tests_import.rs | 91 ++++++++++++++++ docs/parity-checklist.md | 5 +- docs/xmp-interop.md | 6 +- 6 files changed, 283 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..f3c7dc9c0 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -965,9 +965,11 @@ impl ImportJob { } /// What the library held when the import was set up, by path and by content: made once, with - /// the first files (on the thread that prepares them), from the library as it was then. - fn look_up_library(&mut self) { - let Some((library, fresh)) = self.library.take() else { return }; + /// the first files (on the thread that prepares them), from the library as it was then. The + /// snapshot comes back: the files the import is adding are ranked against it for the ownership + /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). + fn look_up_library(&mut self) -> Option { + let (library, fresh) = self.library.take()?; for p in library.photos() { // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate if p.deleted && fresh { @@ -980,12 +982,14 @@ impl ImportJob { self.by_hash.insert(h.clone(), Some(p.id)); } } + Some(library) } /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { use std::sync::atomic::Ordering::Relaxed; - self.look_up_library(); + // the library as it was, kept for the ownership of the shared stem sidecars below + let library = self.look_up_library(); let opts = self.opts.clone(); let mode = self.mode; let mut out = Prepared { scanned: files.len(), items: Vec::new() }; @@ -1025,6 +1029,13 @@ impl ImportJob { let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); + // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones + // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar + // and never the raw's packet (issue #1905). + let stems = crate::sidecar::ImportStems::for_import( + library.as_ref(), + todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), + ); // Files are planned one by one, in order (duplicates, sidecars, names, folders); their // copies or moves — the slow part, reading and writing every byte and verifying it — are // collected and run on several threads (issue #367), then their outcomes take their places. @@ -1063,7 +1074,7 @@ impl ImportJob { // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files // and names the copy when the file itself has none let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar(&path, self.naming) + let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index ea06d69a7..88b2f1d12 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -15,7 +15,10 @@ //! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`. //! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then //! the first by file name — [`Session::sidecar_naming`]); the others use `.xmp` -//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. +//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks +//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF +//! arriving beside its raw's `IMG_0001.xmp` reads its own `.xmp`, never the raw's packet +//! (issue #1905). //! //! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except //! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`, @@ -23,7 +26,7 @@ //! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:` //! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -69,6 +72,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option { sidecar_candidates(original, naming).find(|p| p.is_file()) } +/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out +/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not +/// own its stem sidecar reads its own `.xmp` (the name it writes) and never the packet of +/// the sibling that owns the stem one. +pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option { + if stems.owns(original) { + return find_sidecar(original, naming); + } + let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem)); + sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file()) +} + /// The names `original`'s sidecar can have, in the order they are looked for: under `naming`, /// then under the other naming (the full name also with the photo extension in the other case), /// each as `.xmp`, `.XMP` and `.Xmp`. @@ -87,7 +102,12 @@ pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl } v }) - .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) + .flat_map(xmp_cases) +} + +/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`. +fn xmp_cases(p: PathBuf) -> [PathBuf; 3] { + [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")] } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. @@ -526,6 +546,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec) { } } +/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file +/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks +/// last among files of its kind and name: see [`ImportStems`]). +type Rank = (bool, String, u64); + +/// The rank of the file at `path`, of kind `kind`, catalogued as `id`. +fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank { + (kind != MediaKind::Raw, path.to_lowercase(), id) +} + /// Which photo owns each stem sidecar that several catalogued files map to (see /// [`Session::sidecar_naming`]). pub(crate) struct StemOwners(HashMap); @@ -536,10 +566,10 @@ impl StemOwners { /// owners were worked out before, for every write). #[cfg(test)] pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners { - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -564,7 +594,7 @@ impl StemOwners { /// The owners of the shared stems in `wanted`. fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet) -> StemOwners { // per stem: (raw first, then file name, then id) of the best candidate; and how many - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); let mut key = String::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; @@ -572,7 +602,7 @@ impl StemOwners { if !wanted.contains(key.as_str()) { continue; } - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -591,6 +621,63 @@ impl StemOwners { } } +/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of +/// their stem, and which read their own `.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905: +/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating, +/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with +/// the arriving files ranked against the library and against each other: a raw first, then the +/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when +/// the import commits is above every id in the library, so it loses a tie against a catalogued +/// file; between arriving files the order they arrive in stands for it. +pub(crate) struct ImportStems { + /// The arriving files that own the stem sidecar of their stem, by path as it arrives. + owners: HashSet, +} + +impl ImportStems { + /// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a + /// library already looked over (a job prepared twice): there is nothing to rank against, so + /// every arriving file reads as it did before. The library is passed over once, and only the + /// stems the arriving files map to are looked at closer. + pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator) -> ImportStems { + // per stem: the best of the library's files, and the best arriving file with its path + let mut best: HashMap, Option<(Rank, &'a str)>)> = HashMap::new(); + for (arriving, (path, kind)) in incoming.enumerate() { + let e = best.entry(stem_key(path)).or_default(); + let rank = rank_of(path, kind, arriving as u64); + if e.1.as_ref().is_none_or(|(b, _)| rank < *b) { + e.1 = Some((rank, path)); + } + } + if let Some(cat) = cat { + let mut key = String::new(); + for p in cat.photos().filter(|p| p.copy_of.is_none()) { + let Some(path) = file_path(p) else { continue }; + stem_key_into(&mut key, path); + let Some(e) = best.get_mut(key.as_str()) else { continue }; + let rank = rank_of(path, p.kind, p.id.0); + if e.0.as_ref().is_none_or(|b| rank < *b) { + e.0 = Some(rank); + } + } + } + let owners = best + .into_values() + .filter_map(|(in_library, arriving)| { + let (rank, path) = arriving?; + let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str())); + wins.then(|| path.to_string()) + }) + .collect(); + ImportStems { owners } + } + + /// Whether the arriving file at `original` owns the stem sidecar of its stem. + pub(crate) fn owns(&self, original: &str) -> bool { + self.owners.contains(original) + } +} + /// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming` /// ([`Session::sidecar_namings`], for a thread that has the library and not the session). pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec { diff --git a/crates/engine/src/sidecar/tests_stem_owners.rs b/crates/engine/src/sidecar/tests_stem_owners.rs index 43170f4f6..0d86fe92d 100644 --- a/crates/engine/src/sidecar/tests_stem_owners.rs +++ b/crates/engine/src/sidecar/tests_stem_owners.rs @@ -1,4 +1,5 @@ -//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`). +//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and +//! for the files an import is adding (`ImportStems`). //! //! A library can hold millions of photos; a sidecar that is written for one of them, or a folder //! that is synchronized, must not cost a table of them all. @@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [ "/pics/dots/a.jpg", ]; +/// The kind a file of this name is catalogued as: the `.cr3` ones are raws. +fn kind_of(path: &str) -> MediaKind { + if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image } +} + fn library() -> (Catalog, Vec) { let mut c = Catalog::new(); let mut ids = Vec::new(); @@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec) { let id = c.alloc_photo_id(); let name = path.rsplit('/').next().unwrap_or(path); let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01"); - if path.to_lowercase().ends_with("cr3") { - p.kind = MediaKind::Raw; - } + p.kind = kind_of(path); c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); ids.push(id); } @@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() { assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]); } +/// Given a library where files share stems in every way a name can, when files arrive in an +/// import, then each of them may read the shared stem sidecar exactly when it would own it once +/// it is catalogued: the arriving files are ranked against the library's files and against each +/// other, as the write rule ranks them (issue #1905). +#[test] +fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() { + let (c, _) = library(); + // the arrivals: the same names under an upper-case folder, so each shares its stem with the + // library's files (the key lower-cases the path) without being one of them + let incoming: Vec<(String, MediaKind)> = PATHS + .iter() + .map(|p| { + let (dir, name) = p.rsplit_once('/').unwrap_or(("", p)); + (format!("{}/{name}", dir.to_uppercase()), kind_of(p)) + }) + .collect(); + // the library with these arrivals in it, the ids drawn in the order they arrive + let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog { + let mut cat = base.clone(); + for &k in add { + let (path, kind) = &incoming[k]; + let id = cat.alloc_photo_id(); + let name = path.rsplit('/').next().unwrap_or(path); + let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01"); + p.kind = *kind; + cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + cat + }; + let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool { + let path = &incoming[k].0; + let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap(); + StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem + }; + let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() }; + let all: Vec = (0..incoming.len()).collect(); + for i in 0..incoming.len() { + let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter()); + assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0); + for j in 0..incoming.len() { + // every pair arriving together + if i == j { + continue; + } + let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter()); + let cat = arriving_in(&c, &[i, j]); + for k in [i, j] { + assert_eq!( + pair.owns(&incoming[k].0), + owns_once_catalogued(&cat, k), + "{} with {}", + incoming[k].0, + incoming[if k == i { j } else { i }].0 + ); + } + } + } + // a job prepared twice (a library already looked over) has nothing to rank against: every + // one of them is ranked among the arrivals alone, as it would be in a library of just them + let alone = ImportStems::for_import(None, as_arriving(&all).into_iter()); + let only_arrivals = arriving_in(&Catalog::new(), &all); + for k in &all { + assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0); + } +} + /// The key written into a buffer (no allocation for a plain path) is the key: for every path /// above, for shapes a path from a file can have, and for every string of up to six of the /// characters that decide a key (a letter in each case, a dot, both separators, a letter that is diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 3d6469deb..ce8ef3171 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1748,3 +1748,94 @@ fn presets_apply_the_imported_profile_they_name() { assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); let _ = std::fs::remove_dir_all(&src); } + +/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one. +fn write_raw(path: &Path) { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let px: Vec = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect(); + let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&j); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, f).unwrap(); +} + +/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft. +fn write_tiff(path: &Path, seed: u8) { + use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff}; + let (w, h) = (48usize, 32usize); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect(); + let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first, +/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never +/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating, +/// label, metadata and develop settings. +#[test] +fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() { + let sidecar = |rating: u8| { + format!( + r#" +"# + ) + }; + let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() }; + + // the raw and the edited TIFF of it, same name, in one import + let src = temp_dir("stem-pair"); + write_raw(&src.join("IMG_1.NEF")); + write_tiff(&src.join("IMG_1.TIF"), 7); + std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap(); + std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 2, "{r}"); + assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own"); + assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's"); + let _ = std::fs::remove_dir_all(&src); + + // the TIFF with no sidecar of its own: nothing of the raw's packet comes across + let src = temp_dir("stem-none"); + write_raw(&src.join("IMG_2.NEF")); + write_tiff(&src.join("IMG_2.TIF"), 8); + std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 1, "{r}"); + assert_eq!(read(&s, "IMG_2.NEF"), 4); + assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's"); + let _ = std::fs::remove_dir_all(&src); + + // the raw already in the library, the edited TIFF arriving later + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + let src = temp_dir("stem-after"); + write_raw(&src.join("IMG_3.NEF")); + write_tiff(&src.join("IMG_3.TIF"), 9); + std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap(); + let mut s = Session::new().with_fs(); + let raw = { + let id = s.catalog.alloc_photo_id(); + let path = src.join("IMG_3.NEF").to_string_lossy().into_owned(); + let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48"); + p.kind = MediaKind::Raw; + p + }; + s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}"); + assert_eq!(r["sidecars"], 0, "{r}"); + assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..1fcddcbbd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** patch (LR-META-XMP: a file imported beside another one of its stem reads its own sidecar, never the sibling's shared stem packet, issue #1905) · **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 @@ -170,7 +170,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -887,3 +887,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index c467a471a..c2ce80ea4 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -10,7 +10,7 @@ other tools), and let LightCraft pick up edits made elsewhere. |---|---| | Name | `.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. With stem naming, files sharing a stem don't share a sidecar: see *Shared names* below. | | Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. An existing sidecar is **merged into, never replaced** (see *Saving into an existing sidecar*). Written atomically (temp file, fsync, rename). The result lists `written`, `merged` and `backups`. | -| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). | +| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). A file arriving in an import is ranked as if it were catalogued already: it reads its own `.xmp` when another file of its stem (a raw first) owns the shared stem sidecar (issue #1905). | | Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. | What we write (standard namespaces, so other tools can read the metadata): @@ -74,6 +74,10 @@ library share a stem, the stem sidecar belongs to one of them — a raw first, o the others write and read `.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`). +An import ranks the files it is adding the same way, before they are catalogued: a TIFF or JPEG arriving beside a +raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives +with the raw or after it (issue #1905). + ## Face regions (MWG) LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema