gpx-from-gopro 0.1.0 → 0.3.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
@@ -16,13 +16,20 @@ npm install gpx-from-gopro
16
16
 
17
17
  ## CLI
18
18
 
19
+ No install needed:
20
+
19
21
  ```sh
20
- gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
22
+ npx gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
21
23
  ```
22
24
 
23
- Recurses directories for video files, groups by camera family + local date, and writes one merged
24
- `<YYYYMMDD>-<family>.gpx` per group. A per-file extraction cache (keyed by size+mtime+rate) lets a
25
- killed run resume without re-extracting. `--rate HZ` downsamples from the native ~18 Hz; `--tz HOURS`
25
+ Once installed (`npm install [-g] gpx-from-gopro`), drop the `npx` prefix and just run
26
+ `gpx-from-gopro ...`.
27
+
28
+ Recurses directories for video files, groups by camera body (serial, falling back to filename
29
+ family) + local date, and writes one merged `<YYYYMMDD>-<family>.gpx` per group — within it, points
30
+ split into one `<trkseg>` per recording session (keyed on the filename file-number, with a time-gap
31
+ split for restarts/dropouts). A per-file extraction cache (keyed by size+mtime+rate) lets a killed
32
+ run resume without re-extracting. `--rate HZ` downsamples from the native ~18 Hz; `--tz HOURS`
26
33
  overrides the longitude-guessed local date.
27
34
 
28
35
  ## Library — telemetry export
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gpx-from-gopro",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,8 @@
32
32
  "directory": "packages/gopro"
33
33
  },
