gpx-from-gopro 0.1.0 → 0.2.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 +5 -3
- package/package.json +3 -2
- package/src/gopro-cache.js +27 -0
- package/src/gopro-cli.js +57 -92
- package/src/gopro.js +161 -10
- package/src/group.js +152 -0
- package/src/index.js +4 -1
- package/src/telemetry.js +191 -18
package/README.md
CHANGED
|
@@ -20,9 +20,11 @@ npm install gpx-from-gopro
|
|
|
20
20
|
gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Recurses directories for video files, groups by camera
|
|
24
|
-
`<YYYYMMDD>-<family>.gpx` per group
|
|
25
|
-
|
|
23
|
+
Recurses directories for video files, groups by camera body (serial, falling back to filename
|
|
24
|
+
family) + local date, and writes one merged `<YYYYMMDD>-<family>.gpx` per group — within it, points
|
|
25
|
+
split into one `<trkseg>` per recording session (keyed on the filename file-number, with a time-gap
|
|
26
|
+
split for restarts/dropouts). A per-file extraction cache (keyed by size+mtime+rate) lets a killed
|
|
27
|
+
run resume without re-extracting. `--rate HZ` downsamples from the native ~18 Hz; `--tz HOURS`
|
|
26
28
|
overrides the longitude-guessed local date.
|
|
27
29
|
|
|
28
30
|
## Library — telemetry export
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gpx-from-gopro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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.
|
|
35
|
+
"gpx-stabilizer": "^0.2.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",
|
package/src/gopro-cache.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
7
|
-
//
|
|
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
|
|
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 {
|
|
18
|
-
import {
|
|
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 ->
|
|
52
|
-
const
|
|
53
|
-
// per-file extraction cache: sidecar next to source
|
|
54
|
-
|
|
55
|
-
const
|
|
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
|
|
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
|
-
|
|
129
|
+
let meta;
|
|
130
|
+
let points;
|
|
131
|
+
let fromCache;
|
|
136
132
|
try {
|
|
137
|
-
|
|
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
|
|
135
|
+
console.error(` FAILED ${basename(file)}: ${(e?.message ?? String(e)).split("\n")[0]}`);
|
|
142
136
|
failed++;
|
|
143
137
|
continue;
|
|
144
138
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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))
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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:
|
|
182
|
+
segments: g.segments,
|
|
219
183
|
meta: {
|
|
220
|
-
name:
|
|
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, `${
|
|
189
|
+
const path = join(outDir, `${g.name}.gpx`);
|
|
226
190
|
saveGpx(track, path, { creator: "gpx-from-gopro" });
|
|
227
|
-
|
|
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
|
|
3
|
-
// track and gopro-telemetry to interpret it. The MP4 is streamed to
|
|
4
|
-
// blocks so multi-GB files never load whole into RAM. No filtering:
|
|
5
|
-
// sample is kept (including pre-lock 0,0 points), matching the exiftool
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 {
|
|
6
|
+
import { statSync } from "node:fs";
|
|
7
|
+
import { resample, stabilize } from "gpx-stabilizer";
|
|
7
8
|
import tzlookup from "tz-lookup";
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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,
|
|
78
|
-
* rate in Hz (omit = native ~18 Hz);
|
|
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
|
|
224
|
+
const { meta, points: raw } = await readGoproSamples(path, {
|
|
225
|
+
rate: opts.rate,
|
|
226
|
+
cache: opts.cache,
|
|
227
|
+
});
|
|
83
228
|
if (!meta.hasGps) {
|
|
84
|
-
return {
|
|
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
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
}
|