@nika-js/onlymap 0.6.15 → 0.6.17

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +2 -2
  3. package/bin/onlymapjs.mjs +287 -24
  4. package/dist/{LercDecode.es-BWmtr6fp.js → LercDecode.es-DjPA9zLm.js} +1 -1
  5. package/dist/actions.d.ts +0 -9
  6. package/dist/{basemap-DQeUv-pk.js → basemap-DwN5jPCj.js} +1 -1
  7. package/dist/elements/om-map.d.ts +7 -0
  8. package/dist/elements/om-story.d.ts +29 -0
  9. package/dist/{geoparquet-DsYXvvEo.js → geoparquet-DG3VtNgs.js} +1 -1
  10. package/dist/html-data.d.ts +1 -1
  11. package/dist/{index-BTJy4dWy.js → index-BM-txQRw.js} +1 -1
  12. package/dist/{index-Bnkk4jcp.js → index-CX4qxgVT.js} +48373 -48080
  13. package/dist/{index-DMRuqiXV.js → index-DceORL-v.js} +2 -2
  14. package/dist/{index-CbHslg3u.js → index-DlXIfdhF.js} +1 -1
  15. package/dist/{index-gMV11Nr5.js → index-Yl9rVUWh.js} +1 -1
  16. package/dist/layers/feature-mesh-layer.d.ts +7 -7
  17. package/dist/layers/warm-tile3d-layer.d.ts +24 -0
  18. package/dist/{lerc-Cw1YMyb0.js → lerc-BD1Any2Z.js} +2 -2
  19. package/dist/onlymap.standalone.js +59243 -58950
  20. package/dist/onlymapjs.js +1 -1
  21. package/dist/paced-flyby.d.ts +51 -0
  22. package/dist/{raster-BJaQlAzq.js → raster-Cv5w_kS7.js} +2 -2
  23. package/dist/{raster-pipeline-BCVaME9y.js → raster-pipeline-Nl0EF5em.js} +1 -1
  24. package/dist/runtime-core.d.ts +6 -0
  25. package/dist/telemetry-schema.d.ts +10 -2
  26. package/dist/telemetry.d.ts +4 -0
  27. package/dist/tile-warm.d.ts +144 -0
  28. package/dist/version.d.ts +1 -1
  29. package/dist/{zarr-C6xeWGwX.js → zarr-aau6eARi.js} +2 -2
  30. package/docs/design/paced-flyby.md +42 -0
  31. package/docs/stories.md +22 -0
  32. package/docs/telemetry.md +2 -0
  33. package/llms.txt +4 -2
  34. package/onlymapjs.html-data.json +18 -0
  35. package/package.json +1 -1
  36. package/skills/onlymapjs/SKILL.md +1 -1
  37. package/skills/onlymapjs/references/syntax.md +12 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ Note: npm collapsed a few closely-spaced releases — the GPX/FlatGeobuf (0.5.4)
8
8
  and GeoParquet (0.5.5) work shipped to npm together as **0.5.6**, so npm's
9
9
  version list jumps 0.5.3 → 0.5.6. Each logical version is listed here regardless.
10
10
 