34
34
  "dependencies": {
35
- "gpx-stabilizer": "^0.1.0",
35
+ "gpx-stabilizer": "^0.3.0",
36
+ "egm96-universal": "^1.1.1",
36
37
  "gopro-telemetry": "^1.2.11",
37
38
  "gpmf-extract": "^0.3.3",
38
39
  "mp4box": "^0.5.4",
@@ -6,6 +6,18 @@ import { createHash } from "node:crypto";
6
6
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { basename, join, resolve } from "node:path";
8
8
 
9
+ // Cache schema version: bump whenever the cached record's shape or the
10
+ // extraction output changes, so stale records read as a miss and re-extract.
11
+ // 3: points carry `cts` (media offset); the record is { meta, points, streams },
12
+ // where `streams` holds every non-GPS GPMF channel (IMU/scene/exposure/…) as raw
13
+ // 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.
18
+ // (v2 was { hasGps, meta, points }, pre-cts.)
19
+ export const CACHE_V = 3;
20
+
9
21
  /**
10
22
  * Cache-record path for a source file: sidecar `<file>.gpxcache.json` by
11
23
  * default, or a hashed name inside `cacheDir` (keeps the media tree clean).
@@ -21,6 +33,21 @@ export function cachePath(file, cacheDir = null) {
21
33
  return `${file}.gpxcache.json`;
22
34
  }
23
35
 
36
+ /**
37
+ * Resolve the opt-in `cache` option to a record path, or null when caching is
38
+ * off. Caching is ON by default: `undefined`/`true` → sidecar next to the
39
+ * source; `{ dir }` → a hashed name under `dir` (keeps the media tree clean);
40
+ * `false`/`null` → disabled.
41
+ * @param {string} file
42
+ * @param {boolean | { dir?: string | null } | null} [cache]
43
+ * @returns {string | null}
44
+ */
45
+ export function resolveCachePath(file, cache = true) {
46
+ if (cache === false || cache == null) return null;
47
+ const dir = typeof cache === "object" ? (cache.dir ?? null) : null;
48
+ return cachePath(file, dir);
49
+ }
50
+
24
51
  /**
25
52
  * Read a cache record, returning it only when it matches `ident`
26
53
  * (v + size + mtime + rate); missing/unreadable/stale all return null.
package/src/gopro-cli.js CHANGED
@@ -1,23 +1,28 @@
1
1
  #!/usr/bin/env node
2
- // gpx-from-gopro — extract GoPro GPS into merged GPX, grouped by camera family + local date.
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
4
  //
5
5
  // - Recurses directories for video files (mp4/mov/m4v/360); skips .LRV/.THM and ._ AppleDouble.
6
- // - Groups by (filename family, local date): GOPR/GP = old camera, GX/GH = new camera; a session's
7
- // first file (GOPR) and its continuation chapters (GP..) merge into one family.
6
+ // - Groups by (camera, local date): camera = the body serial (udta CAME) when known, so two
7
+ // same-model bodies shot on the same day stay separate; falls back to the filename family
8
+ // (GOPR/GP = old, GX/GH = new) for files without a serial. Crash-fragmented files still merge
9
+ // (same serial+date), so a session GoPro split across a crash is rejoined into the day's file.
10
+ // - Within a day's file, points split into one <trkseg> per recording session, keyed on the
11
+ // filename file-number (a recording's chapters share it), with a within-session time-gap
12
+ // split for restarts/dropouts: an uncrashed activity is one segment; a crash (new file-number)
13
+ // shows as a segment break, same file. (GUMI is per-chapter on some bodies, so unused here.)
8
14
  // - Local date: timezone from the median longitude of the first valid fixes (round(lon/15)),
9
15
  // snapped to the machine's local timezone when within 1 hour; override with --tz.
10
- // - One merged <YYYYMMDD>-<family>.gpx per group, written to --out (default ".").
16
+ // - One merged <YYYYMMDD>-<family>.gpx per group (a short serial suffix is added only when two
17
+ // cameras collide on the same family+date), written to --out (default ".").
11
18
  // - Tolerant: a file that fails to extract is logged and skipped, the run continues.
12
19
  // - Caches each file's extracted points (sidecar <file>.gpxcache.json by default, or --cache-dir),
13
20
  // keyed by size+mtime+rate+version, so a killed run resumes without re-extracting done files.
14
21
  import { mkdirSync, readdirSync, statSync } from "node:fs";
15
22
  import { basename, join } from "node:path";
16
23
  import { saveGpx } from "gpx-stabilizer";
17
- import { extractGoproPoints, probeGoproMeta } from "./gopro.js";
18
- import { cachePath, readCache, writeCache } from "./gopro-cache.js";
19
-
20
- const CACHE_V = 2; // bump when extraction output shape/logic changes (invalidates old caches)
24
+ import { buildGroups, family, fileNumber } from "./group.js";
25
+ import { readGoproSamples } from "./telemetry.js";
21
26
 
22
27
  const VIDEO_RE = /\.(mp4|mov|m4v|360)$/i;
23
28
  const MEDIAN_N = 30;
@@ -48,22 +53,11 @@ if (manualTZ != null && Number.isNaN(manualTZ)) {
48
53
  console.error(`invalid --tz: ${opts.tz}`);
49
54
  process.exit(1);
50
55
  }
51
- // --rate HZ -> group samples to that rate; omit for native (~18 Hz)
52
- const groupTimes = opts.rate ? Math.round(1000 / Number(opts.rate)) : undefined;
53
- // per-file extraction cache: sidecar next to source by default, or --cache-dir; --no-cache disables
54
- const cacheEnabled = !opts["no-cache"];
55
- const cacheDir = opts["cache-dir"] ?? null;
56
-
57
- // ---- camera family from filename ----
58
- function family(file) {
59
- const b = basename(file).toUpperCase();
60
- if (/^GOPR\d+\./.test(b)) return "GOPR";
61
- if (/^GP\d\d\d+\./.test(b)) return "GOPR";
62
- if (/^GX\d\d\d+\./.test(b)) return "GX";
63
- if (/^GH\d\d\d+\./.test(b)) return "GH";
64
- const m = b.match(/^([A-Z]+)/);
65
- return m ? m[1] : "MISC";
66
- }
56
+ // --rate HZ -> downsample to that rate; omit for native (~18 Hz)
57
+ const rate = opts.rate ? Number(opts.rate) : undefined;
58
+ // per-file extraction cache (on by default): sidecar next to source; --cache-dir
59
+ // redirects to a managed dir; --no-cache disables
60
+ const cache = opts["no-cache"] ? false : opts["cache-dir"] ? { dir: opts["cache-dir"] } : true;
67
61
 
68
62
  // ---- timezone from median longitude of the first valid fixes ----
69
63
  function medianLon(points) {
@@ -127,70 +121,43 @@ if (videos.length === 0) {
127
121
  }
128
122
  console.log(`found ${videos.length} video file(s)`);
129
123
 
130
- const groups = new Map(); // key "YYYYMMDD-FAMILY" -> { points, family, date }
124
+ const entries = []; // one per extracted file -> buildGroups
131
125
  let ok = 0;
132
126
  let skipped = 0;
133
127
  let failed = 0;
134
128
  for (const file of videos) {
135
- const ident = { v: CACHE_V, size: 0, mtime: 0, rate: groupTimes ?? null };
129
+ let meta;
130
+ let points;
131
+ let fromCache;
136
132
  try {
137
- const st = statSync(file);
138
- ident.size = st.size;
139
- ident.mtime = Math.round(st.mtimeMs);
133
+ ({ meta, points, fromCache } = await readGoproSamples(file, { rate, cache }));
140
134
  } catch (e) {
141
- console.error(` FAILED stat ${basename(file)}: ${e.message}`);
135
+ console.error(` FAILED ${basename(file)}: ${(e?.message ?? String(e)).split("\n")[0]}`);
142
136
  failed++;
143
137
  continue;
144
138
  }
145
- const cp = cacheEnabled ? cachePath(file, cacheDir) : null;
146
- const cached = cp ? readCache(cp, ident) : null;
147
-
148
- let points;
149
- if (cached) {
150
- if (!cached.hasGps) {
151
- console.error(` no GPS track, skip (cached): ${basename(file)}`);
152
- skipped++;
153
- continue;
154
- }
155
- points = cached.points;
156
- } else {
157
- // Cheap moov probe first: skip files with no GPMF GPS track without the
158
- // (slow) full extraction. Catches stitched products and non-GoPro videos.
159
- let meta;
160
- try {
161
- meta = await probeGoproMeta(file);
162
- } catch (e) {
163
- console.error(
164
- ` FAILED probe ${basename(file)}: ${(e?.message ?? String(e)).split("\n")[0]}`,
165
- );
166
- failed++;
167
- continue;
168
- }
169
- if (!meta.hasGps) {
170
- if (cp) writeCache(cp, { ...ident, hasGps: false, meta }, cacheDir);
171
- const dim = meta.width && meta.height ? `${meta.width}x${meta.height}` : "?";
172
- console.error(` no GPS track, skip: ${basename(file)} (${dim} ${meta.codec ?? "?"})`);
173
- skipped++;
174
- continue;
175
- }
176
- try {
177
- points = await extractGoproPoints(file, groupTimes ? { groupTimes } : {});
178
- } catch (e) {
179
- console.error(` FAILED ${basename(file)}: ${(e?.message ?? String(e)).split("\n")[0]}`);
180
- failed++;
181
- continue;
182
- }
183
- if (points.length === 0) {
184
- if (cp) writeCache(cp, { ...ident, hasGps: false, meta }, cacheDir);
185
- console.error(` no GPS, skip: ${basename(file)}`);
186
- skipped++;
187
- continue;
188
- }
189
- if (cp) writeCache(cp, { ...ident, hasGps: true, meta, points }, cacheDir);
139
+ // No GPMF GPS track (caught cheaply by the moov probe) or an extraction that
140
+ // yielded nothing skip either way.
141
+ if (!meta.hasGps || points.length === 0) {
142
+ const dim = meta.width && meta.height ? `${meta.width}x${meta.height}` : "?";
143
+ console.error(
144
+ ` no GPS track, skip${fromCache ? " (cached)" : ""}: ${basename(file)} (${dim} ${meta.codec ?? "?"})`,
145
+ );
146
+ skipped++;
147
+ continue;
190
148
  }
191
149
 
192
150
  const fam = family(file);
193
- const fix = points.find((p) => !(p.lat === 0 && p.lon === 0)) ?? points[0];
151
+ const fix = points.find((p) => !(p.lat === 0 && p.lon === 0));
152
+ if (!fix) {
153
+ // Every sample is a (0,0) pre-lock placeholder — the GPS never got a position fix (indoor /
154
+ // no sky view). buildGroups would drop them all and skip the resulting empty group anyway;
155
+ // catch it here so the log is honest. A fallback date off points[0] would be the GoPro's
156
+ // pre-satellite clock default (e.g. 2021), which reads deceptively like a real misdated file.
157
+ console.error(` no real fix, skip: ${basename(file)} (0/${points.length} fixed)`);
158
+ skipped++;
159
+ continue;
160
+ }
194
161
  const tz = decideTZ(medianLon(points));
195
162
  const date = fix.time != null ? localDate(fix.time, tz) : null;
196
163
  if (date == null) {
@@ -198,33 +165,31 @@ for (const file of videos) {
198
165
  skipped++;
199
166
  continue;
200
167
  }
201
- const key = `${date}-${fam}`;
202
- if (!groups.has(key)) groups.set(key, { points: [], family: fam, date });
203
- groups.get(key).points.push(...points);
204
- console.log(` ${basename(file)}: ${points.length} pts -> ${key}${cached ? " (cached)" : ""}`);
168
+ // Hand the grouping inputs to buildGroups: serial (CAME) splits cameras, the filename
169
+ // file-number splits recording sessions into <trkseg>s (+ a time-gap split). See ./group.js.
170
+ entries.push({ family: fam, date, serial: meta.serial, session: fileNumber(file), points });
171
+ const tag = meta.serial ? `${date}-${fam}#${meta.serial.slice(0, 4)}` : `${date}-${fam}`;
172
+ console.log(` ${basename(file)}: ${points.length} pts -> ${tag}${fromCache ? " (cached)" : ""}`);
205
173
  ok++;
206
174
  }
207
175
 
208
176
  mkdirSync(outDir, { recursive: true });
177
+ const { groups, skipped: emptyGroups } = buildGroups(entries);
178
+ for (const name of emptyGroups) console.error(` no real fix, skip group: ${name}`);
209
179
  const written = [];
210
- for (const [key, g] of groups) {
211
- g.points.sort((a, b) => (a.time ?? 0) - (b.time ?? 0));
212
- // metadata start time = earliest real (non 0,0) fix, avoiding pre-lock placeholder times
213
- const realTimes = g.points
214
- .filter((p) => !(p.lat === 0 && p.lon === 0) && p.time != null)
215
- .map((p) => p.time);
216
- const startMs = realTimes.length ? Math.min(...realTimes) : null;
180
+ for (const g of groups) {
217
181
  const track = {
218
- segments: [g.points],
182
+ segments: g.segments,
219
183
  meta: {
220
- name: key,
221
- time: startMs != null ? new Date(startMs).toISOString() : null,
184
+ name: g.name,
185
+ time: g.startMs != null ? new Date(g.startMs).toISOString() : null,
222
186
  type: null,
223
187
  },
224
188
  };
225
- const path = join(outDir, `${key}.gpx`);
189
+ const path = join(outDir, `${g.name}.gpx`);
226
190
  saveGpx(track, path, { creator: "gpx-from-gopro" });
227
- written.push(`${key}.gpx (${g.points.length} pts)`);
191
+ const npts = g.segments.reduce((s, seg) => s + seg.length, 0);
192
+ written.push(`${g.name}.gpx (${npts} pts, ${g.segments.length} seg)`);
228
193
  }
229
194
 
230
195
  console.log(`\ndone. processed=${ok} skipped=${skipped} failed=${failed}`);
package/src/gopro.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // Extract a GoPro video's GPS track as package TrackPoints
2
- // ({ lat, lon, ele, time, speed }). Uses gpmf-extract to pull the GPMF 'gpmd'
3
- // track and gopro-telemetry to interpret it. The MP4 is streamed to mp4box in
4
- // blocks so multi-GB files never load whole into RAM. No filtering: every GPS
5
- // sample is kept (including pre-lock 0,0 points), matching the exiftool path.
2
+ // ({ lat, lon, ele, time, speed, fix, hdop, cts }). Uses gpmf-extract to pull the
3
+ // GPMF 'gpmd' track and gopro-telemetry to interpret it. The MP4 is streamed to
4
+ // mp4box in blocks so multi-GB files never load whole into RAM. No filtering:
5
+ // every GPS sample is kept (including pre-lock 0,0 points), matching the exiftool
6
+ // path. `cts` is the sample's media offset (ms from stream start) — the x-axis a
7
+ // consumer regresses UTC against to recover the true recording start (see
8
+ // telemetry.js regressStartUtc).
6
9
  import { open } from "node:fs/promises";
7
10
  import goproTelemetry from "gopro-telemetry";
8
11
  import gpmfExtract from "gpmf-extract";
@@ -39,6 +42,67 @@ function fileFeeder(path) {
39
42
  };
40
43
  }
41
44
 
45
+ // GoPro firmware prefix → camera model. HD5 / H21 verified against real files; the rest follow
46
+ // GoPro's documented firmware scheme (HD6–HD9 = HERO6–9, H22–H24 = HERO11–13).
47
+ const GOPRO_MODEL = {
48
+ HD5: "HERO5",
49
+ HD6: "HERO6",
50
+ HD7: "HERO7",
51
+ HD8: "HERO8",
52
+ HD9: "HERO9",
53
+ H21: "HERO10",
54
+ H22: "HERO11",
55
+ H23: "HERO12",
56
+ H24: "HERO13",
57
+ };
58
+
59
+ /**
60
+ * Camera model from a GoPro `FIRM` firmware string (e.g. "HD5.02.02.60.00" → "HERO5").
61
+ * @param {string | null} firmware
62
+ * @returns {string | null} null for missing/unknown firmware
63
+ */
64
+ export function goproModel(firmware) {
65
+ return firmware ? (GOPRO_MODEL[firmware.slice(0, 3)] ?? null) : null;
66
+ }
67
+
68
+ /**
69
+ * Read a GoPro `udta` atom's string from a moov-region buffer. GoPro stores camera info as
70
+ * custom atoms `[4-byte BE size][4CC][ASCII data]` inside `udta` (FIRM=firmware, CAME=serial,
71
+ * LENS, …) — non-standard boxes mp4box doesn't decode, so read them straight from the bytes.
72
+ * @param {Buffer} buf bytes covering the moov (including udta)
73
+ * @param {string} fourcc e.g. "FIRM"
74
+ * @returns {string | null}
75
+ */
76
+ export function readUdtaRaw(buf, fourcc) {
77
+ const i = buf.indexOf(fourcc, 0, "latin1");
78
+ if (i < 4) return null;
79
+ const size = buf.readUInt32BE(i - 4); // the 4 bytes before the 4CC are the atom's byte size
80
+ if (size < 8 || i - 4 + size > buf.length) return null;
81
+ return buf.subarray(i + 4, i - 4 + size);
82
+ }
83
+
84
+ /**
85
+ * A GoPro `udta` atom read as a trimmed ASCII string (`FIRM`, `LENS`, …), or null.
86
+ * @param {Buffer} buf
87
+ * @param {string} fourcc
88
+ * @returns {string | null}
89
+ */
90
+ export function readUdtaAtom(buf, fourcc) {
91
+ const d = readUdtaRaw(buf, fourcc);
92
+ return d ? d.toString("latin1").replace(/\0+$/, "").trim() || null : null;
93
+ }
94
+
95
+ // Parse the `HMMT` atom (GoPro highlight tags = the times the user pressed the tag button): a u32
96
+ // count, then that many u32 millisecond offsets. [TBC] format inferred from a count=0 sample; not
97
+ // verified against a clip with real highlights.
98
+ function parseHighlights(buf) {
99
+ if (!buf || buf.length < 4) return [];
100
+ const n = buf.readUInt32BE(0);
101
+ const out = [];
102
+ for (let k = 0; k < n && 8 + k * 4 <= buf.length; k++) out.push(buf.readUInt32BE(4 + k * 4));
103
+ return out;
104
+ }
105
+
42
106
  /**
43
107
  * @typedef {object} GoproMeta
44
108
  * @property {boolean} hasGps whether a GPMF 'gpmd' track with samples is present
@@ -48,6 +112,11 @@ function fileFeeder(path) {
48
112
  * @property {string | null} codec video codec (e.g. "avc1.64002a")
49
113
  * @property {number | null} fps video frame rate
50
114
  * @property {number | null} durationS video duration in seconds
115
+ * @property {string | null} firmware GoPro firmware string (e.g. "HD5.02.02.60.00"), or null
116
+ * @property {string | null} model camera model from the firmware (e.g. "HERO5"), or null
117
+ * @property {string | null} serial camera serial (udta `CAME`, hex) — tells two same-model bodies apart
118
+ * @property {string | null} mediaId global media id (udta `GUMI`, hex) — links a recording's chapter files
119
+ * @property {number[]} highlights user highlight-tag offsets in ms (udta `HMMT`); [] if none
51
120
  */
52
121
 
53
122
  /**
@@ -73,10 +142,12 @@ export async function probeGoproMeta(path) {
73
142
  error = e;
74
143
  };
75
144
  const buf = Buffer.allocUnsafe(PROBE_CHUNK);
145
+ const moovChunks = []; // keep the moov-region bytes mp4box reads, to find GoPro's udta/FIRM
76
146
  let pos = 0;
77
147
  while (pos < size && info === null && error === null) {
78
148
  const { bytesRead } = await fh.read(buf, 0, PROBE_CHUNK, pos);
79
149
  if (bytesRead === 0) break;
150
+ moovChunks.push(Buffer.from(buf.subarray(0, bytesRead))); // copy: buf is reused next loop
80
151
  const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + bytesRead);
81
152
  ab.fileStart = pos; // absolute offset; mp4box uses it to place the block
82
153
  const next = file.appendBuffer(ab);
@@ -88,6 +159,12 @@ export async function probeGoproMeta(path) {
88
159
  const gpmd = tracks.find((t) => t.codec === "gpmd");
89
160
  const video = tracks.find((t) => t.type === "video") ?? tracks.find((t) => t.video);
90
161
  const durationS = info?.duration && info?.timescale ? info.duration / info.timescale : null;
162
+ // GoPro stows camera meta in moov's udta as custom atoms — read them from the moov bytes above
163
+ // (already in hand, ~0 extra IO): FIRM=firmware, CAME=serial, GUMI=media id, HMMT=highlight tags.
164
+ const moov = Buffer.concat(moovChunks);
165
+ const firmware = readUdtaAtom(moov, "FIRM");
166
+ const came = readUdtaRaw(moov, "CAME");
167
+ const gumi = readUdtaRaw(moov, "GUMI");
91
168
  return {
92
169
  hasGps: gpmd != null && gpmd.nb_samples > 0,
93
170
  gpmdSamples: gpmd?.nb_samples ?? 0,
@@ -96,6 +173,11 @@ export async function probeGoproMeta(path) {
96
173
  codec: video?.codec ?? null,
97
174
  fps: video && durationS ? video.nb_samples / durationS : null,
98
175
  durationS,
176
+ firmware,
177
+ model: goproModel(firmware),
178
+ serial: came ? came.toString("hex") : null,
179
+ mediaId: gumi ? gumi.toString("hex") : null,
180
+ highlights: parseHighlights(readUdtaRaw(moov, "HMMT")),
99
181
  };
100
182
  } finally {
101
183
  await fh.close();
@@ -112,14 +194,78 @@ export async function probeGoproMeta(path) {
112
194
  */
113
195
  export async function extractGoproPoints(path, opts = {}) {
114
196
  const groupTimes = opts.rate ? Math.round(1000 / opts.rate) : opts.groupTimes;
197
+ const cfg = { stream: ["GPS"], ...(groupTimes ? { groupTimes } : {}) }; // auto GPS5/GPS9
198
+ const extracted = await gpmfExtract(fileFeeder(path));
199
+ // Two interpretations of the SAME extraction. timeOut:'date' yields the GPS UTC
200
+ // per sample but strips cts; timeIn:'MP4'/timeOut:'cts' yields the media offset
201
+ // (ms within the video, cts 0 = first frame) but no date. We need both — UTC for
202
+ // `time`, media cts as the x-axis the start regression extrapolates to 0.
203
+ const telemetry = await goproTelemetry(extracted, { ...cfg, timeIn: "GPS", timeOut: "date" });
204
+ const mediaTel = await goproTelemetry(extracted, { ...cfg, timeIn: "MP4", timeOut: "cts" });
205
+ return buildGpsPoints(telemetry, mediaTel);
206
+ }
207
+
208
+ /**
209
+ * @typedef {object} GoproStream
210
+ * @property {string | null} name stream description (e.g. "Accelerometer")
211
+ * @property {any} units stream units, as gopro-telemetry reports them
212
+ * @property {{ cts: number | null, value: any }[]} samples media-cts-timed samples
213
+ */
214
+
215
+ /**
216
+ * Extract a GoPro MP4's FULL telemetry: GPS `points` plus every NON-GPS stream
217
+ * (IMU `ACCL`/`GYRO`, `GRAV`/`CORI`, `SCEN`, exposure `SHUT`/ISO, …) as raw
218
+ * cts-timed samples, for multi-sensor analysis. One `gpmf-extract` (the IO, done
219
+ * once) then three cheap parse passes — reading more streams costs ~0 extra IO
220
+ * (all streams share the one gpmd track; the filter is post-parse). `rate` /
221
+ * `groupTimes` downsamples only the GPS `points`; **aux streams stay native**
222
+ * (the IMU's ~200 Hz is the whole point).
223
+ * @param {string} path
224
+ * @param {{ rate?: number, groupTimes?: number }} [opts]
225
+ * @returns {Promise<{ points: import("gpx-stabilizer").TrackPoint[], streams: Record<string, GoproStream> }>}
226
+ */
227
+ export async function extractGoproAll(path, opts = {}) {
228
+ const groupTimes = opts.rate ? Math.round(1000 / opts.rate) : opts.groupTimes;
229
+ const cfg = { stream: ["GPS"], ...(groupTimes ? { groupTimes } : {}) };
115
230
  const extracted = await gpmfExtract(fileFeeder(path));
116
- const telemetry = await goproTelemetry(extracted, {
117
- stream: ["GPS"], // auto-selects GPS5 (Hero5-10) or GPS9 (Hero11+)
118
- timeIn: "GPS", // derive sample time from GPS UTC, not the camera clock
119
- timeOut: "date", // we only need the per-sample date
120
- ...(groupTimes ? { groupTimes } : {}),
121
- });
231
+ const telemetry = await goproTelemetry(extracted, { ...cfg, timeIn: "GPS", timeOut: "date" });
232
+ const mediaTel = await goproTelemetry(extracted, { ...cfg, timeIn: "MP4", timeOut: "cts" });
233
+ // every stream (no filter), on the media-cts clock, at native rate — the aux channels
234
+ const allTel = await goproTelemetry(extracted, { timeIn: "MP4", timeOut: "cts" });
235
+ return { points: buildGpsPoints(telemetry, mediaTel), streams: buildAuxStreams(allTel) };
236
+ }
122
237
 
238
+ // Collapse every NON-GPS stream of a parsed telemetry object into raw cts-timed
239
+ // samples (GPS is excluded — it's the `points` channel, processed separately).
240
+ function buildAuxStreams(tel) {
241
+ const out = {};
242
+ for (const device of Object.values(tel ?? {})) {
243
+ for (const [key, stream] of Object.entries(device?.streams ?? {})) {
244
+ if (key.startsWith("GPS")) continue;
245
+ out[key] = {
246
+ name: stream.name ?? null,
247
+ units: stream.units ?? null,
248
+ samples: (stream.samples ?? []).map((s) => ({
249
+ cts: typeof s.cts === "number" ? s.cts : null,
250
+ value: s.value,
251
+ })),
252
+ };
253
+ }
254
+ }
255
+ return out;
256
+ }
257
+
258
+ // Build GPS TrackPoints by zipping the two GPS passes: `telemetry` (timeOut:'date'
259
+ // → UTC `time`) with `mediaTel` (timeOut:'cts' → media `cts`) by sample index (same
260
+ // stream + groupTimes ⇒ same order/count; a length guard drops cts if that fails).
261
+ function buildGpsPoints(telemetry, mediaTel) {
262
+ const mediaCts = [];
263
+ for (const device of Object.values(mediaTel ?? {})) {
264
+ for (const [key, stream] of Object.entries(device?.streams ?? {})) {
265
+ if (!key.startsWith("GPS")) continue;
266
+ for (const s of stream.samples ?? []) mediaCts.push(typeof s.cts === "number" ? s.cts : null);
267
+ }
268
+ }
123
269
  const points = [];
124
270
  for (const device of Object.values(telemetry ?? {})) {
125
271
  const streams = device?.streams ?? {};
@@ -157,9 +303,14 @@ export async function extractGoproPoints(path, opts = {}) {
157
303
  speed: speed == null ? null : speed,
158
304
  fix,
159
305
  hdop,
306
+ cts: null, // filled by the media-cts zip below
160
307
  });
161
308
  }
162
309
  }
