From 1901a756bb5426a4616d5fffff47803349c0a4e1 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sun, 4 Oct 2026 00:41:11 -0700 Subject: [PATCH] =?UTF-8?q?LRC-MAP-TRACKLOG:=20Auto-Tag=20from=20Tracklog?= =?UTF-8?q?=20=E2=80=94=20GPS=20from=20a=20GPX=20track=20log=20by=20captur?= =?UTF-8?q?e=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPX 1.0/1.1 reader in lightcraft-meta (timed track points per segment; interpolation only within a segment, nearest point within maxGap otherwise). Engine command photo.autoTagTracklog (path or gpx text, camera time-zone offset, maxGap, replace, dryRun; one undo step) and Photo ▸ Auto-Tag from Tracklog… with a time-zone prompt. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/src/main.rs | 8 + crates/engine/src/cmd/manage.rs | 100 ++++++++ crates/engine/src/tests_libops.rs | 65 ++++++ crates/meta/src/gpx.rs | 374 ++++++++++++++++++++++++++++++ crates/meta/src/lib.rs | 3 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 35 +++ crates/ui-egui/src/menus.rs | 43 ++++ docs/parity.md | 8 +- docs/whats-new.md | 1 + 11 files changed, 636 insertions(+), 4 deletions(-) create mode 100644 crates/meta/src/gpx.rs diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 63308367..71f0aceb 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -153,6 +153,7 @@ fn services(originals: Originals, backend: Option, ctx: egui::Context) })), // Preset files: browser pickers are asynchronous; not wired on the web yet. pick_preset_files: None, + pick_tracklog: None, save_preset_file: None, pick_curve_preset_files: None, save_curve_preset_file: None, diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 975283af..6f71769b 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -173,6 +173,14 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), + pick_tracklog: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title("Auto-Tag from Tracklog") + .add_filter("GPS Track Log", &["gpx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), save_preset_file: Some(Box::new(|name: &str| { rfd::FileDialog::new() .set_title("Export Presets") diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 84b46ccc..a538bafd 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -59,6 +59,97 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec { .collect() } +/// Geotag photos from a GPX track log by capture time (Lightroom Classic's Map ▸ Tracklog ▸ +/// Auto-Tag Photos). GPX times are UTC; capture times are the camera's local clock, so a photo's +/// recorded zone (Exif `OffsetTimeOriginal`) or the `offset` parameter converts them. +fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result { + use lightcraft_meta::{DateTime, Match, parse_gpx}; + const C: &str = "photo.autoTagTracklog"; + let text = match (str_param(p, "gpx"), str_param(p, "path")) { + (Some(t), _) => t.to_string(), + (None, Some(path)) => std::fs::read_to_string(path).map_err(|e| bad(C, format!("can't read {path}: {e}")))?, + (None, None) => return Err(bad(C, "missing `path` (a .gpx file) or `gpx` (its text)")), + }; + let log = parse_gpx(&text).map_err(|e| bad(C, e.to_string()))?; + let (start, end) = log.span().ok_or_else(|| bad(C, "the track log has no timed track points"))?; + // minutes east of UTC; `None` = not given (zone-less capture times are then read as UTC) + let offset: Option = match p.get("offset") { + None | Some(Value::Null) => None, + Some(Value::String(o)) if o.trim().is_empty() => None, + Some(Value::Number(h)) => { + let h = h.as_f64().unwrap_or(0.0); + if h.abs() > 18.0 { + return Err(bad(C, "offset is at most ±18 hours")); + } + Some((h * 60.0).round() as i64) + } + Some(Value::String(o)) => { + Some(DateTime::parse_offset(o).ok_or_else(|| bad(C, format!("can't read offset `{o}` (e.g. +02:00 or -5)")))? as i64) + } + Some(_) => return Err(bad(C, "offset is `+HH:MM` or a number of hours")), + }; + let max_gap = f64_or(p, "maxGap", 600.0); + if max_gap.is_nan() || max_gap < 0.0 { + return Err(bad(C, "maxGap must be ≥ 0 seconds")); + } + let replace = bool_or(p, "replace", false); + let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0); + let mut ops = Vec::new(); + let mut photos = Vec::new(); + for id in s.targets(p) { + let Some(ph) = s.catalog.photo(id) else { continue }; + if ph.meta.gps.is_some() && !replace { + has_gps += 1; + continue; + } + let Some(dt) = ph.captured.as_deref().and_then(DateTime::parse_iso) else { + no_time += 1; + continue; + }; + // UTC seconds: the recorded zone wins, else the caller's offset + let utc = match dt.offset_minutes { + Some(_) => dt.unix_seconds(), + None => { + if offset.is_none() { + assumed_utc += 1; + } + dt.unix_seconds() - offset.unwrap_or(0) * 60 + } + } as f64 + + dt.millis as f64 / 1000.0; + let Some((g, m)) = log.locate(utc, max_gap) else { + outside += 1; + continue; + }; + if m == Match::Interpolated { + interpolated += 1; + } + let pos = ((g.latitude * 1e7).round() / 1e7, (g.longitude * 1e7).round() / 1e7); + photos.push(json!({"id": id.0, "gps": [pos.0, pos.1], "match": if m == Match::Interpolated { "interpolated" } else { "nearest" }})); + if ph.meta.gps != Some(pos) { + let mut meta = ph.meta.clone(); + meta.gps = Some(pos); + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + } + let tagged = photos.len(); + if !bool_or(p, "dryRun", false) && !ops.is_empty() { + s.commit("Auto-Tag from Tracklog", Op::Batch { ops })?; + } + let iso = |t: f64| format!("{}Z", lightcraft_catalog::dates::civil(t.floor() as i64)); + Ok(json!({ + "tagged": tagged, + "interpolated": interpolated, + "nearest": tagged - interpolated, + "skipped": {"noTime": no_time, "outside": outside, "hasGps": has_gps}, + "assumedUtc": assumed_utc, + "points": log.points.len(), + "start": iso(start), + "end": iso(end), + "photos": photos, + })) +} + pub fn specs() -> Vec { vec![ cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| { @@ -127,6 +218,15 @@ pub fn specs() -> Vec { Ok(json!({"changed": n, "captured": out})) } ), + cmd!( + "photo.autoTagTracklog", + "Auto-Tag Photos from Tracklog", + [], + None, + "{path?: GPX file | gpx?: GPX text, ids?, offset?: camera clock's UTC offset for photos whose capture time has no zone (`+02:00`, or hours; default UTC), maxGap?: seconds (600), replace?: bool (also photos that already have GPS), dryRun?: bool} → {tagged, interpolated, nearest, skipped: {noTime, outside, hasGps}, assumedUtc, points, start, end, photos: [{id, gps, match}]} — positions from the track log by capture time; one undo step", + has_selection, + auto_tag_tracklog + ), cmd!(query "label.names", "Color Label Names", [], None, "{} → [{label, name, custom}]", always, |s, _| { Ok(json!(ColorLabel::ALL .iter() diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 5ac8e19d..90941498 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -401,3 +401,68 @@ fn keyword_sets_and_recent_keywords() { s.execute("keyword.deleteSet", &json!({"name": "Wedding"})).unwrap(); assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "Recent Keywords"); } + +/// Auto-Tag from Tracklog: GPS positions from a GPX file by capture time — interpolated within a +/// segment, the camera's zone from the photo or `offset`; photos with GPS kept unless `replace`; +/// one undo step, replayed from the op log. +#[test] +fn tracklog_auto_tag() { + use lightcraft_catalog::{Op, PhotoId}; + const GPX: &str = r#" + + + + +"#; + let dir = temp_dir("tracklog"); + let gpx_path = dir.join("walk.gpx"); + std::fs::write(&gpx_path, GPX).unwrap(); + let mut s = Session::new(); + s.open_library(dir.join("lib"), true).unwrap(); + let ids: Vec = s.visible_cloned().iter().take(4).map(|p| p.0).collect(); + let set = |s: &mut Session, id: u64, t: &str| { + s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()) }).unwrap(); + }; + set(&mut s, ids[0], "2026-05-01T12:00:30"); // camera clock on +02:00, no zone recorded + set(&mut s, ids[1], "2026-05-01T12:01:00+02:00"); // zone recorded: exactly on the 2nd point + set(&mut s, ids[2], "2026-05-01T15:00:00"); // hours after the log ends + set(&mut s, ids[3], "2026-05-01T12:02:00"); + s.execute("photo.setMeta", &json!({"ids": [ids[3]], "gps": [10.0, 20.0]})).unwrap(); + let gps = |s: &Session, id: u64| s.catalog.photo(PhotoId(id)).unwrap().meta.gps; + let before: Vec<_> = ids.iter().map(|i| gps(&s, *i)).collect(); + let path = gpx_path.to_string_lossy().to_string(); + // a dry run reports without changing anything + let r = s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": "+02:00", "dryRun": true})).unwrap(); + assert_eq!((r["tagged"].as_u64(), r["interpolated"].as_u64()), (Some(2), Some(2)), "{r}"); + assert_eq!(r["skipped"], json!({"noTime": 0, "outside": 1, "hasGps": 1})); + assert_eq!((r["points"].as_u64(), r["start"].as_str()), (Some(3), Some("2026-05-01T10:00:00Z"))); + assert_eq!(ids.iter().map(|i| gps(&s, *i)).collect::>(), before); + // tag: one undo step + let undo_depth = s.undo.len(); + s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": 2})).unwrap(); + assert_eq!(s.undo.len(), undo_depth + 1); + assert_eq!(gps(&s, ids[0]), Some((46.0005, 7.001)), "interpolated half-way between the first two points"); + assert_eq!(gps(&s, ids[1]), Some((46.001, 7.002))); + assert_eq!(gps(&s, ids[2]), None); + assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0)), "existing GPS kept without `replace`"); + // without an offset, zone-less capture times count as UTC (and are reported) + let r = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "replace": true, "dryRun": true})).unwrap(); + assert_eq!((r["tagged"].as_u64(), r["assumedUtc"].as_u64()), (Some(0), Some(1)), "{r}"); + // replace: the photo that had GPS gets the track's position (its last point, 12:02 local) + s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[3]], "offset": "+02:00", "replace": true})).unwrap(); + assert_eq!(gps(&s, ids[3]), Some((46.002, 7.004))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0))); + // the op log replays the tags + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s = Session::new(); + s.open_library(dir.join("lib"), false).unwrap(); + assert_eq!(s.catalog.to_snapshot(), expect); + // errors: no file, not GPX, no timed points, bad offset + assert!(s.execute("photo.autoTagTracklog", &json!({"ids": [ids[0]]})).is_err()); + assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "", "ids": [ids[0]]})).is_err()); + assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "", "ids": [ids[0]]})).is_err()); + assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "offset": "noon"})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/meta/src/gpx.rs b/crates/meta/src/gpx.rs new file mode 100644 index 00000000..a8e8e907 --- /dev/null +++ b/crates/meta/src/gpx.rs @@ -0,0 +1,374 @@ +//! GPS track logs in GPX 1.0 / 1.1 (the open GPS Exchange Format, ): +//! the timed track points of every ``, and the position at any moment for geotagging photos. +//! +//! What we use from the format: `` → `` → `` with optional `` +//! (metres) and `