gpx-from-gopro 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/package.json +41 -0
- package/src/gopro-cache.js +65 -0
- package/src/gopro-cli.js +231 -0
- package/src/gopro.js +165 -0
- package/src/index.js +12 -0
- package/src/telemetry.js +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zordius
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# gpx-from-gopro
|
|
2
|
+
|
|
3
|
+
Extract a GoPro video's GPS telemetry (GPMF `gpmd` track) as merged GPX, or as a render-agnostic
|
|
4
|
+
telemetry bundle (points + video metadata + recording UTC anchor + timezone). Node ESM. The MP4 is
|
|
5
|
+
streamed through mp4box so multi-GB files never load whole into RAM.
|
|
6
|
+
|
|
7
|
+
Part of the [gpx-stabilizer monorepo](https://github.com/zordius/gpx-stabilizer); builds on the
|
|
8
|
+
core [`gpx-stabilizer`](https://www.npmjs.com/package/gpx-stabilizer) package for GPX writing and
|
|
9
|
+
point stabilization.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install gpx-from-gopro
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## CLI
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
|
|
21
|
+
```
|
|
22
|
+
|
|
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`
|
|
26
|
+
overrides the longitude-guessed local date.
|
|
27
|
+
|
|
28
|
+
## Library — telemetry export
|
|
29
|
+
|
|
30
|
+
A render-agnostic API (telemetry samples + video metadata + recording UTC anchor + timezone) for a
|
|
31
|
+
renderer to consume. Full contract:
|
|
32
|
+
[`docs/export-contract.md`](https://github.com/zordius/gpx-stabilizer/blob/main/docs/export-contract.md).
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { readGoproTelemetry } from "gpx-from-gopro";
|
|
36
|
+
|
|
37
|
+
// one call: probe + extract [+ stabilize] + timezone + start anchor
|
|
38
|
+
const { meta, points, timezone, startUtc } = await readGoproTelemetry("clip.mp4", {
|
|
39
|
+
rate: 1, // Hz; omit for native ~18 Hz
|
|
40
|
+
stabilize: true, // clean the points first (boolean | StabilizeOptions)
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Short-circuits to empty `points` / null `timezone` / null `startUtc` on a video with no GPS track.
|
|
45
|
+
|
|
46
|
+
Also exported:
|
|
47
|
+
|
|
48
|
+
- `probeGoproMeta(path)` — cheap moov-only probe (geometry / fps / duration / `hasGps` gate).
|
|
49
|
+
- `extractGoproPoints(path, { rate? })` — raw `TrackPoint[]` (native ~18 Hz; `rate` in Hz to downsample).
|
|
50
|
+
- `timezoneAt({ lat, lon })` / `timezoneOfPoints(points)` — offline IANA timezone lookup.
|
|
51
|
+
- `recordingStartUtc(points)` — `{ startUtc, fix }` from the first good-fix sample.
|
|
52
|
+
- `stabilize` — re-exported from the core package for convenience.
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gpx-from-gopro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Extract a GoPro video's GPS telemetry as GPX or render-agnostic TrackPoints (points + meta + timezone + UTC anchor).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"gopro",
|
|
7
|
+
"gpx",
|
|
8
|
+
"gps",
|
|
9
|
+
"telemetry",
|
|
10
|
+
"gpmf",
|
|
11
|
+
"mp4"
|
|
12
|
+
],
|
|
13
|
+
"author": "zordius",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"gpx-from-gopro": "src/gopro-cli.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": "./src/index.js",
|
|
19
|
+
"files": [
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/zordius/gpx-stabilizer.git",
|
|
32
|
+
"directory": "packages/gopro"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"gpx-stabilizer": "^0.1.0",
|
|
36
|
+
"gopro-telemetry": "^1.2.11",
|
|
37
|
+
"gpmf-extract": "^0.3.3",
|
|
38
|
+
"mp4box": "^0.5.4",
|
|
39
|
+
"tz-lookup": "^6.1.25"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Per-file extraction cache so a killed gpx-from-gopro run resumes cheaply.
|
|
2
|
+
// A record is keyed by the source's identity (v + size + mtime + rate); a rerun
|
|
3
|
+
// reuses it only when all four match, and writes are atomic (temp then rename)
|
|
4
|
+
// so a crash never leaves a half-written record a later run would trust.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { basename, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cache-record path for a source file: sidecar `<file>.gpxcache.json` by
|
|
11
|
+
* default, or a hashed name inside `cacheDir` (keeps the media tree clean).
|
|
12
|
+
* @param {string} file
|
|
13
|
+
* @param {string | null} [cacheDir]
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function cachePath(file, cacheDir = null) {
|
|
17
|
+
if (cacheDir) {
|
|
18
|
+
const h = createHash("sha1").update(resolve(file)).digest("hex").slice(0, 16);
|
|
19
|
+
return join(cacheDir, `${basename(file)}.${h}.json`);
|
|
20
|
+
}
|
|
21
|
+
return `${file}.gpxcache.json`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a cache record, returning it only when it matches `ident`
|
|
26
|
+
* (v + size + mtime + rate); missing/unreadable/stale all return null.
|
|
27
|
+
* @param {string} path
|
|
28
|
+
* @param {{ v: number, size: number, mtime: number, rate: number | null }} ident
|
|
29
|
+
* @returns {object | null}
|
|
30
|
+
*/
|
|
31
|
+
export function readCache(path, ident) {
|
|
32
|
+
let rec;
|
|
33
|
+
try {
|
|
34
|
+
rec = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return null; // missing or unreadable -> miss
|
|
37
|
+
}
|
|
38
|
+
const fresh =
|
|
39
|
+
rec.v === ident.v &&
|
|
40
|
+
rec.size === ident.size &&
|
|
41
|
+
rec.mtime === ident.mtime &&
|
|
42
|
+
rec.rate === ident.rate;
|
|
43
|
+
return fresh ? rec : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Atomically write a cache record (temp file then rename), creating `cacheDir`
|
|
48
|
+
* if given. Best-effort: returns false instead of throwing on failure, since a
|
|
49
|
+
* cache is only an optimization.
|
|
50
|
+
* @param {string} path
|
|
51
|
+
* @param {object} record
|
|
52
|
+
* @param {string | null} [cacheDir]
|
|
53
|
+
* @returns {boolean} whether the write succeeded
|
|
54
|
+
*/
|
|
55
|
+
export function writeCache(path, record, cacheDir = null) {
|
|
56
|
+
try {
|
|
57
|
+
if (cacheDir) mkdirSync(cacheDir, { recursive: true });
|
|
58
|
+
const tmp = `${path}.tmp`;
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(record));
|
|
60
|
+
renameSync(tmp, path);
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/gopro-cli.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gpx-from-gopro — extract GoPro GPS into merged GPX, grouped by camera family + local date.
|
|
3
|
+
// gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
|
|
4
|
+
//
|
|
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.
|
|
8
|
+
// - Local date: timezone from the median longitude of the first valid fixes (round(lon/15)),
|
|
9
|
+
// 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 ".").
|
|
11
|
+
// - Tolerant: a file that fails to extract is logged and skipped, the run continues.
|
|
12
|
+
// - Caches each file's extracted points (sidecar <file>.gpxcache.json by default, or --cache-dir),
|
|
13
|
+
// keyed by size+mtime+rate+version, so a killed run resumes without re-extracting done files.
|
|
14
|
+
import { mkdirSync, readdirSync, statSync } from "node:fs";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
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)
|
|
21
|
+
|
|
22
|
+
const VIDEO_RE = /\.(mp4|mov|m4v|360)$/i;
|
|
23
|
+
const MEDIAN_N = 30;
|
|
24
|
+
const LOCAL_TZ = -new Date().getTimezoneOffset() / 60; // hours, may be fractional
|
|
25
|
+
|
|
26
|
+
// ---- args ----
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
const WITH_VALUE = new Set(["out", "tz", "rate", "cache-dir"]);
|
|
29
|
+
const inputs = [];
|
|
30
|
+
const opts = {};
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const a = argv[i];
|
|
33
|
+
if (a.startsWith("--")) {
|
|
34
|
+
const name = a.slice(2);
|
|
35
|
+
if (WITH_VALUE.has(name)) opts[name] = argv[++i];
|
|
36
|
+
else opts[name] = true;
|
|
37
|
+
} else inputs.push(a);
|
|
38
|
+
}
|
|
39
|
+
if (inputs.length === 0) {
|
|
40
|
+
console.error(
|
|
41
|
+
"usage: gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]",
|
|
42
|
+
);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const outDir = opts.out ?? ".";
|
|
46
|
+
const manualTZ = opts.tz != null ? Number(opts.tz) : null;
|
|
47
|
+
if (manualTZ != null && Number.isNaN(manualTZ)) {
|
|
48
|
+
console.error(`invalid --tz: ${opts.tz}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
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
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- timezone from median longitude of the first valid fixes ----
|
|
69
|
+
function medianLon(points) {
|
|
70
|
+
const lons = [];
|
|
71
|
+
for (const p of points) {
|
|
72
|
+
if (p.lat === 0 && p.lon === 0) continue;
|
|
73
|
+
lons.push(p.lon);
|
|
74
|
+
if (lons.length >= MEDIAN_N) break;
|
|
75
|
+
}
|
|
76
|
+
if (lons.length === 0) return null;
|
|
77
|
+
lons.sort((a, b) => a - b);
|
|
78
|
+
const mid = Math.floor(lons.length / 2);
|
|
79
|
+
return lons.length % 2 ? lons[mid] : (lons[mid - 1] + lons[mid]) / 2;
|
|
80
|
+
}
|
|
81
|
+
function decideTZ(lon) {
|
|
82
|
+
if (manualTZ != null) return manualTZ;
|
|
83
|
+
if (lon == null || Number.isNaN(lon)) return LOCAL_TZ;
|
|
84
|
+
const approx = Math.round(lon / 15);
|
|
85
|
+
return Math.abs(approx - LOCAL_TZ) <= 1 ? LOCAL_TZ : approx;
|
|
86
|
+
}
|
|
87
|
+
// epoch ms + tz offset -> YYYYMMDD local date
|
|
88
|
+
function localDate(ms, tz) {
|
|
89
|
+
return new Date(ms + tz * 3600e3).toISOString().slice(0, 10).replaceAll("-", "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- collect video files ----
|
|
93
|
+
function walk(dir) {
|
|
94
|
+
const out = [];
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.error(`skip dir ${dir}: ${e.message}`);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
if (e.name.startsWith("._")) continue; // macOS AppleDouble sidecar
|
|
104
|
+
const p = join(dir, e.name);
|
|
105
|
+
if (e.isDirectory()) out.push(...walk(p));
|
|
106
|
+
else if (VIDEO_RE.test(e.name)) out.push(p);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function collect(input) {
|
|
111
|
+
let st;
|
|
112
|
+
try {
|
|
113
|
+
st = statSync(input);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error(`skip ${input}: ${e.message}`);
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
if (st.isDirectory()) return walk(input);
|
|
119
|
+
return VIDEO_RE.test(input) ? [input] : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- main ----
|
|
123
|
+
const videos = inputs.flatMap(collect).sort();
|
|
124
|
+
if (videos.length === 0) {
|
|
125
|
+
console.error("no video files found");
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
console.log(`found ${videos.length} video file(s)`);
|
|
129
|
+
|
|
130
|
+
const groups = new Map(); // key "YYYYMMDD-FAMILY" -> { points, family, date }
|
|
131
|
+
let ok = 0;
|
|
132
|
+
let skipped = 0;
|
|
133
|
+
let failed = 0;
|
|
134
|
+
for (const file of videos) {
|
|
135
|
+
const ident = { v: CACHE_V, size: 0, mtime: 0, rate: groupTimes ?? null };
|
|
136
|
+
try {
|
|
137
|
+
const st = statSync(file);
|
|
138
|
+
ident.size = st.size;
|
|
139
|
+
ident.mtime = Math.round(st.mtimeMs);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error(` FAILED stat ${basename(file)}: ${e.message}`);
|
|
142
|
+
failed++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
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);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const fam = family(file);
|
|
193
|
+
const fix = points.find((p) => !(p.lat === 0 && p.lon === 0)) ?? points[0];
|
|
194
|
+
const tz = decideTZ(medianLon(points));
|
|
195
|
+
const date = fix.time != null ? localDate(fix.time, tz) : null;
|
|
196
|
+
if (date == null) {
|
|
197
|
+
console.error(` bad time, skip: ${basename(file)}`);
|
|
198
|
+
skipped++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
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)" : ""}`);
|
|
205
|
+
ok++;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
mkdirSync(outDir, { recursive: true });
|
|
209
|
+
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;
|
|
217
|
+
const track = {
|
|
218
|
+
segments: [g.points],
|
|
219
|
+
meta: {
|
|
220
|
+
name: key,
|
|
221
|
+
time: startMs != null ? new Date(startMs).toISOString() : null,
|
|
222
|
+
type: null,
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
const path = join(outDir, `${key}.gpx`);
|
|
226
|
+
saveGpx(track, path, { creator: "gpx-from-gopro" });
|
|
227
|
+
written.push(`${key}.gpx (${g.points.length} pts)`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log(`\ndone. processed=${ok} skipped=${skipped} failed=${failed}`);
|
|
231
|
+
for (const w of written) console.log(` -> ${join(outDir, w)}`);
|
package/src/gopro.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
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.
|
|
6
|
+
import { open } from "node:fs/promises";
|
|
7
|
+
import goproTelemetry from "gopro-telemetry";
|
|
8
|
+
import gpmfExtract from "gpmf-extract";
|
|
9
|
+
import MP4Box from "mp4box";
|
|
10
|
+
|
|
11
|
+
const CHUNK = 1 << 24; // 16 MiB read blocks
|
|
12
|
+
const PROBE_CHUNK = 1 << 16; // 64 KiB — enough to reach moov via mp4box's seek hints
|
|
13
|
+
|
|
14
|
+
// GPSF lock type (0 none / 2 2D / 3 3D) -> GPX <fix> token
|
|
15
|
+
function gpsFix(n) {
|
|
16
|
+
return n === 3 ? "3d" : n === 2 ? "2d" : n === 0 ? "none" : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// gpmf-extract's Node "function" input mode: it hands us the mp4box file object
|
|
20
|
+
// and we feed the bytes ourselves, so we control memory for huge files.
|
|
21
|
+
function fileFeeder(path) {
|
|
22
|
+
return async (mp4boxFile) => {
|
|
23
|
+
const fh = await open(path, "r");
|
|
24
|
+
try {
|
|
25
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
26
|
+
let offset = 0;
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { bytesRead } = await fh.read(buf, 0, CHUNK, offset);
|
|
29
|
+
if (bytesRead === 0) break;
|
|
30
|
+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + bytesRead);
|
|
31
|
+
ab.fileStart = offset; // mp4box needs the absolute offset of each block
|
|
32
|
+
mp4boxFile.appendBuffer(ab);
|
|
33
|
+
offset += bytesRead;
|
|
34
|
+
}
|
|
35
|
+
mp4boxFile.flush();
|
|
36
|
+
} finally {
|
|
37
|
+
await fh.close();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} GoproMeta
|
|
44
|
+
* @property {boolean} hasGps whether a GPMF 'gpmd' track with samples is present
|
|
45
|
+
* @property {number} gpmdSamples number of gpmd payloads (GPS data chunks)
|
|
46
|
+
* @property {number | null} width video width in pixels
|
|
47
|
+
* @property {number | null} height video height in pixels
|
|
48
|
+
* @property {string | null} codec video codec (e.g. "avc1.64002a")
|
|
49
|
+
* @property {number | null} fps video frame rate
|
|
50
|
+
* @property {number | null} durationS video duration in seconds
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read only the MP4 moov to report the metadata we use, without extracting any
|
|
55
|
+
* samples. mp4box's appendBuffer returns the next byte offset it needs, which
|
|
56
|
+
* skips past the (multi-GB) mdat straight to the moov — so this reads on the
|
|
57
|
+
* order of the moov size (tens of KB to ~1 MB), not the whole file. Use the
|
|
58
|
+
* returned `hasGps` as a cheap gate before the full (expensive) extraction.
|
|
59
|
+
* @param {string} path
|
|
60
|
+
* @returns {Promise<GoproMeta>}
|
|
61
|
+
*/
|
|
62
|
+
export async function probeGoproMeta(path) {
|
|
63
|
+
const fh = await open(path, "r");
|
|
64
|
+
try {
|
|
65
|
+
const { size } = await fh.stat();
|
|
66
|
+
const file = MP4Box.createFile();
|
|
67
|
+
let info = null;
|
|
68
|
+
let error = null;
|
|
69
|
+
file.onReady = (i) => {
|
|
70
|
+
info = i;
|
|
71
|
+
};
|
|
72
|
+
file.onError = (e) => {
|
|
73
|
+
error = e;
|
|
74
|
+
};
|
|
75
|
+
const buf = Buffer.allocUnsafe(PROBE_CHUNK);
|
|
76
|
+
let pos = 0;
|
|
77
|
+
while (pos < size && info === null && error === null) {
|
|
78
|
+
const { bytesRead } = await fh.read(buf, 0, PROBE_CHUNK, pos);
|
|
79
|
+
if (bytesRead === 0) break;
|
|
80
|
+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + bytesRead);
|
|
81
|
+
ab.fileStart = pos; // absolute offset; mp4box uses it to place the block
|
|
82
|
+
const next = file.appendBuffer(ab);
|
|
83
|
+
// mp4box tells us where it wants data next — this hops over mdat to the moov
|
|
84
|
+
pos = typeof next === "number" && next > pos ? next : pos + bytesRead;
|
|
85
|
+
}
|
|
86
|
+
if (error !== null) throw new Error(`mp4 parse failed: ${error}`);
|
|
87
|
+
const tracks = info?.tracks ?? [];
|
|
88
|
+
const gpmd = tracks.find((t) => t.codec === "gpmd");
|
|
89
|
+
const video = tracks.find((t) => t.type === "video") ?? tracks.find((t) => t.video);
|
|
90
|
+
const durationS = info?.duration && info?.timescale ? info.duration / info.timescale : null;
|
|
91
|
+
return {
|
|
92
|
+
hasGps: gpmd != null && gpmd.nb_samples > 0,
|
|
93
|
+
gpmdSamples: gpmd?.nb_samples ?? 0,
|
|
94
|
+
width: video?.video?.width ?? video?.track_width ?? null,
|
|
95
|
+
height: video?.video?.height ?? video?.track_height ?? null,
|
|
96
|
+
codec: video?.codec ?? null,
|
|
97
|
+
fps: video && durationS ? video.nb_samples / durationS : null,
|
|
98
|
+
durationS,
|
|
99
|
+
};
|
|
100
|
+
} finally {
|
|
101
|
+
await fh.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract GPS samples from a GoPro MP4 as package TrackPoints, in capture order.
|
|
107
|
+
* @param {string} path
|
|
108
|
+
* @param {{ rate?: number, groupTimes?: number }} [opts] rate in Hz (public knob;
|
|
109
|
+
* omit for native ~18 Hz). `groupTimes` (ms) is the internal equivalent kept for
|
|
110
|
+
* existing callers; `rate` wins when both are given (groupTimes = 1000 / rate).
|
|
111
|
+
* @returns {Promise<import("gpx-stabilizer").TrackPoint[]>}
|
|
112
|
+
*/
|
|
113
|
+
export async function extractGoproPoints(path, opts = {}) {
|
|
114
|
+
const groupTimes = opts.rate ? Math.round(1000 / opts.rate) : opts.groupTimes;
|
|
115
|
+
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
|
+
});
|
|
122
|
+
|
|
123
|
+
const points = [];
|
|
124
|
+
for (const device of Object.values(telemetry ?? {})) {
|
|
125
|
+
const streams = device?.streams ?? {};
|
|
126
|
+
for (const [key, stream] of Object.entries(streams)) {
|
|
127
|
+
if (!key.startsWith("GPS")) continue;
|
|
128
|
+
// GPS5 reports fix/precision once per ~1 Hz payload as sticky values; sticky
|
|
129
|
+
// semantics are "applies to all successive samples", so carry them forward
|
|
130
|
+
// onto every (18 Hz) point. precision is DOP x100. GPS9 (Hero11+) instead
|
|
131
|
+
// carries them per-sample in value[7..8] = [DOP, fix] — read those directly.
|
|
132
|
+
const isGps9 = key === "GPS9";
|
|
133
|
+
let fix = null;
|
|
134
|
+
let hdop = null;
|
|
135
|
+
for (const s of stream.samples ?? []) {
|
|
136
|
+
const v = s.value;
|
|
137
|
+
if (!Array.isArray(v)) continue;
|
|
138
|
+
// GPS5 and GPS9 share value[0..3] = [lat, lon, altitude, 2D speed]
|
|
139
|
+
const [lat, lon, ele, speed] = v;
|
|
140
|
+
const time = s.date != null ? new Date(s.date).getTime() : Number.NaN;
|
|
141
|
+
if (isGps9) {
|
|
142
|
+
// value = [lat, lon, alt, 2Dspeed, 3Dspeed, days, secs, DOP, fix]
|
|
143
|
+
fix = gpsFix(v[8]);
|
|
144
|
+
// GPS9 DOP is already in DOP units (not x100 like GPS5's sticky.precision).
|
|
145
|
+
// [TBC] exact scale unverified — no Hero11+ sample on hand to confirm.
|
|
146
|
+
hdop = typeof v[7] === "number" ? v[7] : null;
|
|
147
|
+
} else {
|
|
148
|
+
const sticky = s.sticky ?? {};
|
|
149
|
+
if (sticky.fix != null) fix = gpsFix(sticky.fix);
|
|
150
|
+
if (typeof sticky.precision === "number") hdop = sticky.precision / 100;
|
|
151
|
+
}
|
|
152
|
+
points.push({
|
|
153
|
+
lat,
|
|
154
|
+
lon,
|
|
155
|
+
ele: ele == null ? null : ele,
|
|
156
|
+
time: Number.isNaN(time) ? null : time,
|
|
157
|
+
speed: speed == null ? null : speed,
|
|
158
|
+
fix,
|
|
159
|
+
hdop,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return points;
|
|
165
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Public surface of gpx-from-gopro — the render-agnostic telemetry export
|
|
2
|
+
// contract (docs/export-contract.md). `stabilize` is re-exported from the core
|
|
3
|
+
// gpx-stabilizer package so the contract's "section A" surface stays reachable
|
|
4
|
+
// from this single entry.
|
|
5
|
+
export { stabilize } from "gpx-stabilizer";
|
|
6
|
+
export { extractGoproPoints, probeGoproMeta } from "./gopro.js";
|
|
7
|
+
export {
|
|
8
|
+
readGoproTelemetry,
|
|
9
|
+
recordingStartUtc,
|
|
10
|
+
timezoneAt,
|
|
11
|
+
timezoneOfPoints,
|
|
12
|
+
} from "./telemetry.js";
|
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Render-agnostic telemetry export: turn a GoPro video into the neutral shape a
|
|
2
|
+
// renderer needs — points + meta + timezone + recording UTC anchor. No renderer
|
|
3
|
+
// concepts leak here; the consumer maps this to its own model. See
|
|
4
|
+
// docs/export-contract.md.
|
|
5
|
+
|
|
6
|
+
import { stabilize } from "gpx-stabilizer";
|
|
7
|
+
import tzlookup from "tz-lookup";
|
|
8
|
+
import { extractGoproPoints, probeGoproMeta } from "./gopro.js";
|
|
9
|
+
|
|
10
|
+
function isFiniteNum(n) {
|
|
11
|
+
return typeof n === "number" && Number.isFinite(n);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// First sample with a usable GPS lock: prefer a 3D fix, fall back to 2D, and
|
|
15
|
+
// require finite lat/lon. tz and the start anchor are both constant within one
|
|
16
|
+
// video (cross-timezone travel is out of v1 scope), so the first good fix
|
|
17
|
+
// suffices. Returns the point, or null if none.
|
|
18
|
+
function firstGoodFix(points) {
|
|
19
|
+
const list = points ?? [];
|
|
20
|
+
return (
|
|
21
|
+
list.find((p) => p && p.fix === "3d" && isFiniteNum(p.lat) && isFiniteNum(p.lon)) ??
|
|
22
|
+
list.find((p) => p && p.fix === "2d" && isFiniteNum(p.lat) && isFiniteNum(p.lon)) ??
|
|
23
|
+
null
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Raw lat/lon → IANA timezone lookup (offline, via tz-lookup).
|
|
29
|
+
* @param {{ lat: number, lon: number }} coord
|
|
30
|
+
* @returns {string | null} e.g. "Asia/Tokyo"; null on non-finite or out-of-range input
|
|
31
|
+
*/
|
|
32
|
+
export function timezoneAt({ lat, lon } = {}) {
|
|
33
|
+
if (!isFiniteNum(lat) || !isFiniteNum(lon)) return null;
|
|
34
|
+
try {
|
|
35
|
+
return tzlookup(lat, lon);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* IANA timezone for a track, from its first good-fix point.
|
|
43
|
+
* @param {import("gpx-stabilizer").TrackPoint[]} points
|
|
44
|
+
* @returns {string | null} null if no good-fix point exists
|
|
45
|
+
*/
|
|
46
|
+
export function timezoneOfPoints(points) {
|
|
47
|
+
const p = firstGoodFix(points);
|
|
48
|
+
return p ? timezoneAt({ lat: p.lat, lon: p.lon }) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recording start instant (UTC) = the UTC ms of the first good-fix sample, the
|
|
53
|
+
* wall-clock anchor a renderer pins the segment to. `fix` reports that sample's
|
|
54
|
+
* fix so the consumer knows the confidence.
|
|
55
|
+
* @param {import("gpx-stabilizer").TrackPoint[]} points
|
|
56
|
+
* @returns {{ startUtc: number | null, fix: string | null }}
|
|
57
|
+
*/
|
|
58
|
+
export function recordingStartUtc(points) {
|
|
59
|
+
const p = firstGoodFix(points);
|
|
60
|
+
if (!p) return { startUtc: null, fix: null };
|
|
61
|
+
return { startUtc: isFiniteNum(p.time) ? p.time : null, fix: p.fix };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {object} TelemetryResult
|
|
66
|
+
* @property {import("./gopro.js").GoproMeta} meta geometry / fps / durationS / hasGps
|
|
67
|
+
* @property {import("gpx-stabilizer").TrackPoint[]} points raw, or stabilized per opts
|
|
68
|
+
* @property {string | null} timezone = timezoneOfPoints(raw points)
|
|
69
|
+
* @property {number | null} startUtc = recordingStartUtc(raw points).startUtc
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
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.
|
|
76
|
+
* @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.
|
|
79
|
+
* @returns {Promise<TelemetryResult>}
|
|
80
|
+
*/
|
|
81
|
+
export async function readGoproTelemetry(path, opts = {}) {
|
|
82
|
+
const meta = await probeGoproMeta(path);
|
|
83
|
+
if (!meta.hasGps) {
|
|
84
|
+
return { meta, points: [], timezone: null, startUtc: null };
|
|
85
|
+
}
|
|
86
|
+
const raw = await extractGoproPoints(path, opts.rate != null ? { rate: opts.rate } : {});
|
|
87
|
+
// 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.
|
|
89
|
+
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 };
|
|
95
|
+
}
|