163
310
  }
311
+ // zip media cts onto the points by index; only when the two passes agree in count
312
+ if (mediaCts.length === points.length) {
313
+ for (let i = 0; i < points.length; i++) points[i].cts = mediaCts[i];
314
+ }
164
315
  return points;
165
316
  }
package/src/group.js ADDED
@@ -0,0 +1,152 @@
1
+ // Grouping policy for gpx-from-gopro: decide which extracted files merge into one
2
+ // GPX and how their points split into <trkseg>s. Kept pure (no IO) so it is unit
3
+ // testable; the CLI does the extraction/timezone/skip work and hands entries here.
4
+ import { basename } from "node:path";
5
+
6
+ const PLACEHOLDER = (p) => p.lat === 0 && p.lon === 0; // null-island pre-lock fix
7
+
8
+ // A single continuous recording's kept points are contiguous (chapter rollover is ~1 s); only a
9
+ // real break — a stop/restart a shared file-number missed, or a GPS-fix dropout that left no
10
+ // surviving points — exceeds this. Split there into a new <trkseg>. See buildGroups (signal A).
11
+ const BIG_GAP_MS = 120_000;
12
+
13
+ /**
14
+ * Camera "family" from a GoPro filename: the coarse identity used when a file has
15
+ * no body serial. A session's first file (GOPR####) and its continuation chapters
16
+ * (GP01####, GP02####…) share the "GOPR" family; GX/GH are the newer scheme.
17
+ * @param {string} file
18
+ * @returns {string}
19
+ */
20
+ export function family(file) {
21
+ const b = basename(file).toUpperCase();
22
+ if (/^GOPR\d+\./.test(b)) return "GOPR";
23
+ if (/^GP\d\d\d+\./.test(b)) return "GOPR";
24
+ if (/^GX\d\d\d+\./.test(b)) return "GX";
25
+ if (/^GH\d\d\d+\./.test(b)) return "GH";
26
+ const m = b.match(/^([A-Z]+)/);
27
+ return m ? m[1] : "MISC";
28
+ }
29
+
30
+ /**
31
+ * GoPro recording "file-number": the 4-digit id a recording's chapters share, so it keys a
32
+ * recording session. `GOPR5134` / `GP015134` → "5134"; `GX015131` / `GX115131` → "5131" (the
33
+ * leading 2 digits are the chapter index). A new recording — or a crash restart — gets a fresh
34
+ * number. Null when the name has no 4-digit tail (an unknown scheme falls back to a time split).
35
+ * @param {string} file
36
+ * @returns {string | null}
37
+ */
38
+ export function fileNumber(file) {
39
+ const m = basename(file).match(/(\d{4})\.[^.]+$/);
40
+ return m ? m[1] : null;
41
+ }
42
+
43
+ /**
44
+ * @typedef {object} GroupEntry one extracted file's grouping inputs
45
+ * @property {string} family filename family (see {@link family})
46
+ * @property {string} date local date YYYYMMDD
47
+ * @property {string | null} serial body serial (udta CAME), or null
48
+ * @property {string | null} session recording id = filename file-number (a recording's
49
+ * chapters share it; a new recording / crash restart gets a new one), or null
50
+ * @property {import("gpx-stabilizer").TrackPoint[]} points
51
+ */
52
+
53
+ /**
54
+ * @typedef {object} GpxGroup one output GPX
55
+ * @property {string} name file stem (no extension)
56
+ * @property {import("gpx-stabilizer").TrackPoint[][]} segments one per <trkseg>
57
+ * @property {number | null} startMs earliest real fix (epoch ms), for meta.time
58
+ */
59
+
60
+ /**
61
+ * Group extracted files into output GPX tracks.
62
+ *
63
+ * - **Merge key = (camera, day).** Camera is the body `serial` (udta CAME) when
64
+ * known — so two same-model bodies shot the same day stay separate — and the
65
+ * filename `family` otherwise. `date` keeps each group to a single day, so a
66
+ * session GoPro split across a crash (several files, same serial+date) is
67
+ * rejoined into the day's file.
68
+ * - **Segments = recording sessions**, split by two composed signals:
69
+ * - **(B) file-number** — the primary key: a recording's chapters share it, so they merge;
70
+ * a new recording or a crash restart gets a new number, so it splits. (Replaces the old
71
+ * udta-GUMI key, which is per-chapter on some bodies (Hero10) and over-splits — see
72
+ * TODO.md.) `session` is null when the filename has no parseable number.
73
+ * - **(A) time gap** — a refinement applied *within* each file-number: a jump > BIG_GAP_MS
74
+ * between kept points starts a new segment (a same-number restart, or a dropout hole). It
75
+ * only ever sub-splits, never merges across file-numbers; for files with no number it is the
76
+ * sole clusterer (they share one fallback bucket that A then cleaves by time).
77
+ * A crash still lands in the one daily file (merge key is serial+date) as a separate segment.
78
+ * - Placeholder (0,0) pre-lock fixes are dropped per segment; a session that never
79
+ * locks drops to empty, and a group with no real fix lands in `skipped`.
80
+ *
81
+ * @param {GroupEntry[]} entries
82
+ * @returns {{ groups: GpxGroup[], skipped: string[] }}
83
+ */
84
+ export function buildGroups(entries) {
85
+ // gkey -> { date, family, serial, sessions: Map<fileNumber, points[]> }
86
+ const groups = new Map();
87
+ for (const e of entries) {
88
+ const gkey = e.serial ? `${e.date}|s:${e.serial}` : `${e.date}|f:${e.family}`;
89
+ if (!groups.has(gkey)) {
90
+ groups.set(gkey, {
91
+ date: e.date,
92
+ family: e.family,
93
+ serial: e.serial ?? null,
94
+ sessions: new Map(),
95
+ });
96
+ }
97
+ const sessions = groups.get(gkey).sessions;
98
+ // (B) bucket by file-number; files with none share one fallback bucket that (A) below splits.
99
+ const skey = e.session ?? "__nofilenum__";
100
+ if (!sessions.has(skey)) sessions.set(skey, []);
101
+ // loop-push, not push(...points): a file can carry tens of thousands of points
102
+ // and spreading that many args overflows the call stack.
103
+ const bucket = sessions.get(skey);
104
+ for (const p of e.points) bucket.push(p);
105
+ }
106
+
107
+ // Name a group <date>-<family>; only when two cameras collide on the same
108
+ // family+date do we disambiguate with a short serial suffix, so the common
109
+ // single-camera case keeps the readable name.
110
+ const clash = new Map(); // "date-family" -> distinct group count
111
+ for (const g of groups.values()) {
112
+ const base = `${g.date}-${g.family}`;
113
+ clash.set(base, (clash.get(base) ?? 0) + 1);
114
+ }
115
+
116
+ const out = [];
117
+ const skipped = [];
118
+ for (const g of groups.values()) {
119
+ const base = `${g.date}-${g.family}`;
120
+ const name = clash.get(base) > 1 && g.serial ? `${base}-${g.serial.slice(0, 8)}` : base;
121
+ const segments = [];
122
+ for (const pts of g.sessions.values()) {
123
+ const clean = pts.filter((p) => !PLACEHOLDER(p));
124
+ if (clean.length === 0) continue;
125
+ clean.sort((a, b) => (a.time ?? 0) - (b.time ?? 0));
126
+ // (A) sub-split this session wherever consecutive kept points jump more than BIG_GAP_MS.
127
+ let run = [clean[0]];
128
+ for (let i = 1; i < clean.length; i++) {
129
+ if ((clean[i].time ?? 0) - (clean[i - 1].time ?? 0) > BIG_GAP_MS) {
130
+ segments.push(run);
131
+ run = [];
132
+ }
133
+ run.push(clean[i]);
134
+ }
135
+ segments.push(run);
136
+ }
137
+ if (segments.length === 0) {
138
+ skipped.push(name);
139
+ continue;
140
+ }
141
+ // order segments by start time so the day's trksegs read chronologically
142
+ segments.sort((A, B) => (A[0].time ?? 0) - (B[0].time ?? 0));
143
+ let startMs = null;
144
+ for (const seg of segments) {
145
+ for (const p of seg) {
146
+ if (p.time != null && (startMs === null || p.time < startMs)) startMs = p.time;
147
+ }
148
+ }
149
+ out.push({ name, segments, startMs });
150
+ }
151
+ return { groups: out, skipped };
152
+ }
package/src/index.js CHANGED
@@ -3,10 +3,13 @@
3
3
  // gpx-stabilizer package so the contract's "section A" surface stays reachable
