mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
Merge PR #2977: LR-META-EXIF-FULL: reject malformed EXIF clocks
docs/gaps.md, docs/parity-checklist.md, docs/whats-new.md: both sides kept; the PR's sentences added to main's rows and entries. docs/xmp-interop.md: capture-time paragraph combines both sides. The PR's test file crates/engine/src/tests_import.rs landed in crates/engine/src/tests/import.rs (moved on main).
This commit is contained in:
@@ -497,9 +497,7 @@ impl NativeMenu {
|
||||
// events never reach `shortcuts::handle`, so this is the one guard for AppKit's
|
||||
// accelerators and clicks: while the blocking library window or a quit/settings notice
|
||||
// owns input, a queued command is dropped (Quit stays for the existing confirmation).
|
||||
let (cmd, params) = if key == QUIT {
|
||||
(key, serde_json::json!({}))
|
||||
} else if key == "app.about" || key == SETTINGS {
|
||||
let (cmd, params) = if key == QUIT || key == "app.about" || key == SETTINGS {
|
||||
(key, serde_json::json!({}))
|
||||
} else if let Some(it) = self.items.get(&key) {
|
||||
(it.cmd.clone(), it.params.clone())
|
||||
|
||||
@@ -30,6 +30,34 @@ fn write_png(path: &Path, seed: u8) {
|
||||
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
|
||||
fn exif_clock_block(original: Option<&str>, digitized: Option<&str>) -> Vec<u8> {
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
|
||||
|
||||
let mut exif = IfdBuilder::new();
|
||||
if let Some(value) = original {
|
||||
exif.set(0x9003, Value::Ascii(value.into()));
|
||||
}
|
||||
if let Some(value) = digitized {
|
||||
exif.set(0x9004, Value::Ascii(value.into()));
|
||||
}
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set_child(0x8769, exif);
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
fn write_png_with_metadata(path: &Path, seed: u8, exif: Option<&[u8]>, xmp: Option<&str>) {
|
||||
let (w, h) = (48usize, 32usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let metadata = lightcraft_codecs::EncodeMeta { exif, xmp, ..Default::default() };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &metadata).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
// Keep the same known file-date fallback as write_png, distinct from the regression timestamp.
|
||||
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
|
||||
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value, key: &str) -> usize {
|
||||
v[key].as_array().map(Vec::len).unwrap_or(0)
|
||||
}
|
||||
@@ -128,6 +156,106 @@ fn civil_dates() {
|
||||
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_import_uses_valid_fallback_dates_and_keeps_raw_tags() {
|
||||
const INVALID: &str = "2026:01:01 12:34:56:99";
|
||||
const XMP: &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:xmp="http://ns.adobe.com/xap/1.0/" xmp:CreateDate="2026-01-05T10:00:00Z"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let dir = temp_dir("exif-clock-fallbacks");
|
||||
let digitized_path = dir.join("digitized.png");
|
||||
let digitized_exif = exif_clock_block(Some(INVALID), Some("2026:01:02 10:00:00"));
|
||||
write_png_with_metadata(&digitized_path, 1, Some(&digitized_exif), None);
|
||||
let xmp_path = dir.join("xmp.png");
|
||||
let malformed_only_exif = exif_clock_block(Some(INVALID), None);
|
||||
write_png_with_metadata(&xmp_path, 2, Some(&malformed_only_exif), Some(XMP));
|
||||
|
||||
let mut s = Session::new().with_fs();
|
||||
let imported = s.execute("library.import", &json!({"paths": [digitized_path.to_string_lossy(), xmp_path.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(ids(&imported, "imported"), 2, "{imported}");
|
||||
let digitized = s.catalog.photos().find(|photo| photo.file_name == "digitized.png").unwrap();
|
||||
let digitized_id = digitized.id.0;
|
||||
assert_eq!(digitized.captured.as_deref(), Some("2026-01-02T10:00:00"));
|
||||
let xmp = s.catalog.photos().find(|photo| photo.file_name == "xmp.png").unwrap();
|
||||
let xmp_id = xmp.id.0;
|
||||
assert_eq!(xmp.captured.as_deref(), Some("2026-01-05T10:00:00+00:00"));
|
||||
|
||||
let all = s.execute("photo.allMetadata", &json!({"id": digitized_id})).unwrap();
|
||||
assert!(all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID), "{all}");
|
||||
let all = s.execute("photo.allMetadata", &json!({"id": xmp_id})).unwrap();
|
||||
assert!(all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID), "{all}");
|
||||
assert!(all["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "xmp:CreateDate" && row["value"] == "2026-01-05T10:00:00Z"), "{all}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_malformed_capture_cannot_fabricate_tracklog_match() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
|
||||
const INVALID: &str = "2026:01:01 12:34:56:99";
|
||||
const GPX: &str = r#"<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg><trkpt lat="45" lon="7"><time>2026-01-01T12:34:56Z</time></trkpt></trkseg></trk></gpx>"#;
|
||||
let dir = temp_dir("exif-clock-tracklog");
|
||||
let malformed_path = dir.join("malformed.png");
|
||||
let malformed_exif = exif_clock_block(Some(INVALID), None);
|
||||
write_png_with_metadata(&malformed_path, 3, Some(&malformed_exif), None);
|
||||
let valid_path = dir.join("valid.png");
|
||||
let valid_exif = exif_clock_block(Some("2026:01:01 12:34:56"), None);
|
||||
write_png_with_metadata(&valid_path, 4, Some(&valid_exif), None);
|
||||
|
||||
let mut s = Session::new().with_fs();
|
||||
let imported = s.execute("library.import", &json!({"paths": [malformed_path.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(ids(&imported, "imported"), 1, "{imported}");
|
||||
let bad_id = imported["imported"][0].as_u64().unwrap();
|
||||
assert_eq!(
|
||||
s.catalog.photo(PhotoId(bad_id)).unwrap().captured.as_deref(),
|
||||
Some("2026-01-14T05:58:48"),
|
||||
"malformed EXIF must leave the normal file-date fallback in effect"
|
||||
);
|
||||
let all = s.execute("photo.allMetadata", &json!({"id": bad_id})).unwrap();
|
||||
assert!(
|
||||
all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID),
|
||||
"raw EXIF remains available: {all}"
|
||||
);
|
||||
|
||||
// Leave both undo and redo populated so a no-match call must preserve the existing history branch.
|
||||
s.execute("photo.setMeta", &json!({"ids": [bad_id], "title": "history control"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [bad_id]})).unwrap();
|
||||
assert!(!s.undo.is_empty() && !s.redo.is_empty());
|
||||
let state = |session: &Session| {
|
||||
(
|
||||
session.catalog.to_snapshot(),
|
||||
session.catalog.revision,
|
||||
session.selection.clone(),
|
||||
session.commits(),
|
||||
session.undo.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::<Vec<_>>(),
|
||||
session.redo.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let before = state(&s);
|
||||
let dry = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [bad_id], "maxGap": 0, "dryRun": true})).unwrap();
|
||||
assert_eq!(dry["tagged"], 0, "{dry}");
|
||||
assert_eq!(dry["skipped"]["outside"], 1, "the file-date fallback is outside the one-point track: {dry}");
|
||||
assert_eq!(state(&s), before, "dry-run changed catalog, selection or history");
|
||||
let actual = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [bad_id], "maxGap": 0})).unwrap();
|
||||
assert_eq!(actual["tagged"], 0, "{actual}");
|
||||
assert_eq!(actual["skipped"]["outside"], 1, "{actual}");
|
||||
assert_eq!(state(&s), before, "unmatched tracklog call changed catalog, selection or history");
|
||||
assert_eq!(s.catalog.photo(PhotoId(bad_id)).unwrap().meta.gps, None);
|
||||
|
||||
let imported = s.execute("library.import", &json!({"paths": [valid_path.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(ids(&imported, "imported"), 1, "{imported}");
|
||||
let valid_id = imported["imported"][0].as_u64().unwrap();
|
||||
assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().captured.as_deref(), Some("2026-01-01T12:34:56"));
|
||||
s.execute("library.select", &json!({"ids": [valid_id]})).unwrap();
|
||||
let undo_before = s.undo.len();
|
||||
let tagged = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [valid_id], "maxGap": 0})).unwrap();
|
||||
assert_eq!(tagged["tagged"], 1, "{tagged}");
|
||||
assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().meta.gps, Some((45.0, 7.0)));
|
||||
assert_eq!(s.undo.len(), undo_before + 1, "valid tagging is one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().meta.gps, None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
||||
use crate::LibrarySource;
|
||||
|
||||
@@ -20,6 +20,10 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr
|
||||
35 mm equivalent, flash, exposure bias, program, metering, white balance), orientation, GPS, dimensions and the
|
||||
user fields (artist, copyright, title, caption, keywords, hierarchical keywords, rating −1…5, colour label).
|
||||
|
||||
EXIF capture-time candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock
|
||||
with more than three colon-separated components is rejected as a whole, allowing the next valid candidate or XMP
|
||||
fallback to supply the capture time. The original tag remains available in All Metadata.
|
||||
|
||||
## XMP namespaces
|
||||
|
||||
Written: `dc`, `xmp`, `photoshop`, `exif`, `exifEX`, `tiff`, `lr` (`hierarchicalSubject`) and ours,
|
||||
|
||||
@@ -43,15 +43,27 @@ impl DateTime {
|
||||
if dp.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let tp: Vec<&str> = if t.is_empty() { vec![] } else { t.split(':').collect() };
|
||||
let g = |v: &Vec<&str>, i: usize| v.get(i).map(|x| num(x.trim())).unwrap_or(Some(0));
|
||||
let (hour, minute, second) = if t.is_empty() {
|
||||
(0, 0, 0)
|
||||
} else {
|
||||
let mut parts = t.split(':');
|
||||
let hour = num(parts.next()?.trim())?;
|
||||
let minute = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?;
|
||||
let second = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?;
|
||||
// A fourth value cannot be represented; accepting only the prefix could turn a
|
||||
// malformed DateTimeOriginal into a plausible capture time and block fallbacks.
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
(hour, minute, second)
|
||||
};
|
||||
DateTime {
|
||||
year: num(dp[0])?.try_into().ok()?,
|
||||
month: num(dp[1])?.try_into().ok()?,
|
||||
day: num(dp[2])?.try_into().ok()?,
|
||||
hour: g(&tp, 0)?.try_into().ok()?,
|
||||
minute: g(&tp, 1)?.try_into().ok()?,
|
||||
second: g(&tp, 2)?.try_into().ok()?,
|
||||
hour: hour.try_into().ok()?,
|
||||
minute: minute.try_into().ok()?,
|
||||
second: second.try_into().ok()?,
|
||||
millis: 0,
|
||||
offset_minutes: None,
|
||||
}
|
||||
@@ -196,6 +208,30 @@ mod tests {
|
||||
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_rejects_extra_components() {
|
||||
for value in
|
||||
["2026:01:01 12:34:56:99", "2026:01:01 12:34:56:junk", "2026:01:01 12:34:56:", "2026:01:01 12:34:56::", "2026:01:01 12:34:56:99:junk"]
|
||||
{
|
||||
assert!(DateTime::parse_exif(value).is_none(), "accepted malformed EXIF clock {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_supported_forms_keep_their_meaning() {
|
||||
for (value, expected) in [
|
||||
("2026:01:01", "2026-01-01T00:00:00"),
|
||||
("2026:01:01 12", "2026-01-01T12:00:00"),
|
||||
("2026:01:01 12:34", "2026-01-01T12:34:00"),
|
||||
("2026:01:01 12:34:56", "2026-01-01T12:34:56"),
|
||||
("2026-01-01T12:34:56", "2026-01-01T12:34:56"),
|
||||
(" 2026:01:01 12:34:56\0", "2026-01-01T12:34:56"),
|
||||
("2026:01:01T12:34:60", "2026-01-01T12:34:60"),
|
||||
] {
|
||||
assert_eq!(DateTime::parse_exif(value).map(|date| date.to_iso()), Some(expected.to_string()), "{value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets() {
|
||||
assert_eq!(DateTime::parse_offset("+02:00"), Some(120));
|
||||
|
||||
@@ -242,6 +242,53 @@ pub(crate) mod tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
|
||||
|
||||
fn capture_time_tiff(original: Option<&str>, digitized: Option<&str>, ifd0_time: Option<&str>, xmp: Option<&str>) -> Vec<u8> {
|
||||
let mut exif = IfdBuilder::new();
|
||||
if let Some(value) = original {
|
||||
exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii(value.into()));
|
||||
}
|
||||
if let Some(value) = digitized {
|
||||
exif.set(t::DATE_TIME_DIGITIZED, Value::Ascii(value.into()));
|
||||
}
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
if let Some(value) = ifd0_time {
|
||||
ifd0.set(t::DATE_TIME, Value::Ascii(value.into()));
|
||||
}
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
if let Some(packet) = xmp {
|
||||
ifd0.set(t::XMP, Value::Byte(packet.as_bytes().to_vec()));
|
||||
}
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_invalid_original_uses_the_next_valid_exif_time() {
|
||||
let invalid = "2026:01:01 12:34:56:99";
|
||||
let digitized = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None);
|
||||
assert_eq!(read_exif(&digitized).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00".into()));
|
||||
|
||||
let ifd0 = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:"), Some("2026:01:03 11:00:00"), None);
|
||||
assert_eq!(read_exif(&ifd0).capture_time.map(|date| date.to_iso()), Some("2026-01-03T11:00:00".into()));
|
||||
|
||||
let none = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:junk"), Some("2026:01:03 11:00:00:"), None);
|
||||
assert_eq!(read_exif(&none).capture_time, None);
|
||||
|
||||
let original = capture_time_tiff(Some("2026:01:04 12:00:00"), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None);
|
||||
assert_eq!(read_exif(&original).capture_time.map(|date| date.to_iso()), Some("2026-01-04T12:00:00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_invalid_original_allows_xmp_fallback_and_keeps_raw_tag() {
|
||||
let capture = DateTime::parse_iso("2026-01-02T10:00:00Z");
|
||||
let packet = crate::write_xmp(&Metadata { capture_time: capture, ..Default::default() }, None);
|
||||
let bytes = capture_time_tiff(Some("2026:01:01 12:34:56:99"), None, None, Some(&packet));
|
||||
assert_eq!(crate::extract(&bytes).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00+00:00".into()));
|
||||
|
||||
let without_fallback = capture_time_tiff(Some("2026:01:01 12:34:56:junk"), None, None, None);
|
||||
assert_eq!(crate::extract(&without_fallback).capture_time, None);
|
||||
assert!(crate::embedded(&without_fallback).exif.is_some(), "rejecting the projection must retain the embedded EXIF block");
|
||||
}
|
||||
|
||||
/// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value
|
||||
/// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it).
|
||||
pub(crate) fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec<u8> {
|
||||
|
||||
@@ -606,7 +606,12 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let canvas = area_and_filmstrip(app, ui);
|
||||
let reference = app.ui.reference.map(PhotoId).filter(|r| live(app, *r));
|
||||
let (Some(r), Some(active)) = (reference, app.session.active()) else {
|
||||
super::empty_message(ui, canvas, "No reference photo", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)");
|
||||
super::empty_message(
|
||||
ui,
|
||||
canvas,
|
||||
"No reference photo",
|
||||
"Right-click a photo and choose Set as Reference Photo, then View → Reference View (Shift+R)",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let area = canvas.shrink(18.0);
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps: where LightCraft falls short of Lightroom
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (LR-META-EXIF-FULL rejects extra EXIF clock components and preserves date fallbacks; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)
|
||||
|
||||
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
|
||||
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
|
||||
@@ -86,6 +86,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|---|---|---|
|
||||
| 2026-10-25 | patch | ISO timestamp parsing rejects incomplete or trailing clock/fraction text; XMP falls back to a valid capture-time property and GPX counts malformed times as untimed; ranked gaps and readiness unchanged |
|
||||
| 2026-10-24 | minor | Gap 8: lens profile work is tracked in #329; hours unchanged |
|
||||
| 2026-10-25 | patch | LR-META-EXIF-FULL rejects extra clock components rather than projecting a plausible prefix; valid DateTimeDigitized, IFD0 DateTime and XMP fallbacks remain available, raw tag values stay visible, and the status / feature counts do not change |
|
||||
| 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged |
|
||||
| 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) |
|
||||
| 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched) · **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
|
||||
@@ -169,7 +169,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| 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` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests/xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; keywords are saved as names in `dc:subject` and paths in `lr:hierarchicalSubject` (`keywords::to_file`; before, paths went into `dc:subject`); 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 (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), 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); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`); malformed XMP capture-time values remain in the raw property map and are not projected, allowing the next valid field to supply the capture time |
|
||||
| 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` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests/xmp.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; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets |
|
||||
| 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` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests/xmp.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; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets; malformed EXIF clocks with extra components are rejected so valid DateTimeDigitized, IFD0 DateTime or XMP fallbacks apply, while raw values stay visible in All Metadata (`crates/meta/src/exif.rs`, `crates/engine/src/tests/import.rs`) |
|
||||
|
||||
## F. Edit panel — global adjustments (EDIT)
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
different valid time. XMP keeps the original property available and can use the next valid capture-time field;
|
||||
GPX treats an invalid point time as untimed, so it cannot tag a photo at a fabricated time.
|
||||
|
||||
### Invalid EXIF capture clocks no longer become plausible dates
|
||||
- An EXIF capture time with extra clock components is ignored as a whole. LightCraft can use a valid DateTimeDigitized,
|
||||
IFD0 DateTime or embedded XMP value instead, while All Metadata continues to show the original tag. This also keeps
|
||||
malformed timestamps from creating false matches when geotagging from a track log.
|
||||
|
||||
## 2026-10-24
|
||||
|
||||
### Sidecar keywords stay searchable in other applications
|
||||
|
||||
+6
-5
@@ -31,11 +31,12 @@ 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. 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).
|
||||
An incomplete or malformed XMP timestamp is not projected as a capture time, remains available in the raw property
|
||||
map, and does not prevent the next valid capture-time property from being used.
|
||||
kept. The one exception is the **capture time**: a valid time embedded in the file (EXIF/IPTC) wins. Invalid EXIF
|
||||
clock values are skipped, allowing DateTimeDigitized, IFD0 DateTime or embedded XMP to supply the time. If the file
|
||||
has no valid capture time, 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). An incomplete or malformed XMP timestamp is not projected as a capture time,
|
||||
remains available in the raw property map, and does not prevent the next valid capture-time property from being used.
|
||||
`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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user