gpx-from-gopro 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ No install needed:
20
20
 
21
21
  ```sh
22
22
  npx gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
23
- [--organize DIR] [--yes] [--mode core|ski]
23
+ [--organize DIR] [--yes] [--mode core|ski] [--no-gpx]
24
24
  [--html] [--png [--width N] [--height N]]
25
25
  ```
26
26
 
@@ -34,7 +34,9 @@ split for restarts/dropouts). A per-file extraction cache (keyed by size+mtime+r
34
34
  run resume without re-extracting. `--rate HZ` downsamples from the native ~18 Hz; `--tz HOURS`
35
35
  overrides the longitude-guessed local date. `--mode core|ski` runs each session through
36
36
  [`gpx-stabilizer`](https://www.npmjs.com/package/gpx-stabilizer)'s `stabilizeTrack` before writing
37
- (omit for the raw extraction).
37
+ (omit for the raw extraction). `--no-gpx` skips writing the merged `.gpx` entirely — extraction,
38
+ caching, and (with `--organize`) reorganizing the source videos still happen normally; `--html`/
39
+ `--png` are unaffected either way (both render from the raw extracted points, not the `.gpx` file).
38
40
 
39
41
  ### `--organize DIR` — reorganize the source videos to match the GPX
40
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gpx-from-gopro",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Extract a GoPro video's GPS telemetry as GPX or render-agnostic TrackPoints (points + meta + timezone + UTC anchor).",
5
5
  "keywords": [
6
6
  "gopro",
@@ -32,7 +32,7 @@
32
32
  "directory": "packages/gopro"
33
33
  },
34
34
  "dependencies": {
35
- "gpx-stabilizer": "^0.7.0",
35
+ "gpx-stabilizer": "^0.9.0",
36
36
  "egm96-universal": "^1.1.1",
37
37
  "gopro-telemetry": "^1.2.11",
38
38
  "gpmf-extract": "^0.3.3",
@@ -8,15 +8,17 @@ import { basename, join, resolve } from "node:path";
8
8
 
9
9
  // Cache schema version: bump whenever the cached record's shape or the
10
10
  // extraction output changes, so stale records read as a miss and re-extract.
11
+ // 4: `meta` also carries `createdUtc` (moov `mvhd.creation_time`) — regressStartUtc's
12
+ // disambiguating reference (telemetry.js), needed on every extraction so bumped
13
+ // rather than left graceful (an old v3 record's absent `createdUtc` would silently
14
+ // fall back to the far-less-precise file mtime instead, on every file until it
15
+ // happens to change).
11
16
  // 3: points carry `cts` (media offset); the record is { meta, points, streams },
12
17
  // where `streams` holds every non-GPS GPMF channel (IMU/scene/exposure/…) as raw
13
18
  // cts samples and `meta` also carries camera fields (`model`/`firmware`/`serial`/
14
- // `mediaId`/`highlights`, from the moov udta). These additions are
15
- // ADDITIVE — an older v3 record without them reads back as `streams: {}` / absent
16
- // fields (graceful), so while the multi-sensor format is still iterated we clear
17
- // stale records by hand instead of bumping the version; bump before shipping.
19
+ // `mediaId`/`highlights`, from the moov udta).
18
20
  // (v2 was { hasGps, meta, points }, pre-cts.)
19
- export const CACHE_V = 3;
21
+ export const CACHE_V = 4;
20
22
 
21
23
  /**
22
24
  * Cache-record path for a source file: sidecar `<file>.gpxcache.json` by
package/src/gopro-cli.js CHANGED
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // gpx-from-gopro — extract GoPro GPS into merged GPX, one file per camera per local date.
3
3
  // gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
4
- // [--organize DIR] [--yes] [--mode core|ski]
4
+ // [--organize DIR] [--yes] [--mode core|ski] [--no-gpx]
5
5
  // [--html] [--png [--width N] [--height N]]
6
6
  //
7
+ // - --no-gpx: extract + cache (and, with --organize, reorganize) as normal, but skip writing the
8
+ // merged .gpx to disk. `--organize`'s own gpx-move step already no-ops cleanly when the source
9
+ // .gpx was never written (organize.js's executeMove checks existsSync(g.from) first), so the two
10
+ // flags combine with no special-casing needed. --html/--png are unaffected either way — both
11
+ // render straight from the RAW extracted points (see below), never from the written .gpx file.
12
+ //
7
13
  // - --mode core|ski: runs the merged points through gpx-stabilizer's own `stabilizeTrack` (per
8
14
  // recording session — see the `--out` loop below) before writing, instead of shipping today's
9
15
  // default raw extraction. --html/--png (below) render under the SAME mode when both are given,
@@ -69,10 +75,10 @@ const WITH_VALUE = new Set([
69
75
  "height",
70
76
  "mode",
71
77
  ]);
72
- const KNOWN_BOOL = new Set(["no-cache", "html", "png", "yes"]);
78
+ const KNOWN_BOOL = new Set(["no-cache", "html", "png", "yes", "no-gpx"]);
73
79
  const USAGE =
74
80
  "usage: gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ]" +
75
- " [--cache-dir DIR | --no-cache] [--organize DIR] [--yes] [--mode core|ski]" +
81
+ " [--cache-dir DIR | --no-cache] [--organize DIR] [--yes] [--mode core|ski] [--no-gpx]" +
76
82
  " [--html] [--png [--width N] [--height N]]";
77
83
  const inputs = [];
78
84
  const opts = {};
@@ -250,15 +256,20 @@ for (const g of groups) {
250
256
  type: null,
251
257
  },
252
258
  };
253
- const path = join(outDir, `${g.name}.gpx`);
254
- saveGpx(track, path, { creator: "gpx-from-gopro" });
255
259
  const npts = tracks.reduce((s, t) => s + t.segments.reduce((s2, seg) => s2 + seg.length, 0), 0);
256
260
  const nseg = tracks.reduce((s, t) => s + t.segments.length, 0);
257
- written.push(`${g.name}.gpx (${npts} pts, ${tracks.length} trk, ${nseg} seg)`);
261
+ const stats = `${npts} pts, ${tracks.length} trk, ${nseg} seg`;
262
+ if (opts["no-gpx"]) {
263
+ written.push(`[skipped] ${g.name}: gpx not written (--no-gpx), ${stats}`);
264
+ } else {
265
+ const path = join(outDir, `${g.name}.gpx`);
266
+ saveGpx(track, path, { creator: "gpx-from-gopro" });
267
+ written.push(`${path} (${stats})`);
268
+ }
258
269
  }
259
270
 
260
271
  console.log(`\ndone. processed=${ok} skipped=${skipped} failed=${failed}`);
261
- for (const w of written) console.log(` -> ${join(outDir, w)}`);
272
+ for (const w of written) console.log(` -> ${w}`);
262
273
 
263
274
  // ---- --html / --png: eval visualization of each group's merged track, additive to the .gpx above ----
264
275
  // (analyzedLayers/analyzedSvg run core's noise-removal pipeline over the flattened group, under the
@@ -307,7 +318,8 @@ async function promptLine(question) {
307
318
  }
308
319
 
309
320
  if (opts.organize) {
310
- const includeGpx = !outExplicit; // --out was explicit -> leave the .gpx where the user put it
321
+ // --out explicit -> leave the .gpx where the user put it; --no-gpx -> there is no .gpx at all
322
+ const includeGpx = !outExplicit && !opts["no-gpx"];
311
323
  const plan = planMove(entries, { root: opts.organize, outDir, includeGpx });
312
324
  if (plan.files.length === 0) {
313
325
  console.log("\n--organize: nothing to move (no successfully-extracted videos).");
package/src/gopro.js CHANGED
@@ -116,6 +116,12 @@ function parseHighlights(buf) {
116
116
  * @property {string | null} model camera model from the firmware (e.g. "HERO5"), or null
117
117
  * @property {string | null} serial camera serial (udta `CAME`, hex) — tells two same-model bodies apart
118
118
  * @property {string | null} mediaId global media id (udta `GUMI`, hex) — links a recording's chapter files
119
+ * @property {number | null} createdUtc container creation time (moov `mvhd.creation_time`),
120
+ * epoch ms, or null if mp4box didn't report one. Camera-written at record time, so — unlike
121
+ * file mtime — unaffected by a later copy/move; still the camera's own clock (may be off by
122
+ * a fixed local-vs-UTC labelling error, but never off by YEARS the way an unsynced GPS
123
+ * chip's firmware boot-time constant can be). Used as `regressStartUtc`'s disambiguating
124
+ * reference (telemetry.js).
119
125
  * @property {number[]} highlights user highlight-tag offsets in ms (udta `HMMT`); [] if none
120
126
  */
121
127
 
@@ -177,6 +183,7 @@ export async function probeGoproMeta(path) {
177
183
  model: goproModel(firmware),
178
184
  serial: came ? came.toString("hex") : null,
179
185
  mediaId: gumi ? gumi.toString("hex") : null,
186
+ createdUtc: info?.created instanceof Date ? info.created.getTime() : null,
180
187
  highlights: parseHighlights(readUdtaRaw(moov, "HMMT")),
181
188
  };
182
189
  } finally {
package/src/telemetry.js CHANGED
@@ -67,25 +67,97 @@ export function recordingStartUtc(points) {
67
67
  const MIN_REG_POINTS = 5; // too few points → slope is noise
68
68
  const MIN_REG_SPAN_MS = 5000; // <5 s of media offset → extrapolating to 0 is unreliable
69
69
  const SLOPE_TOL = 0.05; // |slope−1| must be within this (UTC ms vs media ms ⇒ ≈1)
70
+ const MIN_PAIR_SPAN_MS = 1000; // Theil-Sen: skip near-duplicate-cts pairs (unstable slope, tiny denominator)
71
+ const SYNC_RESIDUAL_TOL_MS = 2000; // after the robust fit, >2s off the line predates real time sync
72
+ const REFERENCE_WINDOW_MS = 30 * 24 * 3600 * 1000; // 30 days either side of `referenceUtc`
70
73
 
71
74
  /**
72
- * True recording-start instant (UTC) by **linear regression** of each good-fix
73
- * sample's UTC `time` against its media offset `cts`: `time intercept +
74
- * slope·cts`. The recording starts at `cts = 0`, so the intercept is the
75
- * wall-clock instant of the first video frame *before* GPS lock, recovering the
76
- * pre-lock delay that the first-good-fix anchor ignores. `slope` should be ≈ 1
77
- * (both axes are milliseconds); a slope far from 1 means the GPS UTC and media
78
- * clocks disagree, so the extrapolation is not trustworthy.
75
+ * Robust slope estimate: the MEDIAN of pairwise slopes over a grid-sampled subset
76
+ * (Theil-Sen) unlike OLS, a minority of wildly-off points can't drag the median,
77
+ * because their slopes land at the extremes of the sorted list, not the middle.
78
+ * O(gridN²) pairs; gridN caps at 200 so even a few thousand points stay cheap.
79
+ * Returns null if every sampled pair fails the `MIN_PAIR_SPAN_MS` separation.
80
+ */
81
+ function theilSenSlope(pts) {
82
+ const n = pts.length;
83
+ const gridN = Math.min(n, 200);
84
+ const step = Math.max(1, Math.floor(n / gridN));
85
+ const idxs = [];
86
+ for (let i = 0; i < n; i += step) idxs.push(i);
87
+ const slopes = [];
88
+ for (let i = 0; i < idxs.length; i++) {
89
+ for (let j = i + 1; j < idxs.length; j++) {
90
+ const a = pts[idxs[i]];
91
+ const b = pts[idxs[j]];
92
+ const dx = b.cts - a.cts;
93
+ if (Math.abs(dx) < MIN_PAIR_SPAN_MS) continue;
94
+ slopes.push((b.time - a.time) / dx);
95
+ }
96
+ }
97
+ if (!slopes.length) return null;
98
+ slopes.sort((x, y) => x - y);
99
+ return slopes[Math.floor(slopes.length / 2)];
100
+ }
101
+
102
+ /**
103
+ * True recording-start instant (UTC) by **linear regression** of each sample's
104
+ * UTC `time` against its media offset `cts`: `time ≈ intercept + slope·cts`. The
105
+ * recording starts at `cts = 0`, so the intercept is the wall-clock instant of
106
+ * the first video frame — *before* GPS lock, recovering the pre-lock delay that
107
+ * the first-good-fix anchor ignores. `slope` should be ≈ 1 (both axes are
108
+ * milliseconds); a slope far from 1 means the GPS UTC and media clocks disagree,
109
+ * so the extrapolation is not trustworthy.
110
+ *
111
+ * Deliberately **not** gated on `fix` or position (unlike {@link firstGoodFix}):
112
+ * some chips (observed on a HERO10) sync UTC `time` well before — or entirely
113
+ * independently of — ever reporting a `2d`/`3d` fix, so a `time`+`cts` pair can
114
+ * already be trustworthy while `fix` stays `"none"` and `lat`/`lon` stay stuck at
115
+ * this format's (0,0) "no sample yet" sentinel for the WHOLE clip (a real
116
+ * HERO10 track: 5448 samples, zero ever left (0,0), yet the back four-fifths sync
117
+ * to a slope-1 line). Position and time sync are independent signals — gating the
118
+ * regression on either misses a valid clock.
119
+ *
120
+ * What position-based gating used to catch, this catches differently: a chip
121
+ * commonly emits a PRE-SYNC run of samples (a firmware boot-time constant,
122
+ * unrelated to `cts`) before its real UTC sync kicks in — sometimes as most of
123
+ * the clip, not a minority (observed: an 80/20 split, junk majority). Junk of
124
+ * this shape isn't just scattered noise, either: it's often internally
125
+ * consistent (its OWN slope is also ≈1, e.g. a free-running RTC that just never
126
+ * got corrected) with the WRONG intercept, so a slope check alone can't tell the
127
+ * two clusters apart — whichever has more points wins a plain majority vote,
128
+ * right or wrong. `referenceUtc` (the file's own mtime, or any other rough
129
+ * "roughly when this was recorded" anchor — accurate to a day is plenty) breaks
130
+ * the tie: real satellite time and a firmware boot constant are NEVER
131
+ * confusable at that resolution (a boot constant sits years off), so points
132
+ * more than `REFERENCE_WINDOW_MS` from it are dropped before anything else runs,
133
+ * regardless of which cluster is bigger. Without a `referenceUtc`, this can only
134
+ * fall back to catching a scattered-noise minority via {@link theilSenSlope}'s
135
+ * pairwise-slope median (immune to a minority, not a majority) — pass one
136
+ * whenever it's available.
79
137
  *
80
- * @param {import("gpx-stabilizer").TrackPoint[]} points raw points (need `cts` + `time` + `fix`)
138
+ * @param {import("gpx-stabilizer").TrackPoint[]} points raw points (need `cts` + `time`)
139
+ * @param {{ referenceUtc?: number }} [opts] `referenceUtc`: epoch ms roughly near
140
+ * the true recording time (e.g. file mtime) — disambiguates a majority-junk
141
+ * cluster from the real one; omit only when no such anchor exists at all.
81
142
  * @returns {{ startUtc: number, slope: number, n: number } | null} null when too
82
143
  * few points, too short a media span, or `cts` is unavailable
83
144
  */
84
- export function regressStartUtc(points) {
85
- const pts = (points ?? []).filter(
86
- (p) => p && (p.fix === "3d" || p.fix === "2d") && isFiniteNum(p.time) && isFiniteNum(p.cts),
145
+ export function regressStartUtc(points, { referenceUtc } = {}) {
146
+ let pts = (points ?? []).filter((p) => p && isFiniteNum(p.time) && isFiniteNum(p.cts));
147
+ if (isFiniteNum(referenceUtc)) {
148
+ pts = pts.filter((p) => Math.abs(p.time - referenceUtc) <= REFERENCE_WINDOW_MS);
149
+ }
150
+ if (pts.length < MIN_REG_POINTS) return null;
151
+
152
+ const robustSlope = theilSenSlope(pts);
153
+ if (robustSlope == null) return null;
154
+ const intercepts = pts.map((p) => p.time - robustSlope * p.cts).sort((a, b) => a - b);
155
+ const robustIntercept = intercepts[Math.floor(intercepts.length / 2)];
156
+ const survivors = pts.filter(
157
+ (p) => Math.abs(p.time - (robustIntercept + robustSlope * p.cts)) < SYNC_RESIDUAL_TOL_MS,
87
158
  );
88
- const n = pts.length;
159
+
160
+ const n = survivors.length;
89
161
  if (n < MIN_REG_POINTS) return null;
90
162
  // Centre x (cts) and y (time) before the least-squares sums: UTC ms (~1.7e12)
91
163
  // and cts ms make the raw normal equations lose precision to catastrophic
@@ -95,7 +167,7 @@ export function regressStartUtc(points) {
95
167
  let sy = 0;
96
168
  let minC = Number.POSITIVE_INFINITY;
97
169
  let maxC = Number.NEGATIVE_INFINITY;
98
- for (const p of pts) {
170
+ for (const p of survivors) {
99
171
  sx += p.cts;
100
172
  sy += p.time;
101
173
  if (p.cts < minC) minC = p.cts;
@@ -106,7 +178,7 @@ export function regressStartUtc(points) {
106
178
  const my = sy / n;
107
179
  let sxx = 0;
108
180
  let sxy = 0;
109
- for (const p of pts) {
181
+ for (const p of survivors) {
110
182
  const dx = p.cts - mx;
111
183
  sxx += dx * dx;
112
184
  sxy += dx * (p.time - my);
@@ -122,10 +194,11 @@ export function regressStartUtc(points) {
122
194
  * true-start when its slope passes the quality gate; otherwise falls back to the
123
195
  * first good fix (the pre-lock delay is then unknown and left in).
124
196
  * @param {import("gpx-stabilizer").TrackPoint[]} points raw points
197
+ * @param {{ referenceUtc?: number }} [opts] forwarded to {@link regressStartUtc}
125
198
  * @returns {{ startUtc: number | null, confidence: 'gps' | null, verified: boolean, slope: number | null }}
126
199
  */
127
- export function resolveStartUtc(points) {
128
- const reg = regressStartUtc(points);
200
+ export function resolveStartUtc(points, opts) {
201
+ const reg = regressStartUtc(points, opts);
129
202
  const verified = reg != null && Math.abs(reg.slope - 1) <= SLOPE_TOL;
130
203
  const startUtc = verified ? reg.startUtc : recordingStartUtc(points).startUtc;
131
204
  return {
@@ -241,7 +314,20 @@ export async function readGoproTelemetry(path, opts = {}) {
241
314
  // {lat,lon,ele,time}, dropping the `fix` + `cts` that good-fix selection and the
242
315
  // start regression rely on.
243
316
  const timezone = timezoneOfPoints(raw);
244
- const clock = resolveStartUtc(raw);
317
+ // regressStartUtc's disambiguating reference (its own doc): prefer the camera's
318
+ // own container creation time (meta.createdUtc, moov mvhd — set once at record
319
+ // time) over file mtime, which drifts whenever the file is later copied/moved
320
+ // (observed: over a MONTH off on a real clip, well past a plausible window) —
321
+ // mtime is only a fallback for the rare file whose moov didn't yield one.
322
+ let referenceUtc = meta.createdUtc;
323
+ if (referenceUtc == null) {
324
+ try {
325
+ referenceUtc = statSync(path).mtimeMs;
326
+ } catch {
327
+ /* no reference at all — regressStartUtc falls back to unanchored (minority-only) robustness */
328
+ }
329
+ }
330
+ const clock = resolveStartUtc(raw, { referenceUtc });
245
331
 
246
332
  // resample runs on cleaned survivors, so it IMPLIES stabilize; an explicit
247
333
  // `stabilize: false` alongside `resample` is contradictory.