4
4
  // from this single entry.
5
5
  export { stabilize } from "gpx-stabilizer";
6
- export { extractGoproPoints, probeGoproMeta } from "./gopro.js";
6
+ export { extractGoproAll, extractGoproPoints, probeGoproMeta } from "./gopro.js";
7
7
  export {
8
+ readGoproSamples,
8
9
  readGoproTelemetry,
9
10
  recordingStartUtc,
11
+ regressStartUtc,
12
+ resolveStartUtc,
10
13
  timezoneAt,
11
14
  timezoneOfPoints,
12
15
  } from "./telemetry.js";
package/src/telemetry.js CHANGED
@@ -3,9 +3,11 @@
3
3
  // concepts leak here; the consumer maps this to its own model. See
4
4
  // docs/export-contract.md.
5
5
 
6
- import { stabilize } from "gpx-stabilizer";
6
+ import { statSync } from "node:fs";
7
+ import { resample, stabilize } from "gpx-stabilizer";
7
8
  import tzlookup from "tz-lookup";
8
- import { extractGoproPoints, probeGoproMeta } from "./gopro.js";
9
+ import { extractGoproAll, probeGoproMeta } from "./gopro.js";
10
+ import { CACHE_V, readCache, resolveCachePath, writeCache } from "./gopro-cache.js";
9
11
 
