gpx-from-gopro 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ No install needed:
20
20
 
21
21
  ```sh
22
22
  npx gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
23
+ [--organize DIR] [--yes] [--html] [--png [--width N] [--height N]]
23
24
  ```
24
25
 
25
26
  Once installed (`npm install [-g] gpx-from-gopro`), drop the `npx` prefix and just run
@@ -32,6 +33,30 @@ split for restarts/dropouts). A per-file extraction cache (keyed by size+mtime+r
32
33
  run resume without re-extracting. `--rate HZ` downsamples from the native ~18 Hz; `--tz HOURS`
33
34
  overrides the longitude-guessed local date.
34
35
 
36
+ ### `--organize DIR` — reorganize the source videos to match the GPX
37
+
38
+ After every `.gpx` above has been written, moves each source video into
39
+ `<DIR>/<group>/<session>/` — the same `<date>-<family>` group naming as the `.gpx` file, further
40
+ split into one folder per recording session (the filename file-number; `no-session/` when a file
41
+ has none). Each file's extraction cache moves alongside (never deleted — it's free to keep and
42
+ costly to lose); the group's `.gpx` moves in too, *unless* `--out` was explicitly given (then it
43
+ stays where you put it). Never overwrites an existing destination file.
44
+
45
+ Always previews the full plan first, then asks before touching anything — including whether
46
+ found `.LRV`/`.THM` sidecar files (GoPro's per-chapter low-res preview / thumbnail) should be
47
+ deleted (default) or moved alongside. `--yes` skips both prompts (sidecars default to deleted); a
48
+ non-interactive stdin without `--yes` does nothing rather than hang waiting for input.
49
+
50
+ ### `--html` / `--png` — eval view of each merged group
51
+
52
+ Additive to the `.gpx` output (never instead of it): renders each group's merged track through
53
+ the same **analyzed** view `gpx-stabilizer`'s own CLI uses (clean track + drop markers, hdop
54
+ overlays), so a group can be eyeballed right after extraction with no separate
55
+ `gpx-stabilizer --html`/`--png` pass on the merged `.gpx`. `--html` writes one
56
+ `<out>/gopro-view.html` (one scrolling panel per group); `--png` writes one
57
+ `<out>/<group>.png` per group (`--width`/`--height`, default 1280×720; needs
58
+ `@resvg/resvg-js` — see [`gpx-stabilizer`](../core)'s `png.js`).
59
+
35
60
  ## Library — telemetry export
36
61
 
37
62
  A render-agnostic API (telemetry samples + video metadata + recording UTC anchor + timezone) for a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gpx-from-gopro",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Extract a GoPro video's GPS telemetry as GPX or render-agnostic TrackPoints (points + meta + timezone + UTC anchor).",
5
5
  "keywords": [
6
6
  "gopro",
@@ -32,7 +32,7 @@
32
32
  "directory": "packages/gopro"
33
33
  },
34
34
  "dependencies": {
35
- "gpx-stabilizer": "^0.3.0",
35
+ "gpx-stabilizer": "^0.5.0",
36
36
  "egm96-universal": "^1.1.1",
37
37
  "gopro-telemetry": "^1.2.11",
38
38
  "gpmf-extract": "^0.3.3",
package/src/gopro-cli.js CHANGED
@@ -1,6 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  // gpx-from-gopro — extract GoPro GPS into merged GPX, one file per camera per local date.
3
3
  // gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
4
+ // [--organize DIR] [--yes] [--mode core|ski]
5
+ // [--html] [--png [--width N] [--height N]]
6
+ //
7
+ // - --mode core|ski: runs the merged points through gpx-stabilizer's own `stabilizeTrack` (per
8
+ // recording session — see the `--out` loop below) before writing, instead of shipping today's
9
+ // default raw extraction. --html/--png (below) render under the SAME mode when both are given,
10
+ // so the preview always matches what actually got written. Omitting --mode leaves every output
11
+ // exactly as before (raw, unstabilized) — this is purely additive.
12
+ //
13
+ // - --html / --png: alongside the merged .gpx (never instead of it), also render each group's merged
14
+ // track through the SAME analyzed view core's own CLI uses (clean track + drop markers) — an eval
15
+ // aid for eyeballing a group before/after a pipeline change, no separate `gpx-stabilizer` step
16
+ // needed. --html writes one <out>/gopro-view.html (one panel per group); --png writes one
17
+ // <out>/<group>.png per group (needs @resvg/resvg-js — see core's png.js).
4
18
  //
5
19
  // - Recurses directories for video files (mp4/mov/m4v/360); skips .LRV/.THM and ._ AppleDouble.
6
20
  // - Groups by (camera, local date): camera = the body serial (udta CAME) when known, so two
@@ -18,10 +32,18 @@
18
32
  // - Tolerant: a file that fails to extract is logged and skipped, the run continues.
19
33
  // - Caches each file's extracted points (sidecar <file>.gpxcache.json by default, or --cache-dir),
20
34
  // keyed by size+mtime+rate+version, so a killed run resumes without re-extracting done files.
21
- import { mkdirSync, readdirSync, statSync } from "node:fs";
35
+ // - --organize DIR: AFTER every .gpx has been written, reorganizes the source videos into
36
+ // <DIR>/<group>/<session>/ (same group/session naming as the .gpx above), moving each file's
37
+ // cache record alongside and — when --out was NOT explicitly given — the group's .gpx into its
38
+ // folder too. Always previews the plan and asks before moving anything (--yes skips both
39
+ // prompts, defaulting .LRV/.THM sidecars to delete); a non-interactive stdin without --yes does
40
+ // nothing (never blocks waiting for input that will never come). See organize.js.
41
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
22
42
  import { basename, join } from "node:path";
23
- import { saveGpx } from "gpx-stabilizer";
43
+ import { createInterface } from "node:readline/promises";
44
+ import { analyzedSvg, MODES, saveGpx, savePng, stabilizeTrack, toHtmlAnalyzedFiles } from "gpx-stabilizer";
24
45
  import { buildGroups, family, fileNumber } from "./group.js";
46
+ import { cacheMovePlan, executeMove, findSidecars, planMove } from "./organize.js";
25
47
  import { readGoproSamples } from "./telemetry.js";
26
48
 
27
49
  const VIDEO_RE = /\.(mp4|mov|m4v|360)$/i;
@@ -30,7 +52,12 @@ const LOCAL_TZ = -new Date().getTimezoneOffset() / 60; // hours, may be fraction
30
52
 
31
53
  // ---- args ----
32
54
  const argv = process.argv.slice(2);
33
- const WITH_VALUE = new Set(["out", "tz", "rate", "cache-dir"]);
55
+ const WITH_VALUE = new Set(["out", "tz", "rate", "cache-dir", "organize", "width", "height", "mode"]);
56
+ const KNOWN_BOOL = new Set(["no-cache", "html", "png", "yes"]);
57
+ const USAGE =
58
+ "usage: gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ]" +
59
+ " [--cache-dir DIR | --no-cache] [--organize DIR] [--yes] [--mode core|ski]" +
60
+ " [--html] [--png [--width N] [--height N]]";
34
61
  const inputs = [];
35
62
  const opts = {};
36
63
  for (let i = 0; i < argv.length; i++) {
@@ -38,15 +65,24 @@ for (let i = 0; i < argv.length; i++) {
38
65
  if (a.startsWith("--")) {
39
66
  const name = a.slice(2);
40
67
  if (WITH_VALUE.has(name)) opts[name] = argv[++i];
41
- else opts[name] = true;
68
+ else if (KNOWN_BOOL.has(name)) opts[name] = true;
69
+ else {
70
+ // an unrecognized flag must fail loudly -- silently accepting it into `opts` would look like
71
+ // it took effect while doing nothing.
72
+ console.error(`gpx-from-gopro: unknown option --${name}\n\n${USAGE}`);
73
+ process.exit(1);
74
+ }
42
75
  } else inputs.push(a);
43
76
  }
44
77
  if (inputs.length === 0) {
45
- console.error(
46
- "usage: gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]",
47
- );
78
+ console.error(USAGE);
48
79
  process.exit(1);
49
80
  }
81
+ if (opts.mode != null && !MODES[opts.mode]) {
82
+ console.error(`gpx-from-gopro: unknown --mode "${opts.mode}" (use: ${Object.keys(MODES).join(", ")})\n\n${USAGE}`);
83
+ process.exit(1);
84
+ }
85
+ const outExplicit = opts.out != null; // --organize only sweeps the .gpx along when this is false
50
86
  const outDir = opts.out ?? ".";
51
87
  const manualTZ = opts.tz != null ? Number(opts.tz) : null;
52
88
  if (manualTZ != null && Number.isNaN(manualTZ)) {
@@ -167,7 +203,7 @@ for (const file of videos) {
167
203
  }
168
204
  // Hand the grouping inputs to buildGroups: serial (CAME) splits cameras, the filename
169
205
  // 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 });
206
+ entries.push({ family: fam, date, serial: meta.serial, session: fileNumber(file), points, file });
171
207
  const tag = meta.serial ? `${date}-${fam}#${meta.serial.slice(0, 4)}` : `${date}-${fam}`;
172
208
  console.log(` ${basename(file)}: ${points.length} pts -> ${tag}${fromCache ? " (cached)" : ""}`);
173
209
  ok++;
@@ -178,8 +214,18 @@ const { groups, skipped: emptyGroups } = buildGroups(entries);
178
214
  for (const name of emptyGroups) console.error(` no real fix, skip group: ${name}`);
179
215
  const written = [];
180
216
  for (const g of groups) {
217
+ // --mode stabilizes each recording session independently (stabilizeTrack analyzes a session's own
218
+ // <trkseg>s as one continuous stream, same handling core itself gives a multi-segment track) --
219
+ // sessions themselves stay separate <trk>s, matching buildGroups' own "one <trk> per recording"
220
+ // design. Omitting --mode ships today's raw extraction unchanged.
221
+ const tracks = opts.mode
222
+ ? g.tracks.map((trk) => ({
223
+ name: trk.name,
224
+ segments: stabilizeTrack({ segments: trk.segments }, { mode: opts.mode }).segments,
225
+ }))
226
+ : g.tracks;
181
227
  const track = {
182
- segments: g.segments,
228
+ tracks, // one <trk> per recording session, named after its own original video file
183
229
  meta: {
184
230
  name: g.name,
185
231
  time: g.startMs != null ? new Date(g.startMs).toISOString() : null,
@@ -188,9 +234,117 @@ for (const g of groups) {
188
234
  };
189
235
  const path = join(outDir, `${g.name}.gpx`);
190
236
  saveGpx(track, path, { creator: "gpx-from-gopro" });
191
- const npts = g.segments.reduce((s, seg) => s + seg.length, 0);
192
- written.push(`${g.name}.gpx (${npts} pts, ${g.segments.length} seg)`);
237
+ const npts = tracks.reduce((s, t) => s + t.segments.reduce((s2, seg) => s2 + seg.length, 0), 0);
238
+ const nseg = tracks.reduce((s, t) => s + t.segments.length, 0);
239
+ written.push(`${g.name}.gpx (${npts} pts, ${tracks.length} trk, ${nseg} seg)`);
193
240
  }
194
241
 
195
242
  console.log(`\ndone. processed=${ok} skipped=${skipped} failed=${failed}`);
196
243
  for (const w of written) console.log(` -> ${join(outDir, w)}`);
244
+
245
+ // ---- --html / --png: eval visualization of each group's merged track, additive to the .gpx above ----
246
+ // (analyzedLayers/analyzedSvg run core's noise-removal pipeline over the flattened group, under the
247
+ // same --mode as the .gpx above (core's own default when --mode is omitted) — so drop reasons / hdop
248
+ // overlays are visible without a separate `gpx-stabilizer --html` pass on the merged .gpx.)
249
+ if (opts.html || opts.png) {
250
+ // fed from the RAW groups (not the --mode-stabilized `tracks` written above) -- these views run
251
+ // their OWN analyze()/stabilize() pass internally, so raw points go in and `analyzeOpts` (below)
252
+ // carries the SAME --mode through, keeping the preview consistent with the .gpx without double
253
+ // -stabilizing anything.
254
+ const tracks = groups.map((g) => ({
255
+ name: g.name,
256
+ points: g.tracks.flatMap((t) => t.segments).flat(),
257
+ }));
258
+ const analyzeOpts = opts.mode ? { mode: opts.mode } : {};
259
+ if (opts.html) {
260
+ const htmlPath = join(outDir, "gopro-view.html");
261
+ // one toHtmlAnalyzedFiles() call analyzes every group (potentially slow — full analyze() over
262
+ // each merged day's track) with no other progress signal, so report per-group start/done.
263
+ const onProgress = ({ name, index, total, phase, ms }) => {
264
+ if (phase === "start") console.log(` [${index + 1}/${total}] analyzing ${name}...`);
265
+ else console.log(` [${index + 1}/${total}] ${name} done (${(ms / 1000).toFixed(1)}s)`);
266
+ };
267
+ writeFileSync(htmlPath, toHtmlAnalyzedFiles(tracks, { ...analyzeOpts, onProgress }));
268
+ console.log(`html -> ${htmlPath}`);
269
+ }
270
+ if (opts.png) {
271
+ const width = Number(opts.width ?? 1280);
272
+ const height = Number(opts.height ?? 720);
273
+ for (const t of tracks) {
274
+ const pngPath = join(outDir, `${t.name}.png`);
275
+ await savePng(analyzedSvg(t.points, { ...analyzeOpts, width, height }), pngPath);
276
+ console.log(`png -> ${pngPath}`);
277
+ }
278
+ }
279
+ }
280
+
281
+ // ---- --organize: only after every .gpx above is safely on disk ----
282
+ async function promptLine(question) {
283
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
284
+ try {
285
+ return await rl.question(question);
286
+ } finally {
287
+ rl.close();
288
+ }
289
+ }
290
+
291
+ if (opts.organize) {
292
+ const includeGpx = !outExplicit; // --out was explicit -> leave the .gpx where the user put it
293
+ const plan = planMove(entries, { root: opts.organize, outDir, includeGpx });
294
+ if (plan.files.length === 0) {
295
+ console.log("\n--organize: nothing to move (no successfully-extracted videos).");
296
+ } else {
297
+ const byDestDir = new Map();
298
+ let sidecarTotal = 0;
299
+ let cacheTotal = 0;
300
+ for (const f of plan.files) {
301
+ if (!byDestDir.has(f.destDir)) byDestDir.set(f.destDir, []);
302
+ byDestDir.get(f.destDir).push(f);
303
+ sidecarTotal += findSidecars(f.file).length;
304
+ const cp = cacheMovePlan(f.file, f.destPath, cache);
305
+ if (cp && existsSync(cp.from)) cacheTotal++;
306
+ }
307
+
308
+ console.log(`\n--organize preview: ${plan.files.length} video(s) -> ${opts.organize}`);
309
+ for (const [destDir, list] of byDestDir) {
310
+ console.log(` ${destDir}/`);
311
+ for (const f of list) console.log(` ${basename(f.file)}`);
312
+ }
313
+ if (plan.gpx.length)
314
+ console.log(` + ${plan.gpx.length} .gpx file(s) moving into their group folder`);
315
+ if (cacheTotal) console.log(` + ${cacheTotal} cache file(s) moving alongside (never deleted)`);
316
+ if (sidecarTotal) console.log(` + ${sidecarTotal} .LRV/.THM sidecar file(s) found`);
317
+
318
+ let sidecarAction = "delete";
319
+ let confirmed = true;
320
+ if (!opts.yes) {
321
+ if (!process.stdin.isTTY) {
322
+ console.log(
323
+ "--organize: stdin is not interactive — skipping without --yes (nothing moved).",
324
+ );
325
+ confirmed = false;
326
+ } else {
327
+ if (sidecarTotal > 0) {
328
+ const raw = await promptLine(
329
+ ` delete or move the ${sidecarTotal} sidecar file(s)? [delete/move] (default delete): `,
330
+ );
331
+ sidecarAction = raw.trim().toLowerCase().startsWith("m") ? "move" : "delete";
332
+ }
333
+ const raw = await promptLine(`\nProceed with moving ${plan.files.length} file(s)? [y/N] `);
334
+ confirmed = ["y", "yes"].includes(raw.trim().toLowerCase());
335
+ }
336
+ }
337
+
338
+ if (!confirmed) {
339
+ console.log("--organize: cancelled, nothing moved.");
340
+ } else {
341
+ const summary = executeMove(plan, { cache, sidecarAction });
342
+ console.log(
343
+ `\n--organize done. moved=${summary.moved} gpxMoved=${summary.gpxMoved} cacheMoved=${summary.cacheMoved} ` +
344
+ `sidecars(moved=${summary.sidecarsMoved},deleted=${summary.sidecarsDeleted}) ` +
345
+ `skippedCollisions=${summary.skippedCollisions} errors=${summary.errors.length}`,
346
+ );
347
+ for (const e of summary.errors) console.error(` ERROR ${e.file}: ${e.error}`);
348
+ }
349
+ }
350
+ }
package/src/group.js CHANGED
@@ -48,15 +48,81 @@ export function fileNumber(file) {
48
48
  * @property {string | null} session recording id = filename file-number (a recording's
49
49
  * chapters share it; a new recording / crash restart gets a new one), or null
50
50
  * @property {import("gpx-stabilizer").TrackPoint[]} points
51
+ * @property {string} [file] source video path — optional; also carried through for
52
+ * organize.js's planMove() (see ./organize.js), and used by buildGroups() itself to name each
53
+ * session's own <trk> after its earliest chapter (see {@link sessionName})
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} GpxTrack one <trk> within a GpxGroup — one recording session
58
+ * @property {string | null} name the session's own original video file's basename (no extension),
59
+ * or null when no file path was available to derive one (see {@link sessionName})
60
+ * @property {import("gpx-stabilizer").TrackPoint[][]} segments one per <trkseg> (a session usually
61
+ * has one, but (A)'s time-gap sub-split — see buildGroups — can produce several)
51
62
  */
52
63
 
53
64
  /**
54
65
  * @typedef {object} GpxGroup one output GPX
55
- * @property {string} name file stem (no extension)
56
- * @property {import("gpx-stabilizer").TrackPoint[][]} segments one per <trkseg>
66
+ * @property {string} name file stem (no extension) — the day+camera group name
67
+ * @property {GpxTrack[]} tracks one per recording session (<trk>), in chronological order
57
68
  * @property {number | null} startMs earliest real fix (epoch ms), for meta.time
58
69
  */
59
70
 
71
+ /**
72
+ * A session's own name for its `<trk>`: the earliest (chapter-order) contributing file's basename,
73
+ * no extension — e.g. chapters `GH010042.MP4`/`GH020042.MP4` sharing file-number "0042" both carry
74
+ * their file path here, and plain alphabetical sort picks `GH010042` (chapter 1) over `GH020042`
75
+ * (chapter 2), which also happens to hold for the older GOPR/GP01/GP02 scheme (`"GO" < "GP"`).
76
+ * `null` when no file path was available at all (e.g. a caller that never set `GroupEntry.file`).
77
+ * @param {string[]} files
78
+ * @returns {string | null}
79
+ */
80
+ function sessionName(files) {
81
+ if (!files || files.length === 0) return null;
82
+ const bases = files.map((f) => basename(f).replace(/\.[^.]+$/, ""));
83
+ bases.sort();
84
+ return bases[0];
85
+ }
86
+
87
+ /**
88
+ * Merge key = (camera, day): serial when known (two same-model bodies stay separate), else
89
+ * filename family. Exported so organize.js's planMove() can look a file's name up in the Map
90
+ * groupNames() returns, without recomputing the key by a possibly-drifted copy.
91
+ * @param {GroupEntry} e
92
+ * @returns {string}
93
+ */
94
+ export function gkeyOf(e) {
95
+ return e.serial ? `${e.date}|s:${e.serial}` : `${e.date}|f:${e.family}`;
96
+ }
97
+
98
+ /**
99
+ * The output name for each file's merge group (camera+day) — `<date>-<family>`, disambiguated
100
+ * with a short serial suffix only when two cameras collide on the same family+date. This is
101
+ * exactly the naming buildGroups() gives its `.gpx` files; exported so organize.js's planMove()
102
+ * can name mp4 folders identically without re-deriving the same logic (and risking drift).
103
+ * @param {GroupEntry[]} entries
104
+ * @returns {Map<string, string>} gkey -> name
105
+ */
106
+ export function groupNames(entries) {
107
+ const seen = new Map(); // gkey -> { date, family, serial }
108
+ for (const e of entries) {
109
+ const gkey = gkeyOf(e);
110
+ if (!seen.has(gkey))
111
+ seen.set(gkey, { date: e.date, family: e.family, serial: e.serial ?? null });
112
+ }
113
+ const clash = new Map(); // "date-family" -> distinct group count
114
+ for (const g of seen.values()) {
115
+ const base = `${g.date}-${g.family}`;
116
+ clash.set(base, (clash.get(base) ?? 0) + 1);
117
+ }
118
+ const names = new Map();
119
+ for (const [gkey, g] of seen) {
120
+ const base = `${g.date}-${g.family}`;
121
+ names.set(gkey, clash.get(base) > 1 && g.serial ? `${base}-${g.serial.slice(0, 8)}` : base);
122
+ }
123
+ return names;
124
+ }
125
+
60
126
  /**
61
127
  * Group extracted files into output GPX tracks.
62
128
  *
@@ -77,53 +143,43 @@ export function fileNumber(file) {
77
143
  * A crash still lands in the one daily file (merge key is serial+date) as a separate segment.
78
144
  * - Placeholder (0,0) pre-lock fixes are dropped per segment; a session that never
79
145
  * locks drops to empty, and a group with no real fix lands in `skipped`.
146
+ * - **Each session becomes its own `<trk>`** (`GpxTrack`), named after its own original video file
147
+ * (see {@link sessionName}) — kept distinct from the day+camera group `name` used for the `.gpx`
148
+ * file itself, so a multi-session day's file carries one `<trk>` per recording, not one big track.
80
149
  *
81
150
  * @param {GroupEntry[]} entries
82
151
  * @returns {{ groups: GpxGroup[], skipped: string[] }}
83
152
  */
84
153
  export function buildGroups(entries) {
85
- // gkey -> { date, family, serial, sessions: Map<fileNumber, points[]> }
154
+ const names = groupNames(entries);
155
+ // gkey -> sessions: Map<fileNumber, { points: [], files: [] }>
86
156
  const groups = new Map();
87
157
  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
- }
158
+ const gkey = gkeyOf(e);
159
+ if (!groups.has(gkey)) groups.set(gkey, { sessions: new Map() });
97
160
  const sessions = groups.get(gkey).sessions;
98
161
  // (B) bucket by file-number; files with none share one fallback bucket that (A) below splits.
99
162
  const skey = e.session ?? "__nofilenum__";
100
- if (!sessions.has(skey)) sessions.set(skey, []);
163
+ if (!sessions.has(skey)) sessions.set(skey, { points: [], files: [] });
164
+ const bucket = sessions.get(skey);
101
165
  // loop-push, not push(...points): a file can carry tens of thousands of points
102
166
  // 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);
167
+ for (const p of e.points) bucket.points.push(p);
168
+ if (e.file) bucket.files.push(e.file);
114
169
  }
115
170
 
116
171
  const out = [];
117
172
  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));
173
+ for (const [gkey, g] of groups) {
174
+ const name = names.get(gkey);
175
+ const tracks = [];
176
+ for (const { points, files } of g.sessions.values()) {
177
+ const clean = points.filter((p) => !PLACEHOLDER(p));
124
178
  if (clean.length === 0) continue;
125
179
  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.
180
+ // (A) sub-split this session wherever consecutive kept points jump more than BIG_GAP_MS
181
+ // all resulting <trkseg>s still belong to this ONE session's <trk>.
182
+ const segments = [];
127
183
  let run = [clean[0]];
128
184
  for (let i = 1; i < clean.length; i++) {
129
185
  if ((clean[i].time ?? 0) - (clean[i - 1].time ?? 0) > BIG_GAP_MS) {
@@ -133,20 +189,23 @@ export function buildGroups(entries) {
133
189
  run.push(clean[i]);
134
190
  }
135
191
  segments.push(run);
192
+ tracks.push({ name: sessionName(files), segments });
136
193
  }
137
- if (segments.length === 0) {
194
+ if (tracks.length === 0) {
138
195
  skipped.push(name);
139
196
  continue;
140
197
  }
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));
198
+ // order tracks by start time so the day's <trk>s read chronologically
199
+ tracks.sort((A, B) => (A.segments[0][0].time ?? 0) - (B.segments[0][0].time ?? 0));
143
200
  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;
201
+ for (const trk of tracks) {
202
+ for (const seg of trk.segments) {
203
+ for (const p of seg) {
204
+ if (p.time != null && (startMs === null || p.time < startMs)) startMs = p.time;
205
+ }
147
206
  }
148
207
  }
149
- out.push({ name, segments, startMs });
208
+ out.push({ name, tracks, startMs });
150
209
  }
151
210
  return { groups: out, skipped };
152
211
  }
@@ -0,0 +1,210 @@
1
+ // mp4 directory reorganization for gpx-from-gopro: move each source video into
2
+ // <root>/<group-name>/<session>/, mirroring the exact naming buildGroups() gives the
3
+ // corresponding .gpx (group.js's groupNames()/gkeyOf() — the single source of truth
4
+ // for that name, so the two never drift apart). planMove() is pure (no IO) so it's
5
+ // unit testable; gopro-cli.js prompts/prints and calls executeMove() to touch disk.
6
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
7
+ import { basename, dirname, join } from "node:path";
8
+ import { resolveCachePath } from "./gopro-cache.js";
9
+ import { gkeyOf, groupNames } from "./group.js";
10
+
11
+ const NO_SESSION = "no-session"; // folder for files with no parseable file-number
12
+ const SIDECAR_EXTS = ["lrv", "thm"]; // GoPro's per-chapter low-res preview / thumbnail
13
+
14
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+
16
+ /**
17
+ * @typedef {object} FilePlan
18
+ * @property {string} file source video path
19
+ * @property {string} group the file's group name (matches its .gpx base name)
20
+ * @property {string} destDir <root>/<group>/<session-or-"no-session">
21
+ * @property {string} destPath destDir/basename(file)
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} GpxPlan
26
+ * @property {string} group group name
27
+ * @property {string} from current .gpx path (in --out)
28
+ * @property {string} to <root>/<group>/<group>.gpx
29
+ */
30
+
31
+ /**
32
+ * Plan where each source video — and, when requested, its group's .gpx — should
33
+ * move to. Pure: no filesystem access, so it's cheap to unit test.
34
+ * @param {import("./group.js").GroupEntry[]} entries must carry `file`
35
+ * @param {{ root: string, outDir?: string, includeGpx?: boolean }} opts
36
+ * `includeGpx` moves each group's `<outDir>/<group>.gpx` into its folder too
37
+ * (skip when the caller wants gpx left in an explicitly-chosen --out).
38
+ * @returns {{ files: FilePlan[], gpx: GpxPlan[] }}
39
+ */
40
+ export function planMove(entries, { root, outDir, includeGpx = false }) {
41
+ const names = groupNames(entries);
42
+ const files = [];
43
+ const groupsSeen = new Set();
44
+ for (const e of entries) {
45
+ if (!e.file) continue;
46
+ const group = names.get(gkeyOf(e));
47
+ groupsSeen.add(group);
48
+ const session = e.session ?? NO_SESSION;
49
+ const destDir = join(root, group, session);
50
+ files.push({ file: e.file, group, destDir, destPath: join(destDir, basename(e.file)) });
51
+ }
52
+ const gpx = [];
53
+ if (includeGpx && outDir != null) {
54
+ for (const group of groupsSeen) {
55
+ gpx.push({
56
+ group,
57
+ from: join(outDir, `${group}.gpx`),
58
+ to: join(root, group, `${group}.gpx`),
59
+ });
60
+ }
61
+ }
62
+ return { files, gpx };
63
+ }
64
+
65
+ /**
66
+ * Sidecar files GoPro drops next to a chapter (same stem, `.LRV`/`.THM`) that
67
+ * actually exist on disk — a directory listing + case-insensitive match, not a
68
+ * handful of casing guesses, so it works on both case-sensitive and
69
+ * case-insensitive filesystems without double-counting.
70
+ * @param {string} mp4Path
71
+ * @returns {string[]} existing sidecar paths
72
+ */
73
+ export function findSidecars(mp4Path) {
74
+ const dir = dirname(mp4Path);
75
+ const stem = basename(mp4Path).replace(/\.[^.]+$/, "");
76
+ const re = new RegExp(`^${escapeRegExp(stem)}\\.(${SIDECAR_EXTS.join("|")})$`, "i");
77
+ let names;
78
+ try {
79
+ names = readdirSync(dir);
80
+ } catch {
81
+ return [];
82
+ }
83
+ return names.filter((n) => re.test(n)).map((n) => join(dir, n));
84
+ }
85
+
86
+ /**
87
+ * Move src -> dest via rename; falls back to copy+unlink on EXDEV (rename can't
88
+ * cross filesystems/devices — an SD card to an external drive, most commonly).
89
+ * `rename` is injectable so the EXDEV fallback is testable without a real
90
+ * cross-device pair.
91
+ * @param {string} src
92
+ * @param {string} dest
93
+ * @param {(src: string, dest: string) => void} [rename]
94
+ */
95
+ export function moveFile(src, dest, rename = renameSync) {
96
+ try {
97
+ rename(src, dest);
98
+ } catch (e) {
99
+ if (e?.code !== "EXDEV") throw e;
100
+ copyFileSync(src, dest);
101
+ unlinkSync(src);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Where a file's cache record currently lives (if caching is on) and where it
107
+ * should move to alongside the file — reuses resolveCachePath(), the SAME
108
+ * function the extraction cache itself uses, so this works for both the
109
+ * default sidecar mode and --cache-dir mode with no special-casing.
110
+ * @param {string} file
111
+ * @param {string} destPath
112
+ * @param {boolean | { dir?: string | null } | null} cache
113
+ * @returns {{ from: string, to: string } | null} null when caching is off
114
+ */
115
+ export function cacheMovePlan(file, destPath, cache) {
116
+ const from = resolveCachePath(file, cache);
117
+ if (from == null) return null;
118
+ return { from, to: resolveCachePath(destPath, cache) };
119
+ }
120
+
121
+ /**
122
+ * @typedef {object} MoveSummary
123
+ * @property {number} moved mp4 files moved
124
+ * @property {number} gpxMoved
125
+ * @property {number} cacheMoved
126
+ * @property {number} sidecarsMoved
127
+ * @property {number} sidecarsDeleted
128
+ * @property {number} skippedCollisions a destination already existed — never overwritten
129
+ * @property {{ file: string, error: string }[]} errors
130
+ */
131
+
132
+ /**
133
+ * Execute a plan built by planMove(): create dest dirs, move each mp4 (skip — never
134
+ * overwrite — on a destination collision), move its cache record alongside
135
+ * (best-effort; see cacheMovePlan), move each group's .gpx when plan.gpx has entries,
136
+ * and handle sidecars per `sidecarAction`.
137
+ * @param {{ files: FilePlan[], gpx: GpxPlan[] }} plan
138
+ * @param {{ cache?: boolean | { dir?: string|null } | null, sidecarAction?: "delete"|"move"|"skip" }} [opts]
139
+ * @returns {MoveSummary}
140
+ */
141
+ export function executeMove(plan, { cache = true, sidecarAction = "delete" } = {}) {
142
+ const summary = {
143
+ moved: 0,
144
+ gpxMoved: 0,
145
+ cacheMoved: 0,
146
+ sidecarsMoved: 0,
147
+ sidecarsDeleted: 0,
148
+ skippedCollisions: 0,
149
+ errors: [],
150
+ };
151
+
152
+ for (const f of plan.files) {
153
+ try {
154
+ if (existsSync(f.destPath)) {
155
+ summary.skippedCollisions++;
156
+ continue;
157
+ }
158
+ // sidecars are keyed off the file's ORIGINAL path (dirname/basename of a string,
159
+ // not a live handle), so finding them before or after the mp4 itself has moved
160
+ // is equivalent — the mp4's disappearance doesn't affect scanning its old directory.
161
+ const sidecars = sidecarAction !== "skip" ? findSidecars(f.file) : [];
162
+ const cachePlan = cacheMovePlan(f.file, f.destPath, cache);
163
+
164
+ mkdirSync(f.destDir, { recursive: true });
165
+ moveFile(f.file, f.destPath);
166
+ summary.moved++;
167
+
168
+ if (cachePlan && existsSync(cachePlan.from)) {
169
+ try {
170
+ moveFile(cachePlan.from, cachePlan.to);
171
+ summary.cacheMoved++;
172
+ } catch {
173
+ // best-effort: losing a cache record only costs a future re-extraction
174
+ }
175
+ }
176
+
177
+ for (const side of sidecars) {
178
+ if (sidecarAction === "delete") {
179
+ unlinkSync(side);
180
+ summary.sidecarsDeleted++;
181
+ } else {
182
+ const dest = join(f.destDir, basename(side));
183
+ if (!existsSync(dest)) {
184
+ moveFile(side, dest);
185
+ summary.sidecarsMoved++;
186
+ }
187
+ }
188
+ }
189
+ } catch (e) {
190
+ summary.errors.push({ file: f.file, error: e?.message ?? String(e) });
191
+ }
192
+ }
193
+
194
+ for (const g of plan.gpx) {
195
+ try {
196
+ if (!existsSync(g.from)) continue; // nothing written for this group (e.g. it was skipped)
197
+ if (existsSync(g.to)) {
198
+ summary.skippedCollisions++;
199
+ continue;
200
+ }
201
+ mkdirSync(dirname(g.to), { recursive: true });
202
+ moveFile(g.from, g.to);
203
+ summary.gpxMoved++;
204
+ } catch (e) {
205
+ summary.errors.push({ file: g.from, error: e?.message ?? String(e) });
206
+ }
207
+ }
208
+
209
+ return summary;
210
+ }
package/src/telemetry.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // docs/export-contract.md.
5
5
 
6
6
  import { statSync } from "node:fs";
7
- import { resample, stabilize } from "gpx-stabilizer";
7
+ import { loadModule, resample, stabilize } from "gpx-stabilizer";
8
8
  import tzlookup from "tz-lookup";
9
9
  import { extractGoproAll, probeGoproMeta } from "./gopro.js";
10
10
  import { CACHE_V, readCache, resolveCachePath, writeCache } from "./gopro-cache.js";
@@ -217,7 +217,9 @@ export async function readGoproSamples(path, opts = {}) {
217
217
  * regularises the cleaned points onto a uniform time grid (see {@link resample}) — it
218
218
  * **implies** `stabilize` (resampling raw, uncleaned points is meaningless), and
219
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.
220
+ * there is one point per frame; `cache` controls the on-disk extraction record. When cleaning
221
+ * runs on a HERO10 file, core's `gpsQuality` module is applied automatically (gated on
222
+ * `meta.model` — see the module's doc for why it's model-specific, not a general default).
221
223
  * @returns {Promise<TelemetryResult>}
222
224
  */
223
225
  export async function readGoproTelemetry(path, opts = {}) {
@@ -246,9 +248,18 @@ export async function readGoproTelemetry(path, opts = {}) {
246
248
  if (opts.resample && opts.stabilize === false) {
247
249
  throw new Error("readGoproTelemetry: `resample` requires cleaning — drop `stabilize: false`");
248
250
  }
251
+ // Hero10's GPS chip needs its own quality gate (core's `gpsQuality` module — see its doc): its
252
+ // hdop baseline runs 5-10x higher than a Hero5's on the same trip, so the threshold is calibrated
253
+ // to that one chip generation and must not silently apply to every model. `gpsQuality` is opt-in
254
+ // (not a core builtin) specifically so this decision lives here, where `meta.model` is known.
255
+ const gxModules = meta.model === "HERO10" ? [await loadModule("gpsQuality")] : [];
256
+ const stabilizeOpts = opts.stabilize && opts.stabilize !== true ? opts.stabilize : {};
249
257
  const cleaned =
250
258
  opts.stabilize || opts.resample
251
- ? stabilize(raw, opts.stabilize && opts.stabilize !== true ? opts.stabilize : {})
259
+ ? stabilize(raw, {
260
+ ...stabilizeOpts,
261
+ modules: [...(stabilizeOpts.modules ?? []), ...gxModules],
262
+ })
252
263
  : raw;
253
264
 
254
265
  let points = cleaned;