mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
- Metadata gains alt text, extended description, sublocation, city, state/province, country: catalog, photo.setMeta, copy/paste metadata, XMP read/write (Iptc4xmpCore AltText / ExtDescrAccessibility / Location, photoshop City / State / Country), sidecars and export (place fields dropped with "remove location"). - Info panel: camera card (camera, lens, size · file size, format badge, focal length, shutter, aperture, ISO), then title, caption, alt text, extended description, copyright, creator, file name (rename), file path (reveal), captured (edit), place fields, GPS. - Text fields keep what is typed across frames and save on focus loss (they were rebound to the stored value every frame). New pencil icon (drawn in code); dates::display_time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
15 KiB
Rust
394 lines
15 KiB
Rust
//! XMP sidecars: `<stem>.xmp` (default) or `<file>.xmp` next to the original.
|
|
//!
|
|
//! **Write** ([`Session::save_sidecar`], `photo.saveMetadataToFile`, or automatically after every
|
|
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
|
|
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
|
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
|
//!
|
|
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; 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.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use lightcraft_catalog::{ColorLabel, Flag, MediaKind, Op, Photo, PhotoId, Source};
|
|
use lightcraft_develop::DevelopSettings;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
use crate::{EngineError, Result, Session};
|
|
|
|
/// Sidecar file naming.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum SidecarNaming {
|
|
/// `IMG_0001.xmp` (the common convention; shared by `IMG_0001.CR3` and `IMG_0001.JPG`).
|
|
#[default]
|
|
Stem,
|
|
/// `IMG_0001.CR3.xmp` (unambiguous when raw+JPEG pairs live side by side).
|
|
Full,
|
|
}
|
|
|
|
/// Library preferences for XMP sidecars.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(default, rename_all = "camelCase")]
|
|
pub struct XmpPrefs {
|
|
/// Write the sidecar after every change to a photo's metadata or develop settings.
|
|
pub auto_write: bool,
|
|
pub naming: SidecarNaming,
|
|
}
|
|
|
|
/// The sidecar path for an original, by naming convention.
|
|
pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
|
|
match naming {
|
|
SidecarNaming::Stem => Path::new(original).with_extension("xmp"),
|
|
SidecarNaming::Full => PathBuf::from(format!("{original}.xmp")),
|
|
}
|
|
}
|
|
|
|
/// An existing sidecar for `original`: the preferred naming first, then the other one (also
|
|
/// accepting an upper-case `.XMP`).
|
|
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
|
|
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
|
[naming, other]
|
|
.into_iter()
|
|
.flat_map(|n| {
|
|
let p = sidecar_path(original, n);
|
|
let upper = p.with_extension("XMP");
|
|
[p, upper]
|
|
})
|
|
.find(|p| p.is_file())
|
|
}
|
|
|
|
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct SidecarData {
|
|
pub rating: Option<u8>,
|
|
pub flag: Option<Flag>,
|
|
pub label: Option<Option<ColorLabel>>,
|
|
pub title: Option<String>,
|
|
pub caption: Option<String>,
|
|
pub copyright: Option<String>,
|
|
pub creator: Option<String>,
|
|
pub location: Option<String>,
|
|
pub city: Option<String>,
|
|
pub state: Option<String>,
|
|
pub country: Option<String>,
|
|
pub alt_text: Option<String>,
|
|
pub extended_description: Option<String>,
|
|
pub keywords: Option<Vec<String>>,
|
|
pub develop: Option<DevelopPatch>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum DevelopPatch {
|
|
/// Our own complete settings (`lc:settings`).
|
|
Full(Box<DevelopSettings>),
|
|
/// Mapped `crs:` fields (partial JSON, merged like a preset).
|
|
Partial(Value),
|
|
}
|
|
|
|
/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`.
|
|
pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, String> {
|
|
let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?;
|
|
let m = &d.metadata;
|
|
let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned();
|
|
let mut out = SidecarData {
|
|
title: m.title.clone(),
|
|
caption: m.caption.clone(),
|
|
copyright: m.copyright.clone(),
|
|
creator: m.artist.clone(),
|
|
location: lc("location").or_else(|| m.sublocation.clone()),
|
|
city: m.city.clone(),
|
|
state: m.state.clone(),
|
|
country: m.country.clone(),
|
|
alt_text: m.alt_text.clone(),
|
|
extended_description: m.extended_description.clone(),
|
|
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
|
..Default::default()
|
|
};
|
|
if let Some(r) = m.rating {
|
|
if r < 0 {
|
|
out.flag = Some(Flag::Reject);
|
|
out.rating = Some(0);
|
|
} else {
|
|
out.rating = Some(r.clamp(0, 5) as u8);
|
|
}
|
|
}
|
|
if let Some(f) = lc("flag").and_then(|f| Flag::parse(&f)) {
|
|
out.flag = Some(f);
|
|
}
|
|
if let Some(l) = &m.label {
|
|
out.label = Some(ColorLabel::parse(l.trim()));
|
|
}
|
|
let full = d.lc_settings.as_deref().and_then(|j| serde_json::from_str::<Value>(j).ok()).and_then(|v| DevelopSettings::from_json(&v).ok());
|
|
out.develop = match full {
|
|
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
|
|
None if crate::crs::has_adjustments(&d.properties) => Some(DevelopPatch::Partial(crate::crs::to_partial(&d.properties, Some(raw)))),
|
|
None => None,
|
|
};
|
|
Ok(out)
|
|
}
|
|
|
|
/// Apply sidecar data to a photo record (the sidecar wins). Returns true if develop changed.
|
|
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
|
if let Some(r) = sc.rating {
|
|
p.rating = r;
|
|
}
|
|
if let Some(f) = sc.flag {
|
|
p.flag = f;
|
|
}
|
|
if let Some(l) = sc.label {
|
|
p.label = l;
|
|
}
|
|
let m = &mut p.meta;
|
|
for (src, dst) in [
|
|
(&sc.title, &mut m.title),
|
|
(&sc.caption, &mut m.caption),
|
|
(&sc.copyright, &mut m.copyright),
|
|
(&sc.creator, &mut m.creator),
|
|
(&sc.location, &mut m.location),
|
|
(&sc.city, &mut m.city),
|
|
(&sc.state, &mut m.state),
|
|
(&sc.country, &mut m.country),
|
|
(&sc.alt_text, &mut m.alt_text),
|
|
(&sc.extended_description, &mut m.extended_description),
|
|
] {
|
|
if let Some(v) = src {
|
|
*dst = v.clone();
|
|
}
|
|
}
|
|
if let Some(k) = &sc.keywords {
|
|
m.keywords = k.clone();
|
|
}
|
|
let develop = match &sc.develop {
|
|
Some(DevelopPatch::Full(s)) => (**s).clone(),
|
|
Some(DevelopPatch::Partial(v)) => lightcraft_develop::apply_partial(&p.develop, v, 1.0),
|
|
None => return false,
|
|
};
|
|
if develop == *p.develop {
|
|
return false;
|
|
}
|
|
p.develop = Arc::new(develop);
|
|
p.edited = Some(now.to_string());
|
|
true
|
|
}
|
|
|
|
fn label_name(l: ColorLabel) -> String {
|
|
format!("{l:?}")
|
|
}
|
|
|
|
/// The sidecar packet for a photo.
|
|
pub fn sidecar_packet(p: &Photo) -> String {
|
|
let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
|
let meta = lightcraft_meta::Metadata {
|
|
software: Some("LightCraft".into()),
|
|
title: nz(&p.meta.title),
|
|
caption: nz(&p.meta.caption),
|
|
alt_text: nz(&p.meta.alt_text),
|
|
extended_description: nz(&p.meta.extended_description),
|
|
sublocation: nz(&p.meta.location),
|
|
city: nz(&p.meta.city),
|
|
state: nz(&p.meta.state),
|
|
country: nz(&p.meta.country),
|
|
copyright: nz(&p.meta.copyright),
|
|
artist: nz(&p.meta.creator),
|
|
keywords: p.meta.keywords.clone(),
|
|
rating: Some(p.rating.min(5) as i8),
|
|
label: p.label.map(label_name),
|
|
capture_time: p.captured.as_deref().and_then(lightcraft_meta::DateTime::parse_iso),
|
|
gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }),
|
|
..Default::default()
|
|
};
|
|
let settings = serde_json::to_string(&*p.develop).unwrap_or_default();
|
|
let flag = match p.flag {
|
|
Flag::None => "none",
|
|
Flag::Pick => "pick",
|
|
Flag::Reject => "reject",
|
|
};
|
|
let mut lc = vec![("settings", settings.as_str()), ("flag", flag)];
|
|
if !p.meta.location.trim().is_empty() {
|
|
lc.push(("location", p.meta.location.as_str()));
|
|
}
|
|
lightcraft_meta::write_xmp_lc(&meta, &lc)
|
|
}
|
|
|
|
/// Write `data` to `path` atomically (temp file + rename).
|
|
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
|
let tmp = path.with_extension(format!("xmp.tmp-{}", std::process::id()));
|
|
std::fs::write(&tmp, data)?;
|
|
std::fs::rename(&tmp, path).inspect_err(|_| {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
})
|
|
}
|
|
|
|
fn file_path(p: &Photo) -> Option<&str> {
|
|
match &p.source {
|
|
Source::File { path } => Some(path),
|
|
Source::Demo { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// The XMP packet that describes a photo on disk: its sidecar, else (raw/DNG) the embedded XMP.
|
|
pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Option<(String, PathBuf)> {
|
|
if let Some(sc) = find_sidecar(original, naming)
|
|
&& let Ok(s) = std::fs::read_to_string(&sc)
|
|
{
|
|
return Some((s, sc));
|
|
}
|
|
if kind == MediaKind::Raw {
|
|
let bytes = std::fs::read(original).ok()?;
|
|
let x = lightcraft_meta::embedded(&bytes).xmp?;
|
|
return Some((x, PathBuf::from(original)));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Photo ids touched by an op (for auto-write).
|
|
pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
|
match op {
|
|
Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } => {
|
|
if !out.contains(id) {
|
|
out.push(*id);
|
|
}
|
|
}
|
|
Op::Batch { ops } => ops.iter().for_each(|o| op_photos(o, out)),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// Write the photo's XMP sidecar; returns its path.
|
|
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
|
|
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
|
if p.copy_of.is_some() {
|
|
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
|
}
|
|
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
|
let path = sidecar_path(orig, self.xmp.naming);
|
|
write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
|
Ok(path)
|
|
}
|
|
|
|
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
|
|
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
|
|
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
|
let Some(orig) = file_path(p) else { return Ok(None) };
|
|
let Some((packet, from)) = read_packet(orig, p.kind, self.xmp.naming) else { return Ok(None) };
|
|
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
|
|
let mut q = (**p).clone();
|
|
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
|
|
let mut ops = vec![
|
|
Op::SetRating { id, rating: q.rating },
|
|
Op::SetFlag { id, flag: q.flag },
|
|
Op::SetLabel { id, label: q.label },
|
|
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
|
|
];
|
|
if develop_changed {
|
|
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
|
|
}
|
|
Ok(Some((Op::Batch { ops }, from)))
|
|
}
|
|
|
|
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
|
|
pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) {
|
|
let mut ids = Vec::new();
|
|
ops.iter().for_each(|o| op_photos(o, &mut ids));
|
|
for id in ids {
|
|
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
|
|
&& let Err(e) = self.save_sidecar(id)
|
|
{
|
|
log::warn!("auto-write XMP: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use lightcraft_catalog::Meta;
|
|
|
|
fn photo() -> Photo {
|
|
let mut p = Photo::new(PhotoId(7), Source::File { path: "/x/IMG_1.jpg".into() }, "IMG_1.jpg", "JPEG", 40, 30, "2026-01-01T00:00:00");
|
|
p.rating = 4;
|
|
p.flag = Flag::Pick;
|
|
p.label = Some(ColorLabel::Purple);
|
|
p.captured = Some("2025-06-07T08:09:10".into());
|
|
p.meta = Meta {
|
|
title: "Dune & sky".into(),
|
|
caption: "Line <one>\nline two".into(),
|
|
copyright: "© 2026 Me".into(),
|
|
creator: "Ann Lee".into(),
|
|
location: "Namib".into(),
|
|
keywords: vec!["desert".into(), "red".into()],
|
|
gps: Some((-24.75, 15.3)),
|
|
..Default::default()
|
|
};
|
|
let mut d = DevelopSettings::default();
|
|
d.light.exposure = 0.75;
|
|
d.effects.clarity = 22.0;
|
|
d.crop.geometry.angle = 2.5;
|
|
d.masks.push(lightcraft_develop::Mask { id: 1, ..Default::default() });
|
|
p.develop = Arc::new(d);
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn naming() {
|
|
assert_eq!(sidecar_path("/a/b/IMG_1.CR3", SidecarNaming::Stem), PathBuf::from("/a/b/IMG_1.xmp"));
|
|
assert_eq!(sidecar_path("/a/b/IMG_1.CR3", SidecarNaming::Full), PathBuf::from("/a/b/IMG_1.CR3.xmp"));
|
|
}
|
|
|
|
#[test]
|
|
fn packet_roundtrip_restores_everything() {
|
|
let p = photo();
|
|
let x = sidecar_packet(&p);
|
|
let sc = parse_sidecar(&x, false).unwrap();
|
|
assert_eq!(sc.rating, Some(4));
|
|
assert_eq!(sc.flag, Some(Flag::Pick));
|
|
assert_eq!(sc.label, Some(Some(ColorLabel::Purple)));
|
|
let mut q = Photo::new(PhotoId(7), p.source.clone(), "IMG_1.jpg", "JPEG", 40, 30, "2026-01-01T00:00:00");
|
|
q.captured = p.captured.clone();
|
|
q.meta.gps = p.meta.gps;
|
|
assert!(merge_into(&mut q, &sc, "now"));
|
|
assert_eq!(q.develop, p.develop);
|
|
assert_eq!(q.meta, p.meta);
|
|
assert_eq!((q.rating, q.flag, q.label), (p.rating, p.flag, p.label));
|
|
assert_eq!(q.edited.as_deref(), Some("now"));
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_sidecar_rating_reject_label_and_crs() {
|
|
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
|
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
|
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
|
|
let sc = parse_sidecar(x, true).unwrap();
|
|
assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green))));
|
|
let mut p = photo();
|
|
let before = p.develop.clone();
|
|
assert!(merge_into(&mut p, &sc, "t"));
|
|
assert_eq!(p.develop.light.exposure, -0.4);
|
|
assert_eq!(p.develop.color.vibrance, 12.0);
|
|
assert_eq!(p.develop.effects.clarity, before.effects.clarity, "crs partial keeps unrelated settings");
|
|
assert_eq!(p.meta.title, "Dune & sky", "fields the sidecar doesn't state are kept");
|
|
}
|
|
|
|
#[test]
|
|
fn op_photo_collection() {
|
|
let mut v = Vec::new();
|
|
op_photos(
|
|
&Op::Batch {
|
|
ops: vec![
|
|
Op::SetRating { id: PhotoId(1), rating: 2 },
|
|
Op::SetFlag { id: PhotoId(1), flag: Flag::Pick },
|
|
Op::SetLabel { id: PhotoId(3), label: None },
|
|
],
|
|
},
|
|
&mut v,
|
|
);
|
|
assert_eq!(v, vec![PhotoId(1), PhotoId(3)]);
|
|
}
|
|
}
|