mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
A Custom white balance from a Lightroom catalog or XMP sidecar was taken as absolute Kelvin for every raw. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF; 6500 K means as shot) then read a small tweak as a large blue shift: ΔE 22 against Lightroom's export on the photos of issue #510. The crs mapper now takes a `Target` (preset, rendered, raw with a measured illuminant, relative-WB raw) instead of a bool. For relative-WB raws, and rendered files with only `Temperature`, a Kelvin value is converted to the same mired shift from the packet's `AsShotTemperature` / `AsShotTint`. Without that reference (Lightroom catalogs keep none for Custom photos) the photo stays As Shot and the import report lists `Temperature, Tint` as not carried over. Import, sidecar read and catalog import decide the target the same way through `lightcraft_catalog::relative_wb_format`. Tests: shift from the issue's numbers (3650 → 3578 K lands at ~6275 K), the As Shot fallback with its report entry, target selection per file, and the catalog mapper's Custom-WB case.
919 lines
47 KiB
Rust
919 lines
47 KiB
Rust
//! XMP sidecars end to end: save → delete library → re-import restores edits; auto-write; foreign
|
||
//! `crs:` sidecars; DNG-embedded XMP; read metadata from file (undoable).
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
|
||
let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&d);
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
d
|
||
}
|
||
|
||
fn write_png(path: &Path, seed: u8) {
|
||
let (w, h) = (40usize, 24usize);
|
||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 6) as u8, (i / w * 9) as u8, seed, 255]).collect();
|
||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||
std::fs::write(path, bytes).unwrap();
|
||
}
|
||
|
||
fn only(s: &Session) -> PhotoId {
|
||
s.catalog.photos().next().unwrap().id
|
||
}
|
||
|
||
#[test]
|
||
fn save_delete_library_reimport_restores_edits() {
|
||
let src = temp_dir("roundtrip-src");
|
||
let lib = temp_dir("roundtrip-lib");
|
||
write_png(&src.join("dune.png"), 1);
|
||
write_png(&src.join("plain.png"), 2);
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
s.execute("library.import", &json!({"paths": [src.join("dune.png").to_string_lossy()]})).unwrap();
|
||
let id = only(&s);
|
||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||
s.execute("photo.pick", &json!({})).unwrap();
|
||
s.execute("photo.label", &json!({"label": "blue"})).unwrap();
|
||
s.execute("photo.setMeta", &json!({"title": "Dune", "caption": "Evening light", "copyright": "© me", "keywords": ["sand", "dusk"]})).unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.8})).unwrap();
|
||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 25})).unwrap();
|
||
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
||
let edited = s.catalog.photo(id).unwrap().clone();
|
||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
assert_eq!(r["written"][0], src.join("dune.xmp").to_string_lossy().as_ref(), "{r}");
|
||
assert!(src.join("dune.xmp").is_file());
|
||
assert!(!src.join("plain.xmp").exists());
|
||
drop(s);
|
||
|
||
// delete the library; a fresh library re-imports the folder
|
||
std::fs::remove_dir_all(&lib).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
|
||
assert_eq!(r["sidecars"], 1, "{r}");
|
||
let p = s.catalog.photos().find(|p| p.file_name == "dune.png").unwrap().clone();
|
||
assert_eq!(p.develop, edited.develop, "develop settings restored exactly");
|
||
assert_eq!(p.develop.masks.len(), 1);
|
||
assert_eq!((p.rating, p.flag, p.label), (4, Flag::Pick, Some(ColorLabel::Blue)));
|
||
assert_eq!(p.meta.title, "Dune");
|
||
assert_eq!(p.meta.caption, "Evening light");
|
||
assert_eq!(p.meta.copyright, "© me");
|
||
assert_eq!(p.meta.keywords, vec!["sand".to_string(), "dusk".to_string()]);
|
||
assert!(p.edited.is_some());
|
||
let plain = s.catalog.photos().find(|p| p.file_name == "plain.png").unwrap();
|
||
assert!(plain.develop.is_unedited() && plain.rating == 0);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
let _ = std::fs::remove_dir_all(&lib);
|
||
}
|
||
|
||
#[test]
|
||
fn auto_write_and_naming_preference() {
|
||
let src = temp_dir("auto");
|
||
let lib = temp_dir("auto-lib");
|
||
write_png(&src.join("a.png"), 3);
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||
assert!(!src.join("a.xmp").exists(), "auto-write is off by default");
|
||
|
||
let v = s.execute("library.xmpPreferences", &json!({"autoWrite": true, "naming": "full"})).unwrap();
|
||
assert_eq!(v, json!({"autoWrite": true, "naming": "full"}));
|
||
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
||
let sidecar = src.join("a.png.xmp");
|
||
assert!(sidecar.is_file());
|
||
assert!(std::fs::read_to_string(&sidecar).unwrap().contains("<xmp:Rating>5</xmp:Rating>"));
|
||
// a slider drag writes once, at the end
|
||
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap();
|
||
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().develop
|
||
{
|
||
Some(crate::sidecar::DevelopPatch::Full(d)) => d.light.exposure,
|
||
other => panic!("{other:?}"),
|
||
};
|
||
assert_eq!(exposure(&sidecar), 0.0, "not written mid-drag");
|
||
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||
assert_eq!(exposure(&sidecar), 1.5);
|
||
// undo rewrites too
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(exposure(&sidecar), 0.0);
|
||
|
||
// the preference persists with the library
|
||
drop(s);
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
assert!(s.xmp.auto_write);
|
||
assert_eq!(s.xmp.naming, crate::sidecar::SidecarNaming::Full);
|
||
let v = s.execute("library.toggleAutoWriteXmp", &json!({})).unwrap();
|
||
assert_eq!(v["autoWrite"], false);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
let _ = std::fs::remove_dir_all(&lib);
|
||
}
|
||
|
||
/// Hand-written sidecar in the style other raw developers use (attribute form, `crs:` fields).
|
||
const FOREIGN: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||
<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:dc="http://purl.org/dc/elements/1.1/"
|
||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||
xmp:Rating="3" xmp:Label="Red"
|
||
crs:ProcessVersion="11.0" crs:IncrementalTemperature="+20" crs:IncrementalTint="-5"
|
||
crs:Exposure2012="+1.10" crs:Shadows2012="+35" crs:Vibrance="+15" crs:Clarity2012="+10"
|
||
crs:SplitToningShadowHue="220" crs:SplitToningShadowSaturation="18"
|
||
crs:HasCrop="True" crs:CropLeft="0.1" crs:CropTop="0" crs:CropRight="0.9" crs:CropBottom="1" crs:CropAngle="0">
|
||
<dc:subject><rdf:Bag><rdf:li>harbour</rdf:li></rdf:Bag></dc:subject>
|
||
</rdf:Description>
|
||
</rdf:RDF>
|
||
</x:xmpmeta>
|
||
<?xpacket end="w"?>"#;
|
||
|
||
#[test]
|
||
fn foreign_crs_sidecar_on_import_and_read_from_file() {
|
||
let src = temp_dir("foreign");
|
||
write_png(&src.join("harbour.png"), 4);
|
||
std::fs::write(src.join("harbour.xmp"), FOREIGN).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
assert_eq!(r["sidecars"], 1);
|
||
let p = s.catalog.photos().next().unwrap().clone();
|
||
assert_eq!((p.rating, p.label), (3, Some(ColorLabel::Red)));
|
||
assert_eq!(p.meta.keywords, vec!["harbour".to_string()]);
|
||
let d = &p.develop;
|
||
assert_eq!((d.light.exposure, d.light.shadows, d.color.vibrance, d.effects.clarity), (1.1, 35.0, 15.0, 10.0));
|
||
assert!(d.wb.temp > 6500.0 && d.wb.tint == -5.0, "relative WB for a rendered file: {:?}", d.wb);
|
||
assert_eq!((d.grading.shadows.hue, d.grading.shadows.sat), (220.0, 18.0));
|
||
assert_eq!((d.crop.geometry.rect.x0, d.crop.geometry.rect.x1), (0.1, 0.9));
|
||
assert!(s.render_now(p.id, 32, 32).is_ok());
|
||
|
||
// change things, then read the sidecar again: one undo step restores the edits
|
||
s.execute("library.select", &json!({"ids": [p.id.0]})).unwrap();
|
||
s.execute("develop.reset", &json!({})).unwrap();
|
||
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
|
||
let r = s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||
assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}");
|
||
let q = s.catalog.photo(p.id).unwrap();
|
||
assert_eq!(q.rating, 3);
|
||
assert_eq!(q.develop.light.exposure, 1.1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let q = s.catalog.photo(p.id).unwrap();
|
||
assert_eq!((q.rating, q.develop.light.exposure), (1, 0.0));
|
||
// no sidecar → reported, not an error
|
||
std::fs::remove_file(src.join("harbour.xmp")).unwrap();
|
||
let r = s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
}
|
||
|
||
const MWG_REGIONS: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||
<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:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
|
||
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
|
||
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
|
||
<mwg-rs:Regions>
|
||
<rdf:Description>
|
||
<mwg-rs:AppliedToDimensions stDim:w="100" stDim:h="100" stDim:unit="pixel"/>
|
||
<mwg-rs:RegionList>
|
||
<rdf:Bag>
|
||
<rdf:li>
|
||
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
|
||
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
|
||
</rdf:Description>
|
||
</rdf:li>
|
||
</rdf:Bag>
|
||
</mwg-rs:RegionList>
|
||
</rdf:Description>
|
||
</mwg-rs:Regions>
|
||
</rdf:Description>
|
||
</rdf:RDF>
|
||
</x:xmpmeta>
|
||
<?xpacket end="w"?>"#;
|
||
|
||
/// A face region written by Lightroom (MWG-RS) into a sidecar reaches `Photo.meta.regions` on
|
||
/// import — the same path real catalogs use, exercised end to end rather than just at the parser.
|
||
#[test]
|
||
fn regions_from_sidecar_are_read_on_import() {
|
||
let src = temp_dir("regions");
|
||
write_png(&src.join("portrait.png"), 9);
|
||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
assert_eq!(r["sidecars"], 1);
|
||
let p = s.catalog.photos().next().unwrap().clone();
|
||
assert_eq!(p.meta.regions.len(), 1);
|
||
let region = &p.meta.regions[0];
|
||
assert_eq!(region.name.as_deref(), Some("Jane Doe"));
|
||
assert_eq!(region.kind, lightcraft_meta::RegionKind::Face);
|
||
assert!((region.rect.width() - 0.3).abs() < 1e-9, "{:?}", region.rect);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
}
|
||
|
||
/// Removing a face box is a catalog-only, undoable edit: it never rewrites the sidecar (Lightroom's
|
||
/// regions and everything else in it stay byte for byte), even with auto-write on.
|
||
#[test]
|
||
fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() {
|
||
let src = temp_dir("region-remove");
|
||
write_png(&src.join("portrait.png"), 9);
|
||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
let id = s.active().unwrap();
|
||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
|
||
let n = s.catalog.photo(id).unwrap().meta.regions.len();
|
||
assert!(n >= 1);
|
||
assert!(s.execute("photo.removeRegion", &json!({"index": n})).is_err(), "out of range");
|
||
assert!(s.execute("photo.removeRegion", &json!({})).is_err(), "no index");
|
||
let r = s.execute("photo.removeRegion", &json!({"index": 0})).unwrap();
|
||
assert_eq!(r["removed"], "Jane Doe");
|
||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n - 1);
|
||
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
|
||
// an ordinary edit still auto-writes (the guard is per command)
|
||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||
assert_ne!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "auto-write still works for other edits");
|
||
// undo brings the region back
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
}
|
||
|
||
/// Resizing a face box is a catalog-only, undoable edit with strict input handling; it leaves the
|
||
/// sidecar alone like removal does.
|
||
#[test]
|
||
fn resizing_a_region_validates_clamps_undoes_and_leaves_the_sidecar_alone() {
|
||
let src = temp_dir("region-resize");
|
||
write_png(&src.join("portrait.png"), 9);
|
||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
let id = s.active().unwrap();
|
||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
|
||
let original = s.catalog.photo(id).unwrap().meta.regions[0].rect;
|
||
let set = |s: &mut Session, rect: serde_json::Value| s.execute("photo.setRegion", &json!({"index": 0, "rect": rect}));
|
||
// hostile input is an error and changes nothing
|
||
for bad in [
|
||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1, "y1": 0.5}),
|
||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1001, "y1": 0.5}),
|
||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.5}),
|
||
json!({"x0": "a", "y0": 0.1, "x1": 0.5, "y1": 0.5}),
|
||
json!({"x0": null, "y0": 0.1, "x1": 0.5, "y1": 0.5}),
|
||
] {
|
||
assert!(set(&mut s, bad.clone()).is_err(), "{bad}");
|
||
}
|
||
assert!(s.execute("photo.setRegion", &json!({"index": 9, "rect": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.5}})).is_err(), "no such region");
|
||
assert!(s.execute("photo.setRegion", &json!({"index": 0})).is_err(), "no rect");
|
||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
|
||
// corners given in any order and outside the photo are clamped into it
|
||
let r = set(&mut s, json!({"x0": 0.6, "y0": 1.5, "x1": -0.2, "y1": 0.2})).unwrap();
|
||
assert_eq!(r["rect"], json!({"x0": 0.0, "y0": 0.2, "x1": 0.6, "y1": 1.0}));
|
||
let now = s.catalog.photo(id).unwrap().meta.regions[0].rect;
|
||
assert_eq!((now.x0, now.y0, now.x1, now.y1), (0.0, 0.2, 0.6, 1.0));
|
||
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
}
|
||
|
||
/// Re-reading a sidecar: one that has `mwg-rs:Regions` is authoritative, so an emptied region list
|
||
/// clears the stale regions; one without `mwg-rs:Regions` (an app that doesn't do regions) leaves the
|
||
/// photo's regions alone.
|
||
#[test]
|
||
fn rereading_a_sidecar_clears_regions_only_when_it_states_them() {
|
||
let src = temp_dir("region-reread");
|
||
write_png(&src.join("portrait.png"), 9);
|
||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
let id = s.active().unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
|
||
// another app rewrote the sidecar without any notion of regions: they stay
|
||
std::fs::write(
|
||
src.join("portrait.xmp"),
|
||
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="3"/>
|
||
</rdf:RDF></x:xmpmeta>"#,
|
||
)
|
||
.unwrap();
|
||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||
let p = s.catalog.photo(id).unwrap();
|
||
assert_eq!(p.rating, 3, "the sidecar was read");
|
||
assert_eq!(p.meta.regions.len(), 1, "a sidecar without mwg-rs:Regions keeps the photo's regions");
|
||
// a region-aware app removed every region: the sidecar says so, and the catalog follows
|
||
let emptied = MWG_REGIONS.replace(
|
||
r#"<rdf:li>
|
||
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
|
||
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
|
||
</rdf:Description>
|
||
</rdf:li>"#,
|
||
"",
|
||
);
|
||
assert_ne!(emptied, MWG_REGIONS);
|
||
std::fs::write(src.join("portrait.xmp"), emptied).unwrap();
|
||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||
assert!(s.catalog.photo(id).unwrap().meta.regions.is_empty(), "an emptied region list clears stale regions");
|
||
// undo brings them back
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
}
|
||
|
||
fn synthetic_dng(xmp: &str) -> Vec<u8> {
|
||
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
|
||
}
|
||
|
||
/// A tiny Bayer DNG written by our own DNG writer (optional embedded XMP, camera metadata).
|
||
pub(crate) fn synthetic_dng_with(xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec<u8> {
|
||
let (w, h) = (32usize, 24usize);
|
||
let data: Vec<u16> = (0..w * h).map(|i| 256 + ((i % w) * 300 + (i / w) * 200) as u16).collect();
|
||
synthetic_dng_of(w, h, "RGGB", data, xmp, metadata)
|
||
}
|
||
|
||
/// A synthetic Bayer DNG for denoise and cache tests.
|
||
pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec<u16>, xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec<u8> {
|
||
use lightcraft_raw::*;
|
||
let cfa = Cfa::bayer(cfa).unwrap();
|
||
let raw = RawImage {
|
||
format: RawFormat::Dng,
|
||
width: w,
|
||
height: h,
|
||
cpp: 1,
|
||
data: RawData::U16(data),
|
||
cfa: Some(cfa),
|
||
bits: 14,
|
||
black: BlackLevel::uniform(256.0),
|
||
white: vec![16000.0],
|
||
active_area: Rect::new(0, 0, w, h),
|
||
crop: Rect::new(0, 0, w, h),
|
||
orientation: Orientation::Normal,
|
||
color: ColorData {
|
||
illuminant: [17, 21],
|
||
color_matrix: [
|
||
Some(lightcraft_color::Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])),
|
||
Some(lightcraft_color::Mat3([[0.7, 0.3, -0.1], [-0.35, 1.3, 0.1], [0.05, -0.2, 1.0]])),
|
||
],
|
||
as_shot_neutral: Some([0.5, 1.0, 0.7]),
|
||
..Default::default()
|
||
},
|
||
wb_multipliers: None,
|
||
linearized: false,
|
||
opcodes: OpcodeLists::default(),
|
||
metadata,
|
||
};
|
||
write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn dng_embedded_crs_settings_are_read_on_import() {
|
||
let src = temp_dir("dng");
|
||
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="2" crs:WhiteBalance="Custom" crs:Temperature="4300" crs:Tint="+7" crs:Contrast2012="+30" crs:Dehaze="+12"/>
|
||
</rdf:RDF></x:xmpmeta>"#;
|
||
std::fs::write(src.join("synth.dng"), synthetic_dng(x)).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
assert_eq!((r["imported"].as_array().unwrap().len(), r["sidecars"].as_u64()), (1, Some(1)), "{r}");
|
||
let p = s.catalog.photos().next().unwrap();
|
||
assert_eq!(p.kind, lightcraft_catalog::MediaKind::Raw);
|
||
assert_eq!((p.develop.wb.temp, p.develop.wb.tint), (4300.0, 7.0));
|
||
assert_eq!((p.develop.light.contrast, p.develop.effects.dehaze), (30.0, 12.0));
|
||
assert_eq!(p.develop.detail.sharpen_amount, 40.0, "raw defaults kept for fields the XMP doesn't set");
|
||
assert_eq!(p.rating, 2);
|
||
|
||
// a sidecar next to the DNG takes precedence over the embedded XMP
|
||
let id = p.id;
|
||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.contrast", "value": -10})).unwrap();
|
||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
let mut s2 = Session::new().with_fs();
|
||
s2.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
assert_eq!(s2.catalog.photos().next().unwrap().develop.light.contrast, -10.0);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
}
|
||
|
||
#[test]
|
||
fn demo_photos_have_no_sidecar() {
|
||
let mut s = Session::with_demo();
|
||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
assert_eq!(r["written"].as_array().unwrap().len(), 0);
|
||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||
}
|
||
|
||
/// All Metadata: EXIF rows from the file and its XMP, for a photo on disk.
|
||
#[test]
|
||
fn all_metadata_lists_exif_and_xmp() {
|
||
let dir = temp_dir("allmeta");
|
||
let path = dir.join("tagged.jpg");
|
||
let img = lightcraft_raster::Rgba8 { width: 16, height: 8, data: vec![[90, 120, 200, 255]; 128] };
|
||
let exif = lightcraft_meta::write_exif(&lightcraft_meta::Metadata { make: Some("Maker".into()), iso: Some(800), ..Default::default() });
|
||
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
|
||
let jpg = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
|
||
std::fs::write(&path, jpg).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||
s.execute("photo.setMeta", &json!({"title": "A title"})).unwrap();
|
||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
let r = s.execute("photo.allMetadata", &json!({})).unwrap();
|
||
let exif = r["exif"].as_array().unwrap();
|
||
assert!(exif.iter().any(|e| e["name"] == "Make" && e["value"] == "Maker"), "{r}");
|
||
assert!(exif.iter().any(|e| e["name"] == "ISO Speed" && e["value"] == "800"));
|
||
assert!(r["xmp"].as_array().unwrap().iter().any(|x| x["name"] == "dc:title" && x["value"] == "A title"), "the sidecar's fields: {r}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Convert to DNG: a corpus raw (skipped without the corpus) becomes a DNG next to it with the
|
||
/// photo relinked and its edits kept; undo goes back to the original; non-raws are skipped.
|
||
#[test]
|
||
fn convert_raw_to_dng() {
|
||
let corpus = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw/nef-nikon-d5100-uncompressed.nef");
|
||
let mut s = Session::new().with_fs();
|
||
// a non-raw is skipped
|
||
let dir = temp_dir("todng");
|
||
let png = dir.join("a.png");
|
||
write_png(&png, 3);
|
||
s.execute("library.import", &json!({"paths": [png.to_string_lossy()]})).unwrap();
|
||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||
assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1));
|
||
if !corpus.exists() {
|
||
eprintln!("skip: {} absent", corpus.display());
|
||
return;
|
||
}
|
||
let nef = dir.join("shot.nef");
|
||
std::fs::copy(&corpus, &nef).unwrap();
|
||
s.execute("library.import", &json!({"paths": [nef.to_string_lossy()]})).unwrap();
|
||
let id = s.active().unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.6})).unwrap();
|
||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||
assert_eq!(r["converted"].as_array().unwrap().len(), 1, "{r}");
|
||
let p = s.catalog.photo(id).unwrap().clone();
|
||
assert_eq!((p.file_name.as_str(), p.format.as_str()), ("shot.dng", "DNG"));
|
||
assert!(dir.join("shot.dng").exists() && nef.exists(), "the original is kept");
|
||
assert_eq!(p.develop.light.exposure, 0.6, "edits kept");
|
||
let bytes = std::fs::read(dir.join("shot.dng")).unwrap();
|
||
assert_eq!(lightcraft_raw::probe(&bytes), Some(lightcraft_raw::RawFormat::Dng));
|
||
assert!(s.render_now(id, 64, 64).is_ok(), "the DNG renders");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().file_name, "shot.nef");
|
||
// Copy as DNG at import: only the DNG lands in the destination
|
||
let card = dir.join("card");
|
||
std::fs::create_dir_all(&card).unwrap();
|
||
std::fs::copy(&corpus, card.join("DSC_1.NEF")).unwrap();
|
||
let dest = dir.join("dest");
|
||
let mut s = Session::new().with_fs();
|
||
let r = s
|
||
.execute(
|
||
"library.import",
|
||
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat", "dng": true}),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
|
||
let names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||
assert_eq!(names, ["DSC_1.dng"]);
|
||
assert!(card.join("DSC_1.NEF").exists(), "the card is untouched");
|
||
let id2 = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||
assert_eq!(s.catalog.photo(id2).unwrap().format, "DNG");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Issue #106: Convert to DNG wrote straight to the final name (no temp file, sync or check) and
|
||
/// relinked the photo even when the write was cut short. A failed write now leaves no DNG and
|
||
/// the photo on its raw; a good one is verified and never replaces an existing file.
|
||
#[test]
|
||
fn convert_to_dng_is_verified_and_atomic() {
|
||
let dir = temp_dir("todng-safe");
|
||
// a synthetic raw (stored as DNG, catalogued as a NEF so Convert to DNG takes it)
|
||
let raw = dir.join("shot.dng");
|
||
let bytes = synthetic_dng_with(None, Default::default());
|
||
std::fs::write(&raw, &bytes).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
|
||
let id = s.catalog.photos().next().unwrap().id;
|
||
let path = raw.to_string_lossy().to_string();
|
||
s.catalog
|
||
.apply(lightcraft_catalog::Op::Relink {
|
||
id,
|
||
file_name: "shot.dng".into(),
|
||
source: lightcraft_catalog::Source::File { path: path.clone() },
|
||
format: Some("NEF".into()),
|
||
})
|
||
.unwrap();
|
||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
let names = || {
|
||
let mut v: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||
v.sort();
|
||
v
|
||
};
|
||
{
|
||
// the drive fills up part-way
|
||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(200);
|
||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||
assert_eq!(r["converted"].as_array().unwrap().len(), 0, "{r}");
|
||
assert!(r["skipped"][0][1].as_str().unwrap().contains("the raw is kept"), "{r}");
|
||
}
|
||
assert_eq!(names(), ["shot.dng"], "no partial DNG");
|
||
assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: path.clone() }, "not relinked");
|
||
// a good conversion: a new name (the existing file is not replaced), decodable, relinked
|
||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||
let out = r["converted"][0]["path"].as_str().unwrap().to_string();
|
||
assert!(out.ends_with("shot-2.dng"), "{r}");
|
||
assert_eq!(std::fs::read(&raw).unwrap(), bytes, "the raw is untouched");
|
||
let back = lightcraft_raw::decode(&std::fs::read(&out).unwrap()).unwrap();
|
||
assert_eq!(back.data, lightcraft_raw::decode(&bytes).unwrap().data);
|
||
assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: out });
|
||
assert_eq!(names(), ["shot-2.dng", "shot.dng"], "no temp file left");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to
|
||
/// the original, added and stacked on top of it; a second one doesn't overwrite the first.
|
||
#[test]
|
||
fn external_edit_copy_is_stacked() {
|
||
let dir = temp_dir("extedit");
|
||
let src = dir.join("beach.png");
|
||
write_png(&src, 9);
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
let orig = s.active().unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||
let r = s.execute("photo.editExternal", &json!({})).unwrap();
|
||
let path = r["path"].as_str().unwrap().to_string();
|
||
assert!(path.ends_with("beach-Edit.tif"), "{path}");
|
||
let bytes = std::fs::read(&path).unwrap();
|
||
let d = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||
assert_eq!(d.bit_depth, 16);
|
||
let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
|
||
assert_eq!(s.active(), Some(new));
|
||
let st = s.catalog.stack_of(new).expect("stacked");
|
||
assert_eq!((st.top(), st.photos.contains(&orig)), (new, true));
|
||
let r2 = s.execute("photo.editExternal", &json!({"colorSpace": "proPhoto"})).unwrap();
|
||
assert!(r2["path"].as_str().unwrap().ends_with("beach-Edit-Edit.tif") || r2["path"].as_str().unwrap().contains("-2"), "{r2}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the
|
||
/// cached source is dropped and renders change; unchanged files are left alone.
|
||
#[test]
|
||
fn reload_picks_up_changed_files() {
|
||
let dir = temp_dir("reload");
|
||
let f = dir.join("x.png");
|
||
write_png(&f, 10);
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||
let id = s.active().unwrap();
|
||
let before = s.render_now(id, 32, 32).unwrap().image.data;
|
||
let key0 = s.thumb_job(id, 128).unwrap().key;
|
||
assert_eq!(s.execute("photo.reload", &json!({})).unwrap()["reloaded"], json!([]), "unchanged");
|
||
write_png(&f, 200);
|
||
let r = s.execute("photo.reload", &json!({})).unwrap();
|
||
assert_eq!(r["reloaded"], json!([id.0]));
|
||
assert_ne!(s.thumb_job(id, 128).unwrap().key, key0, "the UI sees a new render key");
|
||
assert_ne!(s.render_now(id, 32, 32).unwrap().image.data, before, "renders the new pixels");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_copies_the_file_and_the_edits() {
|
||
let dir = temp_dir("dup");
|
||
let f = dir.join("pic.png");
|
||
write_png(&f, 5);
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||
let orig = s.active().unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.8})).unwrap();
|
||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||
let alb = s.execute("album.create", &json!({"name": "Keep", "addSelected": true})).unwrap()["id"].as_u64().unwrap();
|
||
let r = s.execute("photo.duplicate", &json!({})).unwrap();
|
||
let dup = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap());
|
||
let d = s.catalog.photo(dup).unwrap().clone();
|
||
assert_eq!(d.file_name, "pic-copy.png");
|
||
assert!(dir.join("pic-copy.png").exists());
|
||
assert_eq!((d.develop.light.exposure, d.rating), (0.8, 4));
|
||
assert_eq!(s.catalog.album_count(lightcraft_catalog::AlbumId(alb)), 2);
|
||
assert_ne!(dup, orig);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(s.catalog.photo(dup).is_none(), "one undo step");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn gps_typed_in_is_saved_to_xmp() {
|
||
let mut s = Session::with_demo();
|
||
let id = s.active().unwrap_or_else(|| s.catalog.photos().next().unwrap().id);
|
||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("photo.setMeta", &json!({"gps": "48°51'30\"N 2°17'40\"E"})).unwrap();
|
||
let (la, lo) = s.catalog.photo(id).unwrap().meta.gps.unwrap();
|
||
assert!((la - 48.8583).abs() < 1e-3 && (lo - 2.2944).abs() < 1e-3, "{la} {lo}");
|
||
let x = crate::sidecar::sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog);
|
||
let back = lightcraft_meta::parse_xmp(&x).unwrap().metadata.gps.unwrap();
|
||
assert!((back.latitude - la).abs() < 1e-5 && (back.longitude - lo).abs() < 1e-5);
|
||
assert!(s.execute("photo.setMeta", &json!({"gps": "north pole-ish"})).is_err());
|
||
s.execute("photo.setMeta", &json!({"gps": null})).unwrap();
|
||
assert!(s.catalog.photo(id).unwrap().meta.gps.is_none());
|
||
}
|
||
|
||
/// DNG export compression: lossless, zip and none all decode; none is the biggest.
|
||
#[test]
|
||
fn dng_export_compression_choices() {
|
||
let corpus = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw/nef-nikon-d5100-uncompressed.nef");
|
||
if !corpus.exists() {
|
||
eprintln!("skip: {} absent", corpus.display());
|
||
return;
|
||
}
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap();
|
||
let id = s.active().unwrap();
|
||
let mut sizes = Vec::new();
|
||
for c in ["lossless", "deflate", "uncompressed"] {
|
||
let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c}));
|
||
let mut out = Vec::new();
|
||
let mut write = |_: &str, b: &[u8]| {
|
||
out = b.to_vec();
|
||
Ok(())
|
||
};
|
||
crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap();
|
||
let raw = lightcraft_raw::decode(&out).unwrap_or_else(|e| panic!("{c}: {e}"));
|
||
assert!(raw.width > 1000, "{c}");
|
||
sizes.push((c, out.len()));
|
||
}
|
||
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);
|
||
}
|
||
|
||
/// Copyright status, rights usage terms and copyright info URL: edited with `photo.setMeta` (one
|
||
/// undo step), copied / pasted, kept in metadata presets, written to the sidecar as XMP Rights
|
||
/// Management fields and read back on a fresh import.
|
||
#[test]
|
||
fn copyright_status_usage_terms_and_url_round_trip() {
|
||
use lightcraft_catalog::CopyrightStatus;
|
||
let src = temp_dir("rights-src");
|
||
let lib = temp_dir("rights-lib");
|
||
write_png(&src.join("a.png"), 1);
|
||
write_png(&src.join("b.png"), 2);
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||
let (a, b) = (id(&s, "a.png"), id(&s, "b.png"));
|
||
let meta = |s: &Session, id: PhotoId| s.catalog.photo(id).unwrap().meta.clone();
|
||
assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown);
|
||
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
|
||
s.execute(
|
||
"photo.setMeta",
|
||
&json!({"copyright": "© 2026 A. Person", "copyrightStatus": "copyrighted", "usageTerms": "Editorial use only", "copyrightUrl": "https://example.com/rights"}),
|
||
)
|
||
.unwrap();
|
||
let m = meta(&s, a);
|
||
assert_eq!(
|
||
(m.copyright_status, m.usage_terms.as_str(), m.copyright_url.as_str()),
|
||
(CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights")
|
||
);
|
||
assert!(s.execute("photo.setMeta", &json!({"copyrightStatus": "maybe"})).is_err());
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown, "one undo step");
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
// copy → paste onto b
|
||
let clip = s.execute("photo.copyMetadata", &json!({})).unwrap();
|
||
assert_eq!(clip["copyrightStatus"], "copyrighted");
|
||
s.execute("photo.pasteMetadata", &json!({"ids": [b.0], "fields": ["copyrightStatus", "usageTerms"]})).unwrap();
|
||
let mb = meta(&s, b);
|
||
assert_eq!((mb.copyright_status, mb.usage_terms.as_str(), mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "Editorial use only", ""));
|
||
// a metadata preset from the active photo carries the copyright fields; applying it sets them
|
||
s.execute("metadata.savePreset", &json!({"name": "Rights"})).unwrap();
|
||
let presets = s.execute("metadata.presets", &json!({})).unwrap();
|
||
assert_eq!(presets[0]["fields"]["copyrightStatus"], "copyrighted", "{presets}");
|
||
assert_eq!(presets[0]["fields"]["copyrightUrl"], "https://example.com/rights");
|
||
s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "publicDomain", "usageTerms": ""})).unwrap();
|
||
s.execute("metadata.applyPreset", &json!({"name": "Rights", "ids": [b.0]})).unwrap();
|
||
let mb = meta(&s, b);
|
||
assert_eq!((mb.copyright_status, mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "https://example.com/rights"));
|
||
assert!(s.execute("metadata.savePreset", &json!({"name": "Bad", "fields": {"copyrightStatus": "sort of"}})).is_err());
|
||
// b becomes public domain; smart-album rule on the status
|
||
s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "public domain"})).unwrap();
|
||
let r = s
|
||
.execute(
|
||
"album.createSmart",
|
||
&json!({"name": "PD", "rules": {"ruleSet": {"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]}}}),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(r["count"], 1, "{r}");
|
||
// sidecars: XMP Rights Management fields, read back by a fresh library
|
||
s.execute("photo.saveMetadataToFile", &json!({"ids": [a.0, b.0]})).unwrap();
|
||
let xmp = std::fs::read_to_string(src.join("a.xmp")).unwrap();
|
||
assert!(xmp.contains("<xmpRights:Marked>True</xmpRights:Marked>"), "{xmp}");
|
||
assert!(xmp.contains("<xmpRights:WebStatement>https://example.com/rights</xmpRights:WebStatement>"), "{xmp}");
|
||
assert!(std::fs::read_to_string(src.join("b.xmp")).unwrap().contains("<xmpRights:Marked>False</xmpRights:Marked>"));
|
||
let expect_a = meta(&s, a);
|
||
drop(s);
|
||
std::fs::remove_dir_all(&lib).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
let ma = meta(&s, id(&s, "a.png"));
|
||
assert_eq!(
|
||
(ma.copyright.as_str(), ma.copyright_status, ma.usage_terms.as_str(), ma.copyright_url.as_str()),
|
||
(expect_a.copyright.as_str(), CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights")
|
||
);
|
||
assert_eq!(meta(&s, id(&s, "b.png")).copyright_status, CopyrightStatus::PublicDomain);
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
let _ = std::fs::remove_dir_all(&lib);
|
||
}
|
||
|
||
/// A sidecar another raw developer wrote: develop settings in attributes and elements, a
|
||
/// structured edit history, an unknown namespace.
|
||
const OTHER_APP_SIDECAR: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Other Toolkit">
|
||
<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:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
||
xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"
|
||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||
xmlns:other="http://example.com/other/1.0/"
|
||
xmp:Rating="2"
|
||
crs:Version="99.0"
|
||
crs:Exposure2012="+0.65"
|
||
crs:HasSettings="True"
|
||
other:Secret="keep me">
|
||
<xmpMM:History>
|
||
<rdf:Seq>
|
||
<rdf:li stEvt:action="derived" stEvt:parameters="converted from image/x-raw to image/png"/>
|
||
</rdf:Seq>
|
||
</xmpMM:History>
|
||
<crs:ToneCurvePV2012>
|
||
<rdf:Seq>
|
||
<rdf:li>0, 0</rdf:li>
|
||
<rdf:li>128, 140</rdf:li>
|
||
<rdf:li>255, 255</rdf:li>
|
||
</rdf:Seq>
|
||
</crs:ToneCurvePV2012>
|
||
</rdf:Description>
|
||
</rdf:RDF>
|
||
</x:xmpmeta>
|
||
<?xpacket end="w"?>"#;
|
||
|
||
/// Issue #92: saving merges into another app's sidecar instead of replacing it.
|
||
#[test]
|
||
fn saving_merges_into_another_apps_sidecar() {
|
||
let src = temp_dir("merge");
|
||
let lib = temp_dir("merge-lib");
|
||
write_png(&src.join("shot.png"), 1);
|
||
std::fs::write(src.join("shot.xmp"), OTHER_APP_SIDECAR).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
s.execute("library.import", &json!({"paths": [src.join("shot.png").to_string_lossy()]})).unwrap();
|
||
let id = only(&s);
|
||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
||
s.execute("photo.setMeta", &json!({"title": "Harbour", "keywords": ["boats"]})).unwrap();
|
||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 30})).unwrap();
|
||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
assert_eq!(r["merged"][0], src.join("shot.xmp").to_string_lossy().as_ref(), "{r}");
|
||
let out = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||
// the other app's data, byte for byte
|
||
for part in [
|
||
"crs:Exposure2012=\"+0.65\"",
|
||
"other:Secret=\"keep me\"",
|
||
" <xmpMM:History>\n <rdf:Seq>\n <rdf:li stEvt:action=\"derived\" stEvt:parameters=\"converted from image/x-raw to image/png\"/>\n </rdf:Seq>\n </xmpMM:History>",
|
||
" <crs:ToneCurvePV2012>\n <rdf:Seq>\n <rdf:li>0, 0</rdf:li>\n <rdf:li>128, 140</rdf:li>",
|
||
] {
|
||
assert!(out.contains(part), "lost {part:?}:\n{out}");
|
||
}
|
||
let d = lightcraft_meta::parse_xmp(&out).unwrap();
|
||
assert_eq!(d.metadata.rating, Some(5), "ours replaces theirs");
|
||
assert_eq!(d.metadata.title.as_deref(), Some("Harbour"));
|
||
assert_eq!(d.properties.get("crs:Version"), Some(&vec!["99.0".to_string()]));
|
||
let saved = s.catalog.photo(id).unwrap().develop.clone();
|
||
// reading it back restores our settings (lc:settings wins over crs:)
|
||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 0})).unwrap();
|
||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().develop, saved);
|
||
// saving again keeps one LightCraft description and the other app's data
|
||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
let again = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||
assert_eq!(again.matches("<rdf:Description").count(), 2, "{again}");
|
||
assert!(again.contains("other:Secret=\"keep me\"") && again.contains("crs:Exposure2012=\"+0.65\""));
|
||
assert_eq!(lightcraft_meta::parse_xmp(&again).unwrap().metadata.rating, Some(3));
|
||
|
||
// a sidecar that isn't XMP is kept as a backup before it is replaced
|
||
std::fs::write(src.join("shot.xmp"), b"<not xmp").unwrap();
|
||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
let backup = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||
assert!(backup.contains("shot.xmp.bak-"), "{r}");
|
||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||
assert_eq!(lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join("shot.xmp")).unwrap()).unwrap().metadata.rating, Some(3));
|
||
// … and another one never overwrites the first backup
|
||
std::fs::write(src.join("shot.xmp"), b"<still not xmp").unwrap();
|
||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||
let second = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||
assert_ne!(second, backup);
|
||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||
assert_eq!(std::fs::read(&second).unwrap(), b"<still not xmp");
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
let _ = std::fs::remove_dir_all(&lib);
|
||
}
|
||
|
||
/// Issue #92: two catalogued files with one stem (`IMG_1.jpg` + `IMG_1.png`) don't share a
|
||
/// Stem-named sidecar: the first by name keeps `IMG_1.xmp`, the other writes and reads
|
||
/// `IMG_1.png.xmp`.
|
||
#[test]
|
||
fn files_sharing_a_stem_get_their_own_sidecars() {
|
||
let src = temp_dir("stem");
|
||
let lib = temp_dir("stem-lib");
|
||
write_png(&src.join("IMG_1.png"), 1);
|
||
let img = lightcraft_raster::Rgba8::from_fn(40, 24, |x, y| [(x * 6) as u8, (y * 9) as u8, 7, 255]);
|
||
let jpg =
|
||
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Jpeg, ..Default::default() }).unwrap();
|
||
std::fs::write(src.join("IMG_1.jpg"), jpg).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||
let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||
let (j, p) = (id(&s, "IMG_1.jpg"), id(&s, "IMG_1.png"));
|
||
assert_eq!(s.sidecar_naming(j), crate::sidecar::SidecarNaming::Stem);
|
||
assert_eq!(s.sidecar_naming(p), crate::sidecar::SidecarNaming::Full);
|
||
s.execute("photo.rate", &json!({"ids": [j.0], "rating": 2})).unwrap();
|
||
s.execute("photo.rate", &json!({"ids": [p.0], "rating": 5})).unwrap();
|
||
let r = s.execute("photo.saveMetadataToFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||
assert_eq!(r["written"].as_array().unwrap().len(), 2, "{r}");
|
||
let rating = |f: &str| lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join(f)).unwrap()).unwrap().metadata.rating;
|
||
assert_eq!(rating("IMG_1.xmp"), Some(2));
|
||
assert_eq!(rating("IMG_1.png.xmp"), Some(5));
|
||
// each reads its own back
|
||
s.execute("photo.rate", &json!({"ids": [j.0, p.0], "rating": 0})).unwrap();
|
||
s.execute("photo.readMetadataFromFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||
assert_eq!((s.catalog.photo(j).unwrap().rating, s.catalog.photo(p).unwrap().rating), (2, 5));
|
||
let _ = std::fs::remove_dir_all(&src);
|
||
let _ = std::fs::remove_dir_all(&lib);
|
||
}
|