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 +25 -0
- package/package.json +2 -2
- package/src/gopro-cli.js +165 -11
- package/src/group.js +97 -38
- package/src/organize.js +210 -0
- package/src/telemetry.js +14 -3
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
|
+
"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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
192
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
104
|
-
|
|
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
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
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 (
|
|
194
|
+
if (tracks.length === 0) {
|
|
138
195
|
skipped.push(name);
|
|
139
196
|
continue;
|
|
140
197
|
}
|
|
141
|
-
// order
|
|
142
|
-
|
|
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
|
|
145
|
-
for (const
|
|
146
|
-
|
|
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,
|
|
208
|
+
out.push({ name, tracks, startMs });
|
|
150
209
|
}
|
|
151
210
|
return { groups: out, skipped };
|
|
152
211
|
}
|
package/src/organize.js
ADDED
|
@@ -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,
|
|
259
|
+
? stabilize(raw, {
|
|
260
|
+
...stabilizeOpts,
|
|
261
|
+
modules: [...(stabilizeOpts.modules ?? []), ...gxModules],
|
|
262
|
+
})
|
|
252
263
|
: raw;
|
|
253
264
|
|
|
254
265
|
let points = cleaned;
|