10
12
  function isFiniteNum(n) {
11
13
  return typeof n === "number" && Number.isFinite(n);
@@ -61,35 +63,206 @@ export function recordingStartUtc(points) {
61
63
  return { startUtc: isFiniteNum(p.time) ? p.time : null, fix: p.fix };
62
64
  }
63
65
 
66
+ // Gates for trusting the regression extrapolation.
67
+ const MIN_REG_POINTS = 5; // too few points → slope is noise
68
+ const MIN_REG_SPAN_MS = 5000; // <5 s of media offset → extrapolating to 0 is unreliable
69
+ const SLOPE_TOL = 0.05; // |slope−1| must be within this (UTC ms vs media ms ⇒ ≈1)
70
+
71
+ /**
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.
79
+ *
80
+ * @param {import("gpx-stabilizer").TrackPoint[]} points raw points (need `cts` + `time` + `fix`)
81
+ * @returns {{ startUtc: number, slope: number, n: number } | null} null when too
82
+ * few points, too short a media span, or `cts` is unavailable
83
+ */
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),
87
+ );
88
+ const n = pts.length;
89
+ if (n < MIN_REG_POINTS) return null;
90
+ // Centre x (cts) and y (time) before the least-squares sums: UTC ms (~1.7e12)
91
+ // and cts ms make the raw normal equations lose precision to catastrophic
92
+ // cancellation; centring keeps the magnitudes small and the slope/intercept
93
+ // accurate.
94
+ let sx = 0;
95
+ let sy = 0;
96
+ let minC = Number.POSITIVE_INFINITY;
97
+ let maxC = Number.NEGATIVE_INFINITY;
98
+ for (const p of pts) {
99
+ sx += p.cts;
100
+ sy += p.time;
101
+ if (p.cts < minC) minC = p.cts;
102
+ if (p.cts > maxC) maxC = p.cts;
103
+ }
104
+ if (maxC - minC < MIN_REG_SPAN_MS) return null;
105
+ const mx = sx / n;
106
+ const my = sy / n;
107
+ let sxx = 0;
108
+ let sxy = 0;
109
+ for (const p of pts) {
110
+ const dx = p.cts - mx;
111
+ sxx += dx * dx;
112
+ sxy += dx * (p.time - my);
113
+ }
114
+ if (sxx === 0) return null;
115
+ const slope = sxy / sxx;
116
+ const intercept = my - slope * mx; // UTC at cts = 0
117
+ return { startUtc: Math.round(intercept), slope, n };
118
+ }
119
+
120
+ /**
121
+ * Best recording-start anchor + how much to trust it. Prefers the regression
122
+ * true-start when its slope passes the quality gate; otherwise falls back to the
123
+ * first good fix (the pre-lock delay is then unknown and left in).
124
+ * @param {import("gpx-stabilizer").TrackPoint[]} points raw points
125
+ * @returns {{ startUtc: number | null, confidence: 'gps' | null, verified: boolean, slope: number | null }}
126
+ */
127
+ export function resolveStartUtc(points) {
128
+ const reg = regressStartUtc(points);
129
+ const verified = reg != null && Math.abs(reg.slope - 1) <= SLOPE_TOL;
130
+ const startUtc = verified ? reg.startUtc : recordingStartUtc(points).startUtc;
131
+ return {
132
+ startUtc,
133
+ confidence: startUtc != null ? "gps" : null,
134
+ verified,
135
+ slope: reg ? reg.slope : null,
136
+ };
137
+ }
138
+
64
139
  /**
65
140
  * @typedef {object} TelemetryResult
66
141
  * @property {import("./gopro.js").GoproMeta} meta geometry / fps / durationS / hasGps
67
- * @property {import("gpx-stabilizer").TrackPoint[]} points raw, or stabilized per opts
142
+ * @property {import("gpx-stabilizer").TrackPoint[]} points raw, or stabilized per opts;
143
+ * with `resample`, the flat concatenation of `segments` (back-compat single array)
144
+ * @property {import("gpx-stabilizer").TrackPoint[][]} segments one entry per `<trkseg>`:
145
+ * `[points]` normally, but `resample` splits at gaps > maxGap into several (always present)
68
146
  * @property {string | null} timezone = timezoneOfPoints(raw points)
69
- * @property {number | null} startUtc = recordingStartUtc(raw points).startUtc
147
+ * @property {number | null} startUtc best recording-start anchor: the regression
148
+ * true-start when verified, else the first good fix (= clock.startUtc)
149
+ * @property {{ startUtc: number|null, confidence: 'gps'|null, verified: boolean, slope: number|null }} clock
150
+ * the start anchor + trust: `verified` true ⇒ regression-extrapolated true start
151
+ * (slope ≈ 1); false ⇒ first-good-fix fallback (pre-lock delay left in)
70
152
  */
