mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge pull request #70 from storytold/fix/issue-19-sidecar-capture-time
Issue #19: XMP sidecar capture time fills in when the file has none
This commit is contained in:
5 files changed
+95
-20
No files matched your search
+23
-16
@@ -8,8 +8,9 @@
|
||||
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
|
||||
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
|
||||
//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]).
|
||||
//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills
|
||||
//! in when the file has none (read before step 5, so it also files and names the copy).
|
||||
//! 7. Optionally apply a preset and add keywords (the import dialog's options).
|
||||
//! 8. Commit all new photos as one undoable op.
|
||||
//!
|
||||
@@ -553,6 +554,23 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
// 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, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
let mut info = info;
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let stored = match (mode, ©_root) {
|
||||
(ImportMode::Copy, Some(root))
|
||||
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
|
||||
@@ -583,7 +601,6 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
let mut info = info;
|
||||
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
|
||||
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
@@ -606,19 +623,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
apply_import_defaults(s, &mut p);
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
if let Some(x) = packet {
|
||||
match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
|
||||
crate::sidecar::merge_into(&mut p, &sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("import {path}: XMP: {e}"),
|
||||
}
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop
|
||||
//! **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`,
|
||||
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
|
||||
//! 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.
|
||||
|
||||
@@ -80,6 +82,9 @@ pub struct SidecarData {
|
||||
pub alt_text: Option<String>,
|
||||
pub extended_description: Option<String>,
|
||||
pub keywords: Option<Vec<String>>,
|
||||
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
|
||||
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
|
||||
pub captured: Option<String>,
|
||||
pub develop: Option<DevelopPatch>,
|
||||
}
|
||||
|
||||
@@ -108,6 +113,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
alt_text: m.alt_text.clone(),
|
||||
extended_description: m.extended_description.clone(),
|
||||
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
||||
captured: m.capture_time.map(|d| d.to_iso()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(r) = m.rating {
|
||||
@@ -136,8 +142,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply sidecar data to a photo record (the sidecar wins). Returns true if develop changed.
|
||||
/// Apply sidecar data to a photo record (the sidecar wins, except that a capture time embedded in
|
||||
/// the file is kept). Returns true if develop changed.
|
||||
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
if p.captured.is_none() {
|
||||
p.captured = sc.captured.clone();
|
||||
}
|
||||
if let Some(r) = sc.rating {
|
||||
p.rating = r;
|
||||
}
|
||||
@@ -306,6 +316,9 @@ impl Session {
|
||||
Op::SetLabel { id, label: q.label },
|
||||
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
|
||||
];
|
||||
if q.captured != p.captured {
|
||||
ops.push(Op::SetCaptured { id, captured: q.captured.clone() });
|
||||
}
|
||||
if develop_changed {
|
||||
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
|
||||
}
|
||||
|
||||
@@ -445,3 +445,55 @@ fn dng_export_compression_choices() {
|
||||
}
|
||||
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
|
||||
}
|
||||
|
||||
fn write_jpeg(path: &Path, capture: Option<&str>) {
|
||||
let (w, h) = (32usize, 24usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let exif = capture.map(|c| {
|
||||
lightcraft_meta::write_exif(&lightcraft_meta::Metadata { capture_time: lightcraft_meta::DateTime::parse_iso(c), ..Default::default() })
|
||||
});
|
||||
let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), ..Default::default() };
|
||||
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
const DATED_SIDECAR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif="http://ns.adobe.com/exif/1.0/"
|
||||
exif:DateTimeOriginal="2026-01-14T05:58:48" />
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn sidecar_capture_time_fills_in_when_the_file_has_none() {
|
||||
let src = temp_dir("sidecar-date");
|
||||
write_jpeg(&src.join("sample.jpg"), None);
|
||||
std::fs::write(src.join("sample.jpg.xmp"), DATED_SIDECAR).unwrap();
|
||||
// the embedded capture time wins over the sidecar's
|
||||
write_jpeg(&src.join("dated.jpg"), Some("2020-05-06T07:08:09"));
|
||||
std::fs::write(src.join("dated.jpg.xmp"), DATED_SIDECAR).unwrap();
|
||||
let lib = temp_dir("sidecar-date-lib");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// copy: the sidecar's date also files the copy
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
|
||||
assert_eq!(r["sidecars"], 2, "{r}");
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().clone();
|
||||
let sample = by_name(&s, "sample.jpg");
|
||||
assert_eq!(sample.captured.as_deref(), Some("2026-01-14T05:58:48"));
|
||||
let lightcraft_catalog::Source::File { path } = &sample.source else { panic!("not a file") };
|
||||
assert!(Path::new(path).starts_with(lib.join("Originals").join("2026").join("2026-01-14")), "{path}");
|
||||
assert_eq!(by_name(&s, "dated.jpg").captured.as_deref(), Some("2020-05-06T07:08:09"));
|
||||
|
||||
// Read Metadata from File fills in a missing capture time too
|
||||
let id = sample.id;
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap();
|
||||
std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2026-01-14T05:58:48"));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
+1
-1
@@ -194,7 +194,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| 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`) | 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; no embedded map, no geocoding |
|
||||
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright, 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` | |
|
||||
| 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 |
|
||||
| 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)
|
||||
|
||||
+4
-1
@@ -29,7 +29,10 @@ What we write (standard namespaces, so other tools can read the metadata):
|
||||
`lc:` is `http://ns.lightcraft.app/lc/1.0/`.
|
||||
|
||||
Reading merges into the catalog with the **sidecar winning** for every field it states; fields it doesn't state are
|
||||
kept. `xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from
|
||||
kept. The one exception is the **capture time**: a time embedded in the file (EXIF/IPTC) always wins; when the file
|
||||
has none, the sidecar's `exif:DateTimeOriginal`, else `photoshop:DateCreated`, else `xmp:CreateDate` (ISO 8601) is
|
||||
used — on import (it then also files a copied photo in its date folder) and by `photo.readMetadataFromFile` (undoable).
|
||||
`xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from
|
||||
`lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate).
|
||||
|
||||
## Reading `crs:` develop fields
|
||||
|
||||
Reference in new issue
Block a user