11
+ ## 0.6.17 — 2026-08-25
12
+
13
+ ### Added
14
+ - **Sharp story flybys over 3D tiles.** `<om-story warm-tiles>` pre-loads every 3D tileset's tiles along the story's camera route in the background before playback, so fly-bys no longer pop in or refine from blurry to clear mid-flight. Also available as the `warm-tiles` action for one-off takes; per-layer `load-options='{"tileset":{"maximumMemoryUsage":512}}'` keeps more tiles cached for long routes.
15
+ - **Flawless flyby takes with `<om-story paced>`.** Where pre-loading isn't enough (very heavy scenes like Google Photorealistic 3D Tiles), a paced story holds each frame until the 3D tiles under the camera have fully sharpened before moving on — the flight takes longer to play, but no frame ever shows blurry tiles, which is exactly what a recorded take needs. Combine with `warm-tiles` to shorten the holds; each frame reports its wait through the new `om-paced-tick` event.
16
+ - **Record a story straight to video: `npx onlymapjs record map.html --out flyby.mp4`.** Plays the story load-paced in headless Chromium and writes an H.264/VP9 video in which every frame's 3D tiles are fully sharp — widgets, overlays, and provider attribution included. Uses ffmpeg when installed (otherwise you get the PNG frames plus the exact command to run); `--fps`, `--width/--height/--scale` (retina takes), `--story`, `--keep-frames`, `--timeout`, `--max-hold`. Frames captured before a deadline hit are kept for salvage instead of discarded. Custom recorders can hook the same seam with `storyEl.setPacedCapture(...)` — the story waits for your capture before advancing, so screenshots can never tear between frames.
17
+ - **`--gpu` makes recordings several times faster on heavy 3D scenes.** Headless Chromium renders on software GL by default, which slows every tile-refinement round; `--gpu` switches `onlymapjs record` to the machine's real GPU (measured ~3× shorter tile holds on a Google Photorealistic 3D Tiles take, identical output sharpness). Recommended whenever a recording holds long for tiles.
18
+ - **`paced-max-hold` on `<om-story>`** tunes how long a paced frame may wait for tiles before moving on (default 10s — the safety valve that keeps a dead tile server from freezing playback). Very heavy scenes can genuinely need longer per frame; raise it (e.g. `paced-max-hold="30s"`, or `--max-hold 30` when recording), or set `"none"` to wait unconditionally — the truly-no-blurry-frame mode for recorded takes.
19
+
20
+ ### Fixed
21
+ - **Story fly-to steps written as `longitude="…" latitude="…"` now actually pan the camera.** Previously only the documented `center="[lng, lat]"` form moved the map (bare longitude/latitude were silently ignored by playback, though route pre-loading read them); both forms now work everywhere, and zoom-only fly-to steps are included in route pre-loading too.
22
+ - **Warm-up passes are sturdier.** Pre-loading now waits for tilesets that are still fetching their `tileset.json` instead of silently warming nothing; a partial warm on any tileset is reported as partial; overlapping warm passes no longer leave a tileset rendering coarser than authored; and `samples`/`budget` values written as attributes (strings) are honored.
23
+ - **Paced playback is sturdier too.** Pausing then seeking in the same breath no longer loses the camera position; a failing recorder capture pauses the story instead of wedging it; and a dead tile server on the final frame can always be escaped with pause — even under `paced-max-hold="none"`.
24
+
25
+ ## 0.6.16 — 2026-08-24
26
+
27
+ ### Added
28
+ - **Packaged mobile apps now identify themselves in usage telemetry.** `configureTelemetry({ platform, appId })` lets a native host (like `@nika-js/onlymap-native`) report the store app identity and platform instead of a meaningless embedded-WebView hostname. Web pages are unaffected; the same privacy rules apply.
29
+
30
+ ### Fixed
31
+ - **A license key that cannot verify now says so on the page.** In a context without `crypto.subtle` (an insecure or misconfigured embed), key verification used to log only a console warning while the map silently ran in the free tier; it now also raises a structured validation error with a fix hint through the normal error channel.
11
32
  ## 0.6.15 — 2026-08-21
12
33
 
13
34
  ### Changed