71
153
 
72
154
  /**
73
- * One-call convenience the adapter actually uses: probe + extract [+ stabilize]
74
- * + timezone + start anchor in a single await. Short-circuits on a video with no
75
- * GPS track.
155
+ * Cached probe + extract: a video's GoPro samples plus its metadata, as
156
+ * `{ meta, points, fromCache }`. The expensive whole-stream extraction (and the
157
+ * moov probe) are skipped entirely on a cache hit.
158
+ *
159
+ * Caching is **ON by default** — a sidecar `<file>.gpxcache.json` is written
160
+ * next to the source, keyed by file size+mtime+rate+schema-version, so a repeat
161
+ * read (or a killed run) returns instantly. Pass `cache: false` to disable
162
+ * (pure, no file writes) or `cache: { dir }` to keep records in a managed
163
+ * directory instead of beside the media.
164
+ *
165
+ * `points` is `[]` for a video with no GPS track (`meta.hasGps === false`). The
166
+ * record also carries `streams` — every non-GPS GPMF channel (IMU `ACCL`/`GYRO`,
167
+ * `SCEN`, exposure, …) as raw cts-timed samples (`{}` when no GPS track), so the
168
+ * one extraction populates the cache for later multi-sensor work at ~0 extra IO.
76
169
  * @param {string} path
77
- * @param {{ rate?: number, stabilize?: boolean | Parameters<typeof stabilize>[1] }} [opts]
78
- * rate in Hz (omit = native ~18 Hz); stabilize cleans the points first.
170
+ * @param {{ rate?: number, cache?: boolean | { dir?: string | null } }} [opts]
171
+ * rate in Hz (omit = native ~18 Hz); cache controls the on-disk record (default on).
172
+ * @returns {Promise<{ meta: import("./gopro.js").GoproMeta, points: import("gpx-stabilizer").TrackPoint[], streams: Record<string, import("./gopro.js").GoproStream>, fromCache: boolean }>}
173
+ */
174
+ export async function readGoproSamples(path, opts = {}) {
175
+ const { rate, cache } = opts;
176
+ const groupTimes = rate ? Math.round(1000 / rate) : undefined;
177
+ const cp = resolveCachePath(path, cache);
178
+ const ident = { v: CACHE_V, size: 0, mtime: 0, rate: groupTimes ?? null };
179
+ if (cp) {
180
+ try {
181
+ const st = statSync(path);
182
+ ident.size = st.size;
183
+ ident.mtime = Math.round(st.mtimeMs);
184
+ } catch {
185
+ // unstattable -> leave size/mtime 0 (guaranteed miss); probe surfaces the error
186
+ }
187
+ const hit = readCache(cp, ident);
188
+ if (hit) {
189
+ return {
190
+ meta: hit.meta,
191
+ points: hit.points ?? [],
192
+ streams: hit.streams ?? {},
193
+ fromCache: true,
194
+ };
195
+ }
196
+ }
197
+ const meta = await probeGoproMeta(path);
198
+ const { points, streams } = meta.hasGps
199
+ ? await extractGoproAll(path, groupTimes ? { groupTimes } : {})
200
+ : { points: [], streams: {} };
201
+ if (cp) {
202
+ const dir = cache && typeof cache === "object" ? (cache.dir ?? null) : null;
203
+ writeCache(cp, { ...ident, meta, points, streams }, dir);
204
+ }
205
+ return { meta, points, streams, fromCache: false };
206
+ }
207
+
208
+ /**
209
+ * One-call convenience the adapter actually uses: (cached) probe + extract
210
+ * [+ stabilize] + timezone + start anchor in a single await. Short-circuits on
211
+ * a video with no GPS track.
212
+ * @param {string} path
213
+ * @param {{ rate?: number, stabilize?: boolean | Parameters<typeof stabilize>[1],
214
+ * resample?: boolean | "fps" | { RESAMPLE_HZ?: number | "fps", maxGap?: number },
215
+ * cache?: boolean | { dir?: string | null } }} [opts]
216
+ * `rate` in Hz (omit = native ~18 Hz); `stabilize` cleans the points first; `resample`
217
+ * regularises the cleaned points onto a uniform time grid (see {@link resample}) — it
218
+ * **implies** `stabilize` (resampling raw, uncleaned points is meaningless), and
219
+ * `RESAMPLE_HZ: "fps"` (or `resample: "fps"`) uses the video frame rate (`meta.fps`) so
220
+ * there is one point per frame; `cache` controls the on-disk extraction record.
79
221
  * @returns {Promise<TelemetryResult>}
80
222
  */
