diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 0d50ebe8..09a19595 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -156,6 +156,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 7f744330..9c8d63d1 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -174,6 +174,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 8b9ee8a2..41b43cab 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.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| { @@ -130,6 +221,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 `