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 `<file>.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.
This commit is contained in:
fanyzp
2026-10-16 02:20:40 +08:00
parent 0da998f24d
commit 5bf0d45ba8
6 changed files with 283 additions and 19 deletions
+16 -5
View File
@@ -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<lightcraft_catalog::Catalog> {
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<String>, 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<Result<ProbeInfo, String>> =
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);
+94 -7
View File
@@ -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 `<file>.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 `<file>.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<PathBuf> {
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 `<file>.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<PathBuf> {
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<PhotoId>) {
}
}
/// 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<String, PhotoId>);
@@ -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<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
let mut best: HashMap<String, (Rank, PhotoId, usize)> = 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<String>) -> StemOwners {
// per stem: (raw first, then file name, then id) of the best candidate; and how many
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
let mut best: HashMap<String, (Rank, PhotoId, usize)> = 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 `<file>.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<String>,
}
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<Item = (&'a str, MediaKind)>) -> ImportStems {
// per stem: the best of the library's files, and the best arriving file with its path
let mut best: HashMap<String, (Option<Rank>, 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<SidecarNaming> {
+74 -4
View File
@@ -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<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
@@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec<PhotoId>) {
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<usize> = (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
+91
View File
@@ -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<u8> = (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<u8> = (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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"#
)
};
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);
}
+3 -2
View File
@@ -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 (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) |
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `<file>.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 `<file>.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 (✅) |
+5 -1
View File
@@ -10,7 +10,7 @@ other tools), and let LightCraft pick up edits made elsewhere.
|---|---|
| Name | `<stem>.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `<file>.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 `<file>.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 `<file>.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 `<file>.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