81
223
  export async function readGoproTelemetry(path, opts = {}) {
82
- const meta = await probeGoproMeta(path);
224
+ const { meta, points: raw } = await readGoproSamples(path, {
225
+ rate: opts.rate,
226
+ cache: opts.cache,
227
+ });
83
228
  if (!meta.hasGps) {
84
- return { meta, points: [], timezone: null, startUtc: null };
229
+ return {
230
+ meta,
231
+ points: [],
232
+ segments: [],
233
+ timezone: null,
234
+ startUtc: null,
235
+ clock: { startUtc: null, confidence: null, verified: false, slope: null },
236
+ };
85
237
  }
86
- const raw = await extractGoproPoints(path, opts.rate != null ? { rate: opts.rate } : {});
87
238
  // tz + anchor are derived from the RAW points: stabilize() reduces a point to
88
- // {lat,lon,ele,time}, dropping the `fix` that good-fix selection relies on.
239
+ // {lat,lon,ele,time}, dropping the `fix` + `cts` that good-fix selection and the
240
+ // start regression rely on.
89
241
  const timezone = timezoneOfPoints(raw);
90
- const { startUtc } = recordingStartUtc(raw);
91
- const points = opts.stabilize
92
- ? stabilize(raw, opts.stabilize === true ? {} : opts.stabilize)
93
- : raw;
94
- return { meta, points, timezone, startUtc };
242
+ const clock = resolveStartUtc(raw);
243
+
244
+ // resample runs on cleaned survivors, so it IMPLIES stabilize; an explicit
245
+ // `stabilize: false` alongside `resample` is contradictory.
246
+ if (opts.resample && opts.stabilize === false) {
247
+ throw new Error("readGoproTelemetry: `resample` requires cleaning — drop `stabilize: false`");
248
+ }
249
+ const cleaned =
250
+ opts.stabilize || opts.resample
251
+ ? stabilize(raw, opts.stabilize && opts.stabilize !== true ? opts.stabilize : {})
252
+ : raw;
253
+
254
+ let points = cleaned;
255
+ let segments = cleaned.length ? [cleaned] : [];
256
+ if (opts.resample) {
257
+ const ro = typeof opts.resample === "object" ? { ...opts.resample } : {};
258
+ // "fps" (shorthand `resample: "fps"` or `RESAMPLE_HZ: "fps"`) → one point per video frame
259
+ if (opts.resample === "fps" || ro.RESAMPLE_HZ === "fps") {
260
+ if (!meta.fps)
261
+ throw new Error("readGoproTelemetry: resample 'fps' but meta.fps is unavailable");
262
+ ro.RESAMPLE_HZ = meta.fps;
263
+ }
264
+ segments = resample(cleaned, ro);
265
+ points = segments.flat();
266
+ }
267
+ return { meta, points, segments, timezone, startUtc: clock.startUtc, clock };
95
268
  }