mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
11 files changed
+636
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 | ⬜ | | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user