Merge pull request #60 from storybored379/feat/gpx-tracklog

LRC-MAP-TRACKLOG: Auto-Tag from Tracklog (GPS from a GPX file by capture time)
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-04 15:32:00 -04:00
commit 840b294e23
11 files changed
+636 -4

No files matched your search

+1
View File
@@ -156,6 +156,7 @@ fn services(originals: Originals, backend: Option<Backend>, 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,
+8
View File
@@ -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")
+100
View File
@@ -59,6 +59,97 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
.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<Value> {
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<i64> = 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<CommandSpec> {
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<CommandSpec> {
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()
+65
View File
@@ -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#"<?xml version="1.0"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="46.0000" lon="7.0000"><time>2026-05-01T10:00:00Z</time></trkpt>
<trkpt lat="46.0010" lon="7.0020"><time>2026-05-01T10:01:00Z</time></trkpt>
<trkpt lat="46.0020" lon="7.0040"><time>2026-05-01T10:02:00Z</time></trkpt>
</trkseg></trk></gpx>"#;
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<u64> = 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::<Vec<_>>(), 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": "<kml/>", "ids": [ids[0]]})).is_err());
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "<gpx><trk><trkseg/></trk></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);
}
+374
View File
@@ -0,0 +1,374 @@
//! GPS track logs in GPX 1.0 / 1.1 (the open GPS Exchange Format, <https://www.topografix.com/gpx.asp>):
//! the timed track points of every `<trk>`, and the position at any moment for geotagging photos.
//!
//! What we use from the format: `<trk>` → `<trkseg>` → `<trkpt lat=… lon=…>` with optional `<ele>`
//! (metres) and `<time>` (an `xsd:dateTime`, UTC per the schema — a time without a zone is read as
//! UTC). Points without a time can't be matched to a photo and are left out; waypoints (`<wpt>`) and
//! routes (`<rte>`) carry no timeline and are ignored. The schema defines a track segment as one
//! continuous span of logging, so positions are only interpolated *within* a segment, never across
//! the gap where reception was lost or the logger was off.
use quick_xml::events::Event;
use crate::{DateTime, Gps};
/// Nesting / size limits (a track log is flat; these only stop hostile input).
const MAX_DEPTH: usize = 64;
const MAX_POINTS: usize = 5_000_000;
/// One timed point of a track.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TrackPoint {
/// Seconds since 1970-01-01T00:00:00Z (fractional when the log has sub-second times).
pub time: f64,
pub latitude: f64,
pub longitude: f64,
/// Metres above sea level.
pub elevation: Option<f64>,
/// Track segment number, counted across the whole file (continuous logging spans).
pub segment: u32,
}
/// How a position was found by [`Tracklog::locate`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Match {
/// The time falls between two points of the same segment: linear interpolation.
Interpolated,
/// The nearest point (at a segment's start or end, or across a gap).
Nearest,
}
/// The timed points of a GPX file, sorted by time.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Tracklog {
pub points: Vec<TrackPoint>,
/// Tracks (`<trk>`) in the file.
pub tracks: usize,
/// Track points without a usable time (left out of `points`).
pub untimed: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub enum GpxError {
Xml(String),
/// Not a GPX document (no `<gpx>` root).
NotGpx,
/// Nesting / size limits exceeded.
TooComplex,
}
impl std::fmt::Display for GpxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GpxError::Xml(e) => write!(f, "malformed GPX: {e}"),
GpxError::NotGpx => f.write_str("not a GPX file (no <gpx> element)"),
GpxError::TooComplex => f.write_str("GPX file too large or too deeply nested"),
}
}
}
impl std::error::Error for GpxError {}
/// Local name of a possibly prefixed XML name (`gpx:trkpt` → `trkpt`).
fn local(name: &[u8]) -> &[u8] {
name.rsplit(|&b| b == b':').next().unwrap_or(name)
}
/// An `xsd:dateTime` as seconds since the Unix epoch; no zone = UTC (the GPX schema's convention).
fn parse_time(s: &str) -> Option<f64> {
let mut d = DateTime::parse_iso(s)?;
// a bare date (no time of day) is too coarse to place a photo
if !s.contains('T') {
return None;
}
d.offset_minutes.get_or_insert(0);
Some(d.unix_seconds() as f64 + d.millis as f64 / 1000.0)
}
#[derive(Default)]
struct Pending {
lat: Option<f64>,
lon: Option<f64>,
ele: Option<f64>,
time: Option<f64>,
}
/// Parse a GPX document.
pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
let mut reader = quick_xml::Reader::from_str(s);
reader.config_mut().check_end_names = false;
let mut path: Vec<Vec<u8>> = Vec::new();
let mut seen_gpx = false;
let mut log = Tracklog::default();
let mut segment: u32 = 0;
let mut pt: Option<Pending> = None;
let mut text = String::new();
loop {
let ev = reader.read_event().map_err(|e| GpxError::Xml(e.to_string()))?;
match ev {
Event::Start(ref e) | Event::Empty(ref e) => {
let name = local(e.name().as_ref()).to_vec();
let parent = path.last().map(Vec::as_slice);
match name.as_slice() {
b"gpx" if path.is_empty() => seen_gpx = true,
b"trk" if parent == Some(b"gpx") => log.tracks += 1,
b"trkseg" if parent == Some(b"trk") => {
if log.points.last().is_some_and(|p| p.segment == segment) {
segment += 1;
}
}
b"trkpt" if parent == Some(b"trkseg") => {
let mut p = Pending::default();
for a in e.attributes().with_checks(false).flatten() {
let v = String::from_utf8_lossy(&a.value);
match local(a.key.as_ref()) {
b"lat" => p.lat = v.trim().parse().ok(),
b"lon" => p.lon = v.trim().parse().ok(),
_ => {}
}
}
pt = Some(p);
}
_ => {}
}
text.clear();
if matches!(ev, Event::Start(_)) {
if path.len() >= MAX_DEPTH {
return Err(GpxError::TooComplex);
}
path.push(name);
} else if name == b"trkpt" {
finish_point(&mut log, pt.take(), segment)?;
}
}
Event::Text(t) => text.push_str(&t.decode().map_err(|e| GpxError::Xml(e.to_string()))?),
Event::CData(t) => text.push_str(&String::from_utf8_lossy(&t)),
Event::End(_) => {
let Some(name) = path.pop() else { continue };
let parent = path.last().map(Vec::as_slice);
match name.as_slice() {
b"ele" if parent == Some(b"trkpt") => {
if let Some(p) = pt.as_mut() {
p.ele = text.trim().parse().ok();
}
}
b"time" if parent == Some(b"trkpt") => {
if let Some(p) = pt.as_mut() {
p.time = parse_time(text.trim());
}
}
b"trkpt" => finish_point(&mut log, pt.take(), segment)?,
_ => {}
}
text.clear();
}
Event::Eof => break,
_ => {}
}
}
if !seen_gpx {
return Err(GpxError::NotGpx);
}
// stable: points logged at the same second keep their file order
log.points.sort_by(|a, b| a.time.total_cmp(&b.time));
Ok(log)
}
fn finish_point(log: &mut Tracklog, p: Option<Pending>, segment: u32) -> Result<(), GpxError> {
let Some(p) = p else { return Ok(()) };
let (Some(lat), Some(lon)) = (p.lat, p.lon) else { return Ok(()) };
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
return Ok(());
}
let Some(time) = p.time else {
log.untimed += 1;
return Ok(());
};
if log.points.len() >= MAX_POINTS {
return Err(GpxError::TooComplex);
}
log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation: p.ele, segment });
Ok(())
}
impl Tracklog {
/// First and last point times (seconds since the epoch, UTC).
pub fn span(&self) -> Option<(f64, f64)> {
Some((self.points.first()?.time, self.points.last()?.time))
}
/// Where the logger was at `time` (seconds since the epoch, UTC).
///
/// Between two points of the same segment that are at most `max_gap` seconds apart, the position
/// is interpolated linearly in time. Otherwise the nearest point counts if it is within `max_gap`
/// seconds; beyond that there is no position (the photo was taken while nothing was logged).
pub fn locate(&self, time: f64, max_gap: f64) -> Option<(Gps, Match)> {
let pts = &self.points;
if pts.is_empty() || !time.is_finite() {
return None;
}
let i = pts.partition_point(|p| p.time < time);
let next = pts.get(i);
let prev = i.checked_sub(1).and_then(|j| pts.get(j));
if let Some(n) = next
&& n.time == time
{
return Some((gps(n), Match::Interpolated));
}
if let (Some(a), Some(b)) = (prev, next)
&& a.segment == b.segment
&& b.time - a.time <= max_gap
{
let t = (time - a.time) / (b.time - a.time);
return Some((lerp(a, b, t), Match::Interpolated));
}
let near = match (prev, next) {
(Some(a), Some(b)) => {
if time - a.time <= b.time - time {
a
} else {
b
}
}
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => return None,
};
((near.time - time).abs() <= max_gap).then(|| (gps(near), Match::Nearest))
}
}
fn gps(p: &TrackPoint) -> Gps {
Gps { latitude: p.latitude, longitude: p.longitude, altitude: p.elevation }
}
/// Linear interpolation, taking the short way across the ±180° meridian.
fn lerp(a: &TrackPoint, b: &TrackPoint, t: f64) -> Gps {
let mut dlon = b.longitude - a.longitude;
if dlon > 180.0 {
dlon -= 360.0;
} else if dlon < -180.0 {
dlon += 360.0;
}
let mut lon = a.longitude + dlon * t;
if lon > 180.0 {
lon -= 360.0;
} else if lon < -180.0 {
lon += 360.0;
}
let altitude = match (a.elevation, b.elevation) {
(Some(x), Some(y)) => Some(x + (y - x) * t),
(x, y) => x.or(y),
};
Gps { latitude: a.latitude + (b.latitude - a.latitude) * t, longitude: lon, altitude }
}
#[cfg(test)]
mod tests {
use super::*;
const LOG: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="1.0" lon="1.0"><time>2026-05-01T10:00:30Z</time></wpt>
<trk><name>Walk</name>
<trkseg>
<trkpt lat="46.0000" lon="7.0000"><ele>1600</ele><time>2026-05-01T10:00:00Z</time></trkpt>
<trkpt lat="46.0010" lon="7.0020"><ele>1610</ele><time>2026-05-01T10:01:00Z</time></trkpt>
<trkpt lat="46.0020" lon="7.0040"><time>2026-05-01T10:02:00.500Z</time></trkpt>
<trkpt lat="46.5" lon="7.5"/>
</trkseg>
<trkseg>
<trkpt lat="47.0000" lon="8.0000"><time>2026-05-01T12:00:00Z</time></trkpt>
<trkpt lat="47.0010" lon="8.0010"><time>2026-05-01T12:00:10+00:00</time></trkpt>
</trkseg>
</trk>
</gpx>"#;
fn t(s: &str) -> f64 {
parse_time(s).unwrap()
}
#[test]
fn parses_timed_track_points() {
let log = parse_gpx(LOG).unwrap();
assert_eq!(log.tracks, 1);
assert_eq!(log.points.len(), 5, "waypoints and the untimed point are left out");
assert_eq!(log.untimed, 1);
assert_eq!(log.points[0].elevation, Some(1600.0));
assert_eq!(log.points[2].time, t("2026-05-01T10:02:00Z") + 0.5);
assert_eq!((log.points[0].segment, log.points[2].segment, log.points[3].segment), (0, 0, 1));
assert_eq!(log.span(), Some((t("2026-05-01T10:00:00Z"), t("2026-05-01T12:00:10Z"))));
// 2026-05-01T10:00:00Z
assert_eq!(log.points[0].time, 1_777_629_600.0);
}
#[test]
fn interpolates_within_a_segment() {
let log = parse_gpx(LOG).unwrap();
let (g, m) = log.locate(t("2026-05-01T10:00:30Z"), 300.0).unwrap();
assert_eq!(m, Match::Interpolated);
assert!((g.latitude - 46.0005).abs() < 1e-9 && (g.longitude - 7.0010).abs() < 1e-9, "{g:?}");
assert_eq!(g.altitude, Some(1605.0));
// exactly on a point
let (g, _) = log.locate(t("2026-05-01T10:01:00Z"), 300.0).unwrap();
assert_eq!((g.latitude, g.longitude), (46.0010, 7.0020));
}
#[test]
fn never_interpolates_across_segments() {
let log = parse_gpx(LOG).unwrap();
// between the segments (10:02 … 12:00): only near either end
let (g, m) = log.locate(t("2026-05-01T10:04:00Z"), 300.0).unwrap();
assert_eq!((m, g.latitude), (Match::Nearest, 46.0020));
let (g, m) = log.locate(t("2026-05-01T11:58:00Z"), 300.0).unwrap();
assert_eq!((m, g.latitude), (Match::Nearest, 47.0));
assert!(log.locate(t("2026-05-01T11:00:00Z"), 300.0).is_none(), "an hour from any point");
// before the first and after the last point
assert!(log.locate(t("2026-05-01T09:59:00Z"), 300.0).is_some());
assert!(log.locate(t("2026-05-01T09:50:00Z"), 300.0).is_none());
assert!(log.locate(t("2026-05-01T12:10:00Z"), 300.0).is_none());
}
#[test]
fn max_gap_limits_interpolation() {
let log = parse_gpx(LOG).unwrap();
// the points are 60 s apart: with a 20 s limit, only the nearest point within 20 s counts
assert!(log.locate(t("2026-05-01T10:00:30Z"), 20.0).is_none());
let (g, m) = log.locate(t("2026-05-01T10:00:50Z"), 20.0).unwrap();
assert_eq!((m, g.latitude), (Match::Nearest, 46.0010));
}
#[test]
fn gpx_1_0_prefixes_and_zones() {
let s = r#"<gpx:gpx version="1.0" xmlns:gpx="http://www.topografix.com/GPX/1/0"><gpx:trk><gpx:trkseg>
<gpx:trkpt lat="-33.9" lon="151.2"><gpx:time>2026-01-02T10:00:00+11:00</gpx:time></gpx:trkpt>
<gpx:trkpt lat="-33.8" lon="151.3"><gpx:time>2026-01-01T23:01:00</gpx:time></gpx:trkpt>
</gpx:trkseg></gpx:trk></gpx:gpx>"#;
let log = parse_gpx(s).unwrap();
assert_eq!(log.points.len(), 2);
// +11:00 is converted to UTC; no zone = UTC
assert_eq!(log.points[1].time - log.points[0].time, 60.0);
}
#[test]
fn interpolation_across_the_antimeridian() {
let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 };
let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0 };
let g = lerp(&a, &b, 0.75);
assert!((g.longitude - -179.5).abs() < 1e-9, "{g:?}");
}
#[test]
fn rejects_non_gpx_and_bad_input() {
assert_eq!(parse_gpx("<kml><Document/></kml>"), Err(GpxError::NotGpx));
assert!(parse_gpx("").is_err());
assert!(
parse_gpx("<gpx><trk><trkseg><trkpt lat=\"x\" lon=\"1\"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>")
.unwrap()
.points
.is_empty()
);
let deep = "<gpx>".to_string() + &"<a>".repeat(100);
assert_eq!(parse_gpx(&deep), Err(GpxError::TooComplex));
}
}
+3
View File
@@ -6,12 +6,14 @@
//! - [`embedded`] / [`jpeg_segments`] / [`png_chunks`] / [`webp_chunks`] — locate Exif / XMP / ICC / IPTC blocks.
//! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON).
//! - [`parse_iptc`] — IPTC-IIM record 2 datasets.
//! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time.
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw).
#![forbid(unsafe_code)]
mod container;
mod datetime;
mod exif;
mod gpx;
mod iptc;
pub mod tags;
mod xmp;
@@ -19,6 +21,7 @@ mod xmp;
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
pub use iptc::parse_iptc;
pub use lightcraft_geom::Orientation;
pub use tags::{TagRow, file_tag_rows, tag_rows};
+2
View File
@@ -63,6 +63,8 @@ pub struct Services {
pub pick_files: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
pub pick_preset_files: Option<PickFiles>,
/// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…).
pub pick_tracklog: Option<PickFiles>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
/// Open dialog for point-curve preset files (`.lccurve`).
+35
View File
@@ -178,6 +178,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"app.showInFinder",
"dialog.rename",
"dialog.captureTime",
"photo.tagFromTracklog",
"---",
"photo.delete",
],
@@ -929,6 +930,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auto_tag_from_tracklog() {
let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let gpx = dir.join("walk.gpx");
std::fs::write(
&gpx,
r#"<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="46.0" lon="7.0"><time>2026-05-01T10:00:00Z</time></trkpt>
<trkpt lat="46.002" lon="7.004"><time>2026-05-01T10:02:00Z</time></trkpt>
</trkseg></trk></gpx>"#,
)
.unwrap();
let mut app = app();
let id = app.session.visible()[0].0;
app.session.execute("library.select", &json!({"ids": [id]})).unwrap();
app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap();
app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap();
// in the Photo menu; without a file dialog (headless) it is disabled
let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1;
let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") };
assert!(!enabled);
// a file without an offset asks for the camera's time zone; confirming runs the tagging
run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap();
let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") };
*value = " +02:00 ".into();
let d = app.ui.dialog.take().unwrap();
let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap();
assert_eq!(r["tagged"], 1, "{r}");
let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap();
assert_eq!(p.meta.gps, Some((46.001, 7.002)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn metadata_preset_menu() {
let mut app = app();
+43
View File
@@ -140,6 +140,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
("dialog.labelNames", "Edit Color Label Names…", None, ""),
("dialog.captureTime", "Edit Capture Time…", None, "Photo"),
("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"),
("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"),
];
@@ -916,6 +917,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
r
}
"photo.tagFromTracklog" => {
// a GPX file → GPS for the selected photos by capture time (one undo step)
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => Some(x.to_string()),
None => app.services.pick_tracklog.as_mut().and_then(|f| f().into_iter().next()),
};
let Some(path) = path else { return Some(Ok(Value::Null)) };
let mut params = p.as_object().cloned().unwrap_or_default();
params.insert("path".into(), json!(path));
let ask_zone = !params.contains_key("offset");
let params = Value::Object(params);
if ask_zone {
// GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here
crate::panels::dialogs::prompt(
app,
"Auto-Tag from Tracklog",
"Camera time zone, e.g. -07:00 (empty: UTC)",
"",
"photo.tagFromTracklog",
params,
"offset",
);
return Some(Ok(Value::Null));
}
let r = app.run("photo.autoTagTracklog", params);
if let Ok(v) = &r {
let n = v["tagged"].as_u64().unwrap_or(0);
let sk = &v["skipped"];
let mut msg = format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" });
let outside = sk["outside"].as_u64().unwrap_or(0);
if outside > 0 {
msg += &format!("; {outside} outside its time range");
}
let kept = sk["hasGps"].as_u64().unwrap_or(0);
if kept > 0 {
msg += &format!("; {kept} already had a location");
}
app.toast(&egui::Context::default(), msg);
}
r
}
"photo.locate" => {
let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };
let path = match p.get("path").and_then(Value::as_str) {
@@ -1111,6 +1153,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => {
app.session.active().is_some()
}
"photo.tagFromTracklog" => app.session.active().is_some() && app.services.pick_tracklog.is_some(),
"app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(),
"dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(),
"app.showInFinder" => {
+4 -4
View File
@@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| X. Cross-cutting behaviours (BEHAV) | 15 | 1 | 2 | 1 | 8/8 (100%) | 6/6 (100%) |
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
| Lightroom Classic extras | 29 | 9 | 41 | 9 | — | 20/21 (95%) |
| **Total** | 384 | 25 | 92 | 37 | 194/198 (98%) | 137/145 (94%) |
| Lightroom Classic extras | 30 | 9 | 40 | 9 | — | 20/21 (95%) |
| **Total** | 385 | 25 | 91 | 37 | 194/198 (98%) | 137/145 (94%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 501 in-scope rows — P0 99.0% of 198 · P1 96.2% of 145 · P2 38.6% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 501 in-scope rows — P0 99.0% of 198 · P1 96.2% of 145 · P2 39.2% of 158.
<!-- /parity:summary -->
## Top gaps
@@ -726,7 +726,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-MAP-VIEW | Map view | P2 | ⬜ | | |
| LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | |
| LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | |
| LRC-MAP-TRACKLOG | GPS track logs | P2 | ⬜ | | |
| LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | `cmd:photo.autoTagTracklog`, `cmd:photo.tagFromTracklog`, `crates/meta/src/gpx.rs` | Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within `maxGap` (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless `replace`; `dryRun` previews; one undo step. No track drawn on a map (no Map module) |
| LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | |
| LRC-MAP-REVGEO | Reverse geocoding | OOS | 🚫 | | |
| LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | |
+1
View File
@@ -17,6 +17,7 @@
- Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.
- Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.
- Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.
- Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.
### Editing
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.