package/README.md CHANGED
@@ -147,7 +147,7 @@ Built-in actions wire to picks, widget buttons (`data-emit`), or script (`ctx.em
147
147
 
148
148
  **Animation:** camera moves accept a duration — `map.flyTo(coords, zoom, { duration: 1200, curve: true })`, or the `fly-to` action (`center`/`zoom`/`pitch`/`bearing`/`duration`) from any behavior or button; `prefers-reduced-motion` is honored (moves become instant, final state identical). Per-prop GPU transitions via the `transition` attribute: `transition="get-fill-color 800ms"` fades color changes; on a streaming layer, `transition="get-position 300ms"` makes entities glide between updates.
149
149
 
150
- **Map stories:** a guided tour as markup — `<om-story>` holds `<om-step>` children that fire the same actions behaviors use, on a timeline (`duration`, `delay`, `parallel`); the built-in `type="player"` widget gives play/pause/scrub, seeking restores the scene's captured initial state, one story is active per map, and grabbing the map pauses playback. Effect verbs — `fade`, `pulse`, `trace` (progressive TripsLayer draw-on; with `feature-id`, a single polygon draws itself on inside its own layer), and `populate` (rows drop in one by one via a GPU filter sweep) — work as step shorthands or plain actions. The story is a sibling that references layers by id — delete it and the map is unchanged. Guide: [docs/stories.md](docs/stories.md).
150
+ **Map stories:** a guided tour as markup — `<om-story>` holds `<om-step>` children that fire the same actions behaviors use, on a timeline (`duration`, `delay`, `parallel`); the built-in `type="player"` widget gives play/pause/scrub, seeking restores the scene's captured initial state, one story is active per map, and grabbing the map pauses playback. Effect verbs — `fade`, `pulse`, `trace` (progressive TripsLayer draw-on; with `feature-id`, a single polygon draws itself on inside its own layer), and `populate` (rows drop in one by one via a GPU filter sweep) — work as step shorthands or plain actions. The story is a sibling that references layers by id — delete it and the map is unchanged. For flybys over 3D tilesets, `warm-tiles` pre-loads the route's tiles in the background before playback, and `paced` goes further: the story steps its own clock and never advances a frame while a tileset is still refining, so no frame ever shows unrefined tiles — wall-clock stretches instead (built for recorded takes; emits `om-paced-tick {t, waitedMs}` per frame, and the two compose — warm first, wait less). `npx @nika-js/onlymap record map.html --out flyby.mp4` turns that guarantee into an actual video: one screenshot per paced frame in headless Chromium (widgets and attribution included), assembled with ffmpeg. Guide: [docs/stories.md](docs/stories.md).
151
151
 
152
152
  **Classified symbology:** `classify-by="magnitude" classify-scale="jenks" classify-classes="5" classify-ramp="viridis"` computes class breaks *from the data* (quantile, equal-interval, or Jenks natural breaks), styles the layer with a named ramp, and auto-generates the classes legend — no domains or colors to hand-author; an explicit `get-fill-color`/`color` always wins. **Temporal playback:** `<om-widget type="time-slider" layer="…" field="time" duration="20s" window="86400000" format="date">` is a play/pause/scrub control over a layer's time field, driving the same `filter-layer` mechanics as the filter widget (stats stay coherent; `loop` restarts the sweep).
153
153
 
@@ -337,7 +337,7 @@ The `appId` must come from trusted native build metadata, never page/bridge inpu
337
337
 
338
338
  ## Telemetry
339
339
 
340
- The library reports one **deployment-scoped** usage snapshot per map per page load — layer types and counts, widget types, renderer; hostname only, no page URLs, no visitor identifiers, and `headless` (test) maps never report — plus errors caused by the library's own code (own-bundle stack filtering, scrubbed, rate-limited). Reports go to a first-party endpoint, never a third-party domain. Opt out globally with `OmMap.configureTelemetry({ disabled: true })` or per map with `telemetry="off"`. Full schema, rules, and the license disclosure: [docs/telemetry.md](docs/telemetry.md), LICENSE.md §11.
340
+ The library reports one **deployment-scoped** usage snapshot per map per page load — layer types and counts, widget types, renderer; hostname only (a packaged mobile app reports its declared app id instead, since an embedded WebView's hostname identifies nothing), no page URLs, no visitor identifiers, and `headless` (test) maps never report — plus errors caused by the library's own code (own-bundle stack filtering, scrubbed, rate-limited). Reports go to a first-party endpoint, never a third-party domain. Opt out globally with `OmMap.configureTelemetry({ disabled: true })` or per map with `telemetry="off"`. Full schema, rules, and the license disclosure: [docs/telemetry.md](docs/telemetry.md), LICENSE.md §11.
341
341
 
342
342
  ## Not implemented yet (honestly)
343
343
 
package/bin/onlymapjs.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import { createServer } from "node:http";
4
- import { dirname, extname, join, resolve, sep } from "node:path";
5
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
 
7
8
  const CUSTOM_DATA = "./node_modules/@nika-js/onlymap/onlymapjs.html-data.json";
@@ -11,6 +12,7 @@ function usage() {
11
12
  console.log(`Usage:
12
13
  onlymapjs init [--force]
13
14
  onlymapjs check-layout <manifest.html>
15
+ onlymapjs record <manifest.html> [options]
14
16
  onlymapjs --help
15
17
 
16
18
  Commands:
@@ -18,12 +20,62 @@ Commands:
18
20
  check-layout
19
21
  Load a browser-runnable manifest in isolated headless Chromium,
20
22
  audit real widget geometry at 360/640/768/1024px, and exit 0/1.
21
-
22
- Options:
23
+ record Play a story load-paced in isolated headless Chromium and write a
24
+ video in which every frame's 3D tiles are fully refined — no
25
+ pop-in, no blur, however slow the tile server. Captures one
26
+ screenshot per story frame (widgets, overlays, and attribution
27
+ included) and assembles with ffmpeg when available (PNG frames +
28
+ a printed ffmpeg command otherwise). Needs Playwright.
29
+
30
+ Options (init):
23
31
  --force Overwrite .vscode/${SNIPPET_FILE} if it already exists.
32
+
33
+ Options (record):
34
+ --story <id> Which <om-story> to record (default: the first one).
35
+ --out <file> Output path; .mp4 (H.264) or .webm (VP9). Default <manifest>.mp4.
36
+ --fps <n> Story frames per second (default: the story's paced="…" value, else 30).
37
+ --width <px> Viewport width in CSS px (default 1280).
38
+ --height <px> Viewport height in CSS px (default 720).
39
+ --scale <n> Device scale factor — 2 renders retina frames (default 1).
40
+ --gpu Render on the real GPU (ANGLE Metal on macOS) instead of
41
+ headless Chromium's default software GL — measured ~3×
42
+ shorter tile holds on Google Photorealistic 3D Tiles.
43
+ --keep-frames Keep the PNG frame directory next to the output.
44
+ --timeout <s> Overall recording deadline in seconds (default 600). Frames
45
+ captured before a deadline hit are kept for salvage.
46
+ --max-hold <s|none>
47
+ Per-frame tile-wait cap (default 10). Heavy tilesets that
48
+ keep hitting the cap may carry residual blur — raise it, or
49
+ pass "none" to wait unconditionally for full refinement
50
+ (guaranteed-sharp; only --timeout bounds the run then).
24
51
  `);
25
52
  }
26
53
 
54
+ async function importPlaywright(commandName) {
55
+ try {
56
+ return await import("playwright");
57
+ } catch (error) {
58
+ if (error?.code === "ERR_MODULE_NOT_FOUND" || String(error).includes("Cannot find package 'playwright'")) {
59
+ throw new Error(
60
+ `${commandName} needs Playwright in this project.\n` +
61
+ "Install it with:\n npm install --save-dev playwright\n npx playwright install chromium",
62
+ );
63
+ }
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ async function launchChromium(playwright, args = []) {
69
+ try {
70
+ return await playwright.chromium.launch({ headless: true, args });
71
+ } catch (error) {
72
+ if (String(error).includes("Executable doesn't exist") || String(error).includes("browserType.launch")) {
73
+ throw new Error(`Chromium is not installed for Playwright.\nRun:\n npx playwright install chromium\n\n${String(error)}`);
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+
27
79
  const MIME_TYPES = {
28
80
  ".html": "text/html; charset=utf-8",
29
81
  ".js": "text/javascript; charset=utf-8",
@@ -107,18 +159,7 @@ async function checkLayout(manifestArg) {
107
159
  throw new Error(`check-layout expects an .html manifest, received: ${manifestPath}`);
108
160
  }
109
161
 
110
- let playwright;
111
- try {
112
- playwright = await import("playwright");
113
- } catch (error) {
114
- if (error?.code === "ERR_MODULE_NOT_FOUND" || String(error).includes("Cannot find package 'playwright'")) {
115
- throw new Error(
116
- "check-layout needs Playwright in this project.\n" +
117
- "Install it with:\n npm install --save-dev playwright\n npx playwright install chromium",
118
- );
119
- }
120
- throw error;
121
- }
162
+ const playwright = await importPlaywright("check-layout");
122
163
 
123
164
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
124
165
  const { auditLayout } = await import(new URL("../dist/onlymapjs.js", import.meta.url));
@@ -127,14 +168,7 @@ async function checkLayout(manifestArg) {
127
168
  try {
128
169
  console.log(`Auditing ${manifestPath}`);
129
170
  console.log("Note: check-layout executes this page's scripts inside an isolated headless Chromium context.");
130
- try {
131
- browser = await playwright.chromium.launch({ headless: true });
132
- } catch (error) {
133
- if (String(error).includes("Executable doesn't exist") || String(error).includes("browserType.launch")) {
134
- throw new Error(`Chromium is not installed for Playwright.\nRun:\n npx playwright install chromium\n\n${String(error)}`);
135
- }
136
- throw error;
137
- }
171
+ browser = await launchChromium(playwright);
138
172
  const context = await browser.newContext({ serviceWorkers: "block" });
139
173
  const page = await context.newPage();
140
174
  const pageErrors = [];
@@ -165,6 +199,233 @@ async function checkLayout(manifestArg) {
165
199
  }
166
200
  }
167
201
 
202
+ function parseRecordArgs(rest) {
203
+ const opts = { manifest: undefined, out: undefined, story: undefined, fps: undefined, width: 1280, height: 720, scale: 1, keepFrames: false, timeoutS: 600, maxHoldS: undefined };
204
+ const numberFor = (flag, raw) => {
205
+ const value = Number(raw);
206
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`${flag} needs a positive number, received: ${raw}`);
207
+ return value;
208
+ };
209
+ for (let i = 0; i < rest.length; i += 1) {
210
+ const arg = rest[i];
211
+ const next = () => {
212
+ i += 1;
213
+ if (i >= rest.length) throw new Error(`${arg} needs a value.`);
214
+ return rest[i];
215
+ };
216
+ if (arg === "--out") opts.out = next();
217
+ else if (arg === "--story") opts.story = next();
218
+ else if (arg === "--fps") opts.fps = numberFor("--fps", next());
219
+ else if (arg === "--width") opts.width = numberFor("--width", next());
220
+ else if (arg === "--height") opts.height = numberFor("--height", next());
221
+ else if (arg === "--scale") opts.scale = numberFor("--scale", next());
222
+ else if (arg === "--keep-frames") opts.keepFrames = true;
223
+ else if (arg === "--gpu") opts.gpu = true;
224
+ else if (arg === "--timeout") opts.timeoutS = numberFor("--timeout", next());
225
+ else if (arg === "--max-hold") {
226
+ const raw = next();
227
+ opts.maxHoldS = raw === "none" ? "none" : numberFor("--max-hold", raw);
228
+ }
229
+ else if (arg.startsWith("--")) throw new Error(`Unknown record option: ${arg}`);
230
+ else if (opts.manifest === undefined) opts.manifest = arg;
231
+ else throw new Error(`Unexpected argument: ${arg}`);
232
+ }
233
+ if (!opts.manifest) throw new Error("record needs a manifest HTML path.\nUsage: onlymapjs record <manifest.html> [options]");
234
+ return opts;
235
+ }
236
+
237
+ function ffmpegAvailable() {
238
+ const probe = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" });
239
+ return !probe.error && probe.status === 0;
240
+ }
241
+
242
+ function ffmpegArgs(framesDir, outPath, fps) {
243
+ const codec =
244
+ extname(outPath).toLowerCase() === ".webm"
245
+ ? ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30"]
246
+ : ["-c:v", "libx264", "-crf", "18", "-movflags", "+faststart"];
247
+ // The crop keeps dimensions even — yuv420p (the compatibility pixel format) requires it.
248
+ return ["-y", "-framerate", String(fps), "-start_number", "0", "-i", join(framesDir, "%06d.png"), "-vf", "crop=trunc(iw/2)*2:trunc(ih/2)*2", ...codec, "-pix_fmt", "yuv420p", outPath];
249
+ }
250
+
251
+ async function recordStory(rest) {
252
+ const opts = parseRecordArgs(rest);
253
+ const manifestPath = resolve(process.cwd(), opts.manifest);
254
+ if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) throw new Error(`Manifest not found: ${manifestPath}`);
255
+ if (extname(manifestPath).toLowerCase() !== ".html") throw new Error(`record expects an .html manifest, received: ${manifestPath}`);
256
+ const outPath = resolve(process.cwd(), opts.out ?? `${basename(manifestPath, ".html")}.mp4`);
257
+ const outExt = extname(outPath).toLowerCase();
258
+ if (outExt !== ".mp4" && outExt !== ".webm") throw new Error(`--out must end in .mp4 or .webm, received: ${outPath}`);
259
+
260
+ const playwright = await importPlaywright("record");
261
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
262
+ const { server, url } = await startStaticServer(manifestPath, packageRoot);
263
+ // Frames go next to the output and SURVIVE a failed run — a deadline hit
264
+ // after 70 tile-bound frames must salvage them, not discard them. Only a
265
+ // fully successful encode (without --keep-frames) cleans up. Cleared up
266
+ // front so a shorter re-run can't inherit stale higher-numbered frames.
267
+ const framesDir = `${outPath}.frames`;
268
+ rmSync(framesDir, { recursive: true, force: true });
269
+ mkdirSync(framesDir, { recursive: true });
270
+
271
+ let browser;
272
+ let frames = 0;
273
+ try {
274
+ console.log(`Recording ${manifestPath}`);
275
+ console.log("Note: record executes this page's scripts inside an isolated headless Chromium context.");
276
+ // Headless Chromium renders on SwiftShader (software GL) by default —
277
+ // tile refinement advances on the page's render loop, so heavy 3D scenes
278
+ // hold far longer than they would on the user's GPU. --gpu opts into
279
+ // hardware rendering (ANGLE Metal on macOS, default ANGLE elsewhere).
280
+ browser = await launchChromium(playwright, opts.gpu ? [process.platform === "darwin" ? "--use-angle=metal" : "--use-angle=default"] : []);
281
+ if (opts.gpu) console.log("GPU rendering requested (--gpu).");
282
+ else console.log("Tip: --gpu renders on the real GPU — typically several times shorter tile holds on heavy 3D scenes.");
283
+ const context = await browser.newContext({
284
+ serviceWorkers: "block",
285
+ viewport: { width: Math.round(opts.width), height: Math.round(opts.height) },
286
+ deviceScaleFactor: opts.scale,
287
+ });
288
+ const page = await context.newPage();
289
+ const pageErrors = [];
290
+ page.on("pageerror", (error) => pageErrors.push(String(error).split("\n")[0]));
291
+ // om-tiles-warmed fires on <om-map> before our handlers can attach —
292
+ // catch it from the capture phase at the document, from t=0.
293
+ await page.addInitScript(() => {
294
+ window.__omRecord = { warmed: null };
295
+ document.addEventListener("om-tiles-warmed", (event) => (window.__omRecord.warmed = event.detail), true);
296
+ });
297
+ await page.goto(url, { waitUntil: "domcontentloaded" });
298
+
299
+ let info;
300
+ try {
301
+ info = await page.evaluate(
302
+ async ({ storyId }) => {
303
+ const story = storyId ? document.getElementById(storyId) : document.querySelector("om-story");
304
+ if (!story || story.tagName.toLowerCase() !== "om-story") {
305
+ throw new Error(storyId ? `No <om-story id="${storyId}"> on this page.` : "No <om-story> on this page.");
306
+ }
307
+ const mapEl = story.closest("om-map");
308
+ if (!mapEl) throw new Error("The story is not inside an <om-map>.");
309
+ await mapEl.ready;
310
+ mapEl.setAttribute("data-om-recording", "");
311
+ const looped = story.hasAttribute("loop");
312
+ story.removeAttribute("loop"); // a looping take never ends — record one pass
313
+ return {
314
+ hasWarm: story.hasAttribute("warm-tiles"),
315
+ authoredFps: Number(story.getAttribute("paced")) > 0 ? Number(story.getAttribute("paced")) : null,
316
+ durationMs: story.duration,
317
+ looped,
318
+ };
319
+ },
320
+ { storyId: opts.story ?? null },
321
+ );
322
+ } catch (error) {
323
+ const detail = pageErrors.length ? `\nPage errors:\n ${pageErrors.slice(0, 3).join("\n ")}` : "";
324
+ throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`);
325
+ }
326
+ if (info.looped) console.log("Story has loop — recording a single pass.");
327
+
328
+ const fps = opts.fps ?? info.authoredFps ?? 30;
329
+ // The driver advances BEFORE its first tick (no t=0 frame): D ms at f fps
330
+ // is exactly ceil(D*f/1000) frames; a zero-duration story still ticks once.
331
+ const estFrames = Math.max(1, Math.ceil((info.durationMs * fps) / 1000));
332
+ console.log(`Story duration ${(info.durationMs / 1000).toFixed(1)}s → ~${estFrames} frames at ${fps} story-fps.`);
333
+
334
+ if (info.hasWarm) {
335
+ console.log("Story has warm-tiles — waiting for the route pre-load (shortens per-frame waits)…");
336
+ await page
337
+ .waitForFunction(() => window.__omRecord.warmed, undefined, { timeout: 240_000 })
338
+ .catch(() => console.log("warm-tiles did not finish in 4 min — recording anyway (pacing covers correctness)."));
339
+ }
340
+
341
+ const mapHandle = await page.$("om-map[data-om-recording]");
342
+ if (!mapHandle) throw new Error("Could not locate the recording <om-map>.");
343
+ let waitedTotal = 0;
344
+ await page.exposeFunction("__omRecordFrame", async (t, waitedMs) => {
345
+ await mapHandle.screenshot({ path: join(framesDir, `${String(frames).padStart(6, "0")}.png`) });
346
+ frames += 1;
347
+ waitedTotal += waitedMs;
348
+ const pct = info.durationMs > 0 ? Math.min(100, Math.round((t / info.durationMs) * 100)) : 0;
349
+ process.stdout.write(`\r frame ${frames}${estFrames ? `/${estFrames}` : ""} (${pct}%) — story t=${(t / 1000).toFixed(2)}s, held ${(waitedTotal / 1000).toFixed(1)}s for tiles `);
350
+ });
351
+
352
+ let timeoutHandle;
353
+ const deadline = new Promise((_, reject) => {
354
+ timeoutHandle = setTimeout(
355
+ () =>
356
+ reject(
357
+ new Error(
358
+ `record hit the ${opts.timeoutS}s deadline (--timeout raises it). ${frames} frame(s) salvaged in ${framesDir} — assemble the partial take with:\n ffmpeg ${ffmpegArgs(framesDir, outPath, fps).join(" ")}${pageErrors.length ? `\nPage errors (possible real cause):\n ${pageErrors.slice(0, 3).join("\n ")}` : ""}`,
359
+ ),
360
+ ),
361
+ opts.timeoutS * 1000,
362
+ );
363
+ });
364
+ try {
365
+ await Promise.race([
366
+ page.evaluate(
367
+ ({ storyId, fps: pacedFps, maxHoldS }) =>
368
+ new Promise((resolveDone, rejectDone) => {
369
+ const story = storyId ? document.getElementById(storyId) : document.querySelector("om-story");
370
+ // An autoplay story is already PLAYING un-paced by now (its
371
+ // ready-hook beat this evaluate) and play() would no-op — reset
372
+ // it so the paced driver actually takes over.
373
+ if (story.state !== "idle") {
374
+ story.pause();
375
+ story.seek(0);
376
+ }
377
+ story.setAttribute("paced", String(pacedFps));
378
+ if (maxHoldS) story.setAttribute("paced-max-hold", maxHoldS === "none" ? "none" : `${maxHoldS}s`);
379
+ story.setPacedCapture((tick) => window.__omRecordFrame(tick.t, tick.waitedMs));
380
+ // om-paced-tick fires AFTER the frame's capture resolved, so the
381
+ // ended-state tick means every frame is on disk.
382
+ story.addEventListener("om-paced-tick", () => {
383
+ if (story.state === "ended") {
384
+ story.setPacedCapture(null);
385
+ resolveDone();
386
+ }
387
+ });
388
+ // A run that stops without ending (a rejecting capture lands the
389
+ // story in "paused"; a page script pausing it) must fail fast,
390
+ // not idle to the deadline.
391
+ story.addEventListener("om-story-tick", (event) => {
392
+ if (event.detail.state === "paused") rejectDone(new Error('The story stopped mid-record (state "paused") — a failing capture or a page script paused it.'));
393
+ });
394
+ try {
395
+ story.play();
396
+ } catch (error) {
397
+ rejectDone(error);
398
+ }
399
+ }),
400
+ { storyId: opts.story ?? null, fps, maxHoldS: opts.maxHoldS ?? null },
401
+ ),
402
+ deadline,
403
+ ]);
404
+ } finally {
405
+ clearTimeout(timeoutHandle);
406
+ }
407
+ process.stdout.write("\n");
408
+ if (frames === 0) throw new Error("The story produced no frames — does it have any steps?");
409
+
410
+ if (ffmpegAvailable()) {
411
+ console.log(`Encoding ${outPath} …`);
412
+ const run = spawnSync("ffmpeg", ffmpegArgs(framesDir, outPath, fps), { stdio: ["ignore", "ignore", "pipe"] });
413
+ if (run.status !== 0) throw new Error(`ffmpeg failed:\n${run.stderr?.toString().slice(-2000) ?? "(no stderr)"}`);
414
+ console.log(`DONE: ${outPath} — ${frames} frames at ${fps} fps, every frame fully refined.`);
415
+ if (opts.keepFrames) console.log(`Frames kept in ${framesDir}`);
416
+ else rmSync(framesDir, { recursive: true, force: true }); // full success — the video subsumes the frames
417
+ return 0;
418
+ }
419
+ // No ffmpeg: the frames ARE the deliverable.
420
+ console.log(`ffmpeg not found — ${frames} PNG frame(s) written to ${framesDir}`);
421
+ console.log(`Assemble with:\n ffmpeg ${ffmpegArgs(framesDir, outPath, fps).join(" ")}`);
422
+ return 0;
423
+ } finally {
424
+ await browser?.close();
425
+ await new Promise((resolveClose) => server.close(resolveClose));
426
+ }
427
+ }
428
+
168
429
  function parseJsonc(text) {
169
430
  let out = "";
170
431
  let inString = false;
@@ -320,6 +581,8 @@ try {
320
581
  } else if (command === "check-layout") {
321
582
  if (rest.length !== 1) throw new Error("Usage: onlymapjs check-layout <manifest.html>");
322
583
  process.exitCode = await checkLayout(rest[0]);
584
+ } else if (command === "record") {
585
+ process.exitCode = await recordStory(rest);
323
586
  } else {
324
587
  console.error(`Unknown command: ${command}\n`);
325
588
  usage();
@@ -19,7 +19,7 @@ async function v(N = {}) {
19
19
  if (n) {
20
20
  const { createRequire: I } = await import(
21
21
  /*webpackIgnore:true*/
22
- "./lerc-Cw1YMyb0.js"
22
+ "./lerc-BD1Any2Z.js"
23
23
  ).then((g) => g._);
24
24
  var O = I(import.meta.url);
25
25
  }
package/dist/actions.d.ts CHANGED
@@ -1,12 +1,3 @@
1
- /**
2
- * The action registry (spec: "Module Breakdown / Behavior Engine" — "Action
3
- * payload contract"). One event name maps to one action handler with one
4
- * payload schema, honored identically across all three emit surfaces —
5
- * `ctx.emit(name, payload)`, `data-emit`, and `<om-behavior>` — an action
6
- * reads the same keys regardless of how it was triggered. `registerAction`
7
- * is re-exported from the package entry point (index.ts) as the public
8
- * `OmMap.registerAction` surface.
9
- */
10
1
  import type { RuntimeCore } from "./runtime-core";
11
2
  import type { LayerIR } from "./ir";
12
3
  export type ActionHandler = (payload: Record<string, unknown>, mapEl: Element, core: RuntimeCore, layerIRs: ReadonlyMap<string, LayerIR>) => void;
@@ -1,4 +1,4 @@
1
- import { C as ny, L as sy, M as oy, m as ay, c as ka, z as oc, a as Sd, G as sm, V as ly, W as cy, b as uy, g as hy, d as Vu, f as Ed, e as dy, l as py, u as fy, D as Qf, h as my } from "./index-Bnkk4jcp.js";
1
+ import { C as ny, L as sy, M as oy, m as ay, c as ka, a as Sd, z as oc, G as sm, V as ly, W as cy, b as uy, g as hy, d as Vu, f as Ed, e as dy, l as py, u as fy, D as Qf, h as my } from "./index-CX4qxgVT.js";
2
2
  const Cd = Math.PI / 180, gy = 180 / Math.PI;
3
3
  function em(Pe, Q = 0) {
4
4
  const me = Math.min(180, Pe) * Cd;
@@ -279,6 +279,13 @@ export declare class OmMapElement extends HTMLElementBase {
279
279
  pitch: number;
280
280
  bearing: number;
281
281
  } | undefined;
282
+ /** Live Tileset3D per currently-present 3D-tiles layer — the paced-flyby driver's isLoaded() gate (same surface the warm-tiles action reads). */
283
+ getLiveTilesetsInternal(): Map<string, unknown>;
284
+ /** Rendered viewport size — the paced driver's fly-to arc math needs real dims (undefined until the renderer has one). */
285
+ getViewportSizeInternal(): {
286
+ width: number;
287
+ height: number;
288
+ } | undefined;
282
289
  /** Resolved scene-lighting IR (headless test inspection) — null = deck default lights. */
283
290
  getLightingInternal(): LightingIR | null;
284
291
  /** Resolved terrain IR (headless test inspection) — null = no surface. */
@@ -11,6 +11,12 @@ export declare class OmStoryElement extends HTMLElementBase {
11
11
  private lastTimestamp;
12
12
  /** Set by the test harness (advanceInternal): the clock is driven manually, never by rAF. */
13
13
  private manualClock;
14
+ /** Generation token for the paced driver — bumping it aborts any in-flight run (replay, disconnect). */
15
+ private pacedGen;
16
+ /** True while the paced driver owns the camera — fireDueSteps must not dispatch camera actions against it. */
17
+ private pacedDriving;
18
+ private warnedPacedCameraSkip;
19
+ private pacedCapture;
14
20
  private captured;
15
21
  private capturedCamera;
16
22
  private warnedCustomActions;
@@ -23,6 +29,19 @@ export declare class OmStoryElement extends HTMLElementBase {
23
29
  play(): void;
24
30
  pause(): void;
25
31
  seek(ms: number): void;
32
+ /**
33
+ * Per-frame capture hook for paced runs (the recorder seam, issue #16):
34
+ * called after each paced frame is fully refined and BEFORE the frame's
35
+ * om-paced-tick event; the story does not advance to the next frame until
36
+ * the returned promise resolves, so `await mapEl.snapshot()` — or a
37
+ * driving tool's screenshot — is race-free by construction. The final
38
+ * om-paced-tick therefore also guarantees every capture has completed.
39
+ * Pass null to clear.
40
+ */
41
+ setPacedCapture(cb: ((tick: {
42
+ t: number;
43
+ waitedMs: number;
44
+ }) => void | Promise<void>) | null): void;
26
45
  /**
27
46
  * Test-harness clock drive (spec: "Consumer Testing Surface" +
28
47
  * "Map Stories"): switches this story to a manual clock permanently and
@@ -32,6 +51,16 @@ export declare class OmStoryElement extends HTMLElementBase {
32
51
  advanceInternal(ms: number): void;
33
52
  private startClock;
34
53
  private stopClock;
54
+ /**
55
+ * Load-paced playback (docs/design/paced-flyby.md): manual stepping via
56
+ * the pure runPacedFlyby driver — it owns the camera per frame
57
+ * (cameraAtTime over the same fly-to arc route warming samples, written
58
+ * through the instant setView path) and gates every advance on all live
59
+ * tilesets reporting isLoaded(). Emits om-paced-tick {t, waitedMs} per
60
+ * frame (the recorder hook). `paced` may carry a frame rate (`paced="60"`,
61
+ * default 30 — story-time fps of the output, not a wall-clock promise).
62
+ */
63
+ private startPaced;
35
64
  private advanceBy;
36
65
  private fireDueSteps;
37
66
  /** Steps and the timeline are (re)built lazily when idle — mid-play DOM edits apply on the next play/seek. */
@@ -1,4 +1,4 @@
1
- import { r as p, n as d } from "./index-Bnkk4jcp.js";
1
+ import { r as p, n as d } from "./index-CX4qxgVT.js";
2
2
  function g(t) {
3
3
  if (t == null) return !0;
4
4
  const e = t, n = e.id?.code;
@@ -15,7 +15,7 @@ interface TagData {
15
15
  }[];
16
16
  }
17
17
  /** Hand-authored action list — validated against hasAction() in html-data.test.ts. */
18
- export declare const BUILTIN_ACTIONS: readonly ["toggle-layer", "zoom-in", "zoom-out", "filter-layer", "highlight-feature", "fade", "pulse", "populate", "trace", "story-play", "story-pause", "story-seek", "fly-to", "zoom-to-feature", "show-overlay", "hide-overlay", "show-tooltip", "hide-tooltip", "draw-mode", "draw-commit", "draw-cancel", "draw-delete", "draw-clear", "draw-config", "draw-save", "export-region-3d", "measure-mode", "measure-clear", "measure-units", "set-basemap", "set-lighting", "set-terrain", "set-clip-box", "clip-box-edit", "set-widgets-visible", "undo", "redo"];
18
+ export declare const BUILTIN_ACTIONS: readonly ["toggle-layer", "zoom-in", "zoom-out", "filter-layer", "highlight-feature", "fade", "pulse", "populate", "trace", "story-play", "story-pause", "story-seek", "fly-to", "zoom-to-feature", "show-overlay", "hide-overlay", "show-tooltip", "hide-tooltip", "draw-mode", "draw-commit", "draw-cancel", "draw-delete", "draw-clear", "draw-config", "draw-save", "export-region-3d", "measure-mode", "measure-clear", "measure-units", "set-basemap", "set-lighting", "set-terrain", "set-clip-box", "clip-box-edit", "set-widgets-visible", "undo", "redo", "warm-tiles"];
19
19
  /** Hand-authored widget types — validated against the widget registry in html-data.test.ts. */
20
20
  export declare const BUILTIN_WIDGETS: readonly ["legend", "layer-switcher", "zoom-controls", "scale-bar", "attribution", "filter", "vega-lite", "dynamic-chart", "player", "draw", "measure", "basemap-switcher", "lighting", "clip-box", "undo-redo", "widgets-toggle", "time-slider", "ifc-browser", "ifc-legend", "feature-inspector", "ifc-inspector", "ifc-loader", "ifc-clash"];
21
21
  export declare function buildHtmlCustomData(): {
@@ -1,4 +1,4 @@
1
- import { ae as _t, af as Ct } from "./index-Bnkk4jcp.js";
1
+ import { ae as _t, af as Ct } from "./index-CX4qxgVT.js";
2
2
  function Yt(e, r, t = 2, i, o = "xy") {
3
3
  const s = r && r.length, l = s ? r[0] * t : e.length;
4
4
  let c = Ut(e, 0, l, t, !0, i && i[0], o);