@tileflow/capture 0.1.0-alpha.21 → 0.1.0-alpha.23

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
@@ -1,34 +1,97 @@
1
1
  # @tileflow/capture
2
2
 
3
- Pinned, headless local capture and visual comparison for Tileflow scenes. Most users should invoke
4
- it through the project-local `tileflow` CLI; this package is the public Node integration surface.
3
+ Headless Tileflow map capture for Node.js. Produce PNGs with versioned receipts, compare reviewed
4
+ baselines, or inspect differences between deliberate renders. Use the
5
+ [CLI](https://github.com/tileflow/tileflow-sdk/blob/main/packages/cli/README.md) for ordinary terminal
6
+ work; use this package when embedding capture in a script or integration.
5
7
 
6
- ## Capture committed scenes
8
+ > Related packages and guides: [documentation index](https://raw.githubusercontent.com/tileflow/tileflow-sdk/main/llms.txt).
9
+
10
+ ## Install
11
+
12
+ Use Node.js 22 or newer:
13
+
14
+ ```sh
15
+ npm install --save-dev @tileflow/capture@alpha @tileflow/core@alpha @tileflow/maps@alpha
16
+ ```
17
+
18
+ Capture uses Playwright's exact pinned Chromium headless shell. The browser is not bundled in npm
19
+ and a system Chrome installation is not a substitute. `allowBrowserInstall: true` permits its
20
+ first-use download. Provision the matching shell and system libraries ahead of time in restricted
21
+ CI environments; never assume that installing the npm package installed the browser.
22
+
23
+ Local capture needs no Tileflow API key. Configs are executable trusted code; remote tiles, fonts,
24
+ and sprites may still require network access and their own permissions.
25
+
26
+ ## Capture a committed scene
27
+
28
+ Create `tileflow.config.ts` in the working directory:
29
+
30
+ <!-- docs:check -->
7
31
 
8
32
  ```ts
9
- import {captureTileflowScenes} from '@tileflow/capture';
33
+ import {defineMap} from '@tileflow/core';
34
+ import {streets} from '@tileflow/maps';
35
+
36
+ export default defineMap({
37
+ id: 'madrid',
38
+ version: 1,
39
+ extends: streets,
40
+ scenes: {
41
+ 'madrid-desktop': {
42
+ theme: 'dark',
43
+ camera: {type: 'center', center: [-3.7038, 40.4168], zoom: 12},
44
+ viewport: {width: 1280, height: 800, dpr: 1},
45
+ },
46
+ },
47
+ });
48
+ ```
49
+
50
+ Scenes belong to this map and do not inherit. Every scene needs a concrete theme; omission and
51
+ `system` are invalid. Coordinates are `[longitude, latitude]`, viewport dimensions are CSS pixels,
52
+ and `dpr` is 1 or 2.
53
+
54
+ Run the following TypeScript module from that working directory with your project's TypeScript
55
+ runner. It writes evidence, not an approved baseline:
56
+
57
+ <!-- docs:check -->
58
+
59
+ ```ts
60
+ import {mkdir, writeFile} from 'node:fs/promises';
61
+ import {captureTileflowScenes, serializeTileflowCaptureReceipt} from '@tileflow/capture';
10
62
 
11
63
  const result = await captureTileflowScenes({
64
+ config: 'tileflow.config.ts',
12
65
  scenes: ['madrid-desktop'],
13
66
  allowBrowserInstall: true,
14
67
  });
15
-
16
- const [{png, receipt, sha256, networkDependent}] = result.captures;
68
+ const capture = result.captures[0];
69
+ if (!capture) throw new Error('Capture returned no image.');
70
+
71
+ await mkdir('.tileflow/captures', {recursive: true});
72
+ await writeFile('.tileflow/captures/madrid-desktop.png', capture.png);
73
+ await writeFile(
74
+ '.tileflow/captures/madrid-desktop.receipt.json',
75
+ serializeTileflowCaptureReceipt(capture.receipt),
76
+ );
77
+ console.log(capture.sha256, capture.networkDependent, capture.warnings);
17
78
  ```
18
79
 
19
- Standalone scenes compile through `@tileflow/dev`, inject the installed MapLibre JS and CSS into a
20
- pinned Playwright Chromium headless shell, fulfill generated local icon assets in memory, and wait
21
- for `load`, `idle`, and two animation frames. They never open a visible window or an HTTP listener.
22
- One call with multiple scenes uses one Browser and a fresh isolated context per scene. Capture
23
- installs the exact pinned shell automatically when `allowBrowserInstall` is true; the CLI enables
24
- that normal happy path by default.
80
+ A standalone capture needs no HTTP server or visible browser window. One call uses one browser and
81
+ a fresh isolated context for each scene. Results include PNG bytes, dimensions, concrete theme,
82
+ scene/style/image hashes, runtime identity, a receipt, warnings, and `networkDependent`. They do not
83
+ expose a Playwright page or browser.
84
+
85
+ ## Reuse or prepare the browser
86
+
87
+ For repeated capture, retain a session and close it even after errors:
25
88
 
26
- For a warm integration, retain and close a session:
89
+ <!-- docs:check -->
27
90
 
28
91
  ```ts
29
92
  import {createTileflowCaptureSession} from '@tileflow/capture';
30
93
 
31
- const session = createTileflowCaptureSession({cwd: process.cwd()});
94
+ const session = createTileflowCaptureSession({allowBrowserInstall: true});
32
95
  try {
33
96
  await session.capture(['madrid-desktop']);
34
97
  await session.captureAll();
@@ -37,166 +100,107 @@ try {
37
100
  }
38
101
  ```
39
102
 
40
- Sessions can also capture caller-provided scene definitions or a prepared `TileflowBuildArtifacts`
41
- snapshot. Every durable definition names one concrete theme; omission and the browser-only
42
- `system` selector are rejected. Results carry that theme, PNG bytes, dimensions, hashes, safe
43
- runtime identity, the canonical receipt, warnings, and a `networkDependent` flag. They never expose
44
- a Playwright page or browser as part of the capture result.
103
+ To provision ahead of time, call `setupTileflowCaptureBrowser({allowInstall: true})`. In an offline
104
+ prepared environment, use `allowInstall: false` and capture with `allowBrowserInstall: false`.
105
+ The equivalent CLI command is `tileflow setup capture`. These settings control browser installation,
106
+ not the network access required by a map's remote resources. Tileflow never deletes the shared
107
+ per-user browser cache.
45
108
 
46
- Failures use `TileflowCaptureError` with a stable code and optional bounded details. The phase
47
- distinguishes style validation, browser start, resource loading, MapLibre load/idle, and screenshot
48
- production. Resource details contain only a classified kind, sanitized origin, optional HTTP
49
- status, and safe bounded context; raw Playwright stacks, queries, credentials, response bodies, and
50
- DOM contents are not part of the public diagnostic contract.
109
+ ## Capture the running application
51
110
 
52
- ## Application capture
111
+ Application capture uses the app's normal loopback server; it does not start a server or discover a
112
+ port. Add a `madrid-product` entry to the config's `scenes` using this definition:
53
113
 
54
- Application scenes need only the app's normal loopback server:
114
+ <!-- docs:check -->
55
115
 
56
116
  ```ts
57
- const result = await captureTileflowScenes({
58
- appOrigin: 'http://127.0.0.1:3000',
59
- scenes: ['madrid-product'],
60
- });
117
+ import type {TileflowCaptureScene} from '@tileflow/core';
118
+
119
+ const applicationScene = {
120
+ theme: 'dark',
121
+ camera: {type: 'center', center: [-3.7038, 40.4168], zoom: 12},
122
+ viewport: {width: 1280, height: 800, dpr: 1},
123
+ target: {kind: 'application', path: '/maps/madrid', captureId: 'main-map'},
124
+ } satisfies Omit<TileflowCaptureScene, 'map'>;
61
125
  ```
62
126
 
63
- Tileflow does not start or discover the server. It accepts only loopback HTTP(S), creates a fresh
64
- context without reused profile/cookies/storage or service workers, confines redirects to the exact
65
- approved origin, finds exactly one target, waits for Tileflow's `data-tileflow-state="idle"` marker,
66
- and captures the element or viewport. The React, Vue, and Svelte wrappers expose the marker plus
67
- `data-tileflow-map`, the resolved `data-tileflow-theme`, and optional `data-tileflow-capture-id`.
68
- Capture verifies the concrete theme before and after screenshot production. A full one-off
69
- `appUrl`, selector, and frame can be supplied through the session options. The route and component
70
- props own application camera state; capture includes that camera in scene identity but does not
71
- rewrite it.
72
-
73
- The application's development server also remains authoritative for local PMTiles. Each range is
74
- served from one immutable snapshot and strong ETags prevent one PMTiles read from silently mixing
75
- generations. Application Capture deliberately adds no lease or token that freezes every local
76
- dataset for the complete screenshot. A source edit during that exact window can therefore move a
77
- later read to the next valid generation; application receipts already classify style and data as
78
- `expected-unverified`. Standalone Capture is stricter because it owns and retains its artifact
79
- snapshot for the complete render.
80
-
81
- ## Prepared/offline browser setup
82
-
83
- ```ts
84
- import {setupTileflowCaptureBrowser} from '@tileflow/capture';
85
-
86
- const renderer = await setupTileflowCaptureBrowser({allowInstall: true});
87
- ```
127
+ The route must render a Tileflow component for `madrid`, with theme `dark` and
128
+ `captureId="main-map"`. The application owns its camera; capture records the scene camera but does
129
+ not move the application's map. Start the app in one terminal, then run:
88
130
 
89
- Playwright provisions its exact platform-specific headless shell in the versioned per-user browser
90
- cache. Tileflow always launches headlessly, never uses system Chrome, never embeds a browser in the
91
- npm tarball, and never deletes the shared cache. Set `allowInstall: false` for an offline prepared
92
- environment. The equivalent CLI command is `tileflow setup capture`; use it to pay installation
93
- cost in advance or verify prepared CI, not as a prerequisite for normal capture.
94
-
95
- ## Receipts and visual comparison
96
-
97
- `createTileflowCaptureReceipt`, `parseTileflowCaptureReceipt`,
98
- `validateTileflowCaptureReceipt`, and `serializeTileflowCaptureReceipt` implement the strict,
99
- bounded schema-version-4 receipt contract. A receipt contains the concrete theme,
100
- image/scene/style hashes, dimensions,
101
- renderer/platform identity, the required resolved `data` identity, explicit style/data verification,
102
- and remote-dependency state. Standalone map receipts mark configured style and data as `rendered`;
103
- application receipts mark them as `expected-unverified` because capture cannot inspect an arbitrary
104
- application's live MapLibre instance. Vector endpoints are represented by a query-free SHA-256
105
- fingerprint rather than a raw URL. Receipts contain no time, user, origin, path, query, signed token,
106
- repository, absolute filesystem path, config source, environment, or credential.
107
-
108
- For Tileflow World, Capture resolves the map's TileJSON selector once per session, requires
109
- `tileflow.world` to identify `world-v1` with exact release, descriptor, archive, data-contract, and
110
- product-contract SHA-256 values, and renders every standalone scene from the returned immutable tile
111
- template. Repeated scenes and retries reuse that one resolution; another selector in the same
112
- session fails instead of mixing releases. Existing canonical schema-v2 and schema-v3 receipts remain
113
- readable as historical evidence and are never reclassified as schema v4.
114
-
115
- Capture reuses Core's exact World V1 release-ID contract: 12–128 characters matching
116
- `^world-v1-[a-z0-9][a-z0-9._-]*[a-z0-9]$`. Selector responses and durable receipts reject rather
117
- than normalize uppercase, whitespace, another generation, or an incomplete boundary value.
118
-
119
- Receipt-only tooling can import these contracts from `@tileflow/capture/receipt` without loading the
120
- Playwright capture runtime.
131
+ <!-- docs:check -->
121
132
 
122
133
  ```ts
123
- import {compareTileflowCaptureToBaseline} from '@tileflow/capture';
134
+ import {captureTileflowScenes} from '@tileflow/capture';
124
135
 
125
- const comparison = await compareTileflowCaptureToBaseline(result.captures[0], {
126
- png: baselinePng,
127
- receipt: baselineReceiptJson,
136
+ const result = await captureTileflowScenes({
137
+ appOrigin: 'http://127.0.0.1:3000',
138
+ scenes: ['madrid-product'],
139
+ allowBrowserInstall: true,
128
140
  });
141
+ console.log(result.captures[0]?.sha256);
129
142
  ```
130
143
 
131
- Comparison validates PNG CRC, hash, dimensions, pixel bounds, and receipt identity before reading
132
- pixels. It classifies unchanged, changed, missing, scene-mismatch, and runtime-mismatch results,
133
- reports exact RGBA changed pixels plus a fixed 0.1 perceptual metric, and returns an optional
134
- transparent high-contrast diff PNG. Exact RGBA inequality determines `changed`.
144
+ Only loopback HTTP(S) is accepted. Capture confines redirects to the approved origin, uses no
145
+ reused profile/cookies/storage or service workers, selects exactly one target, waits for
146
+ `data-tileflow-state="idle"`, and verifies the concrete theme before and after capture.
147
+ `appUrl`, `selector`, and `frame` provide explicit one-off overrides. Do not run a separate
148
+ `tileflow preview` server beside the app.
135
149
 
136
- To review two deliberate Tileflow renders that are allowed to use different maps, themes, and
137
- styles, use the separate review primitive:
150
+ Application receipts mark style/data as `expected-unverified`; capture cannot inspect an arbitrary
151
+ application's live MapLibre instance. Standalone receipts mark them as `rendered` and retain one
152
+ prepared artifact generation for the whole capture. An application dataset edit can move later
153
+ requests to a new generation; application capture does not freeze every dataset for the screenshot.
138
154
 
139
- ```ts
140
- import {
141
- compareTileflowCapturesForReview,
142
- createTileflowVisualReviewDocument,
143
- } from '@tileflow/capture';
144
-
145
- const review = await compareTileflowCapturesForReview(
146
- {capture: leftCapture, definition: leftDefinition},
147
- {capture: rightCapture, definition: rightDefinition},
148
- {includeDiff: true, region: {x: 0, y: 0, width: 1200, height: 760}},
149
- );
150
- const document = createTileflowVisualReviewDocument(review);
151
- ```
155
+ ## Compare a reviewed baseline
156
+
157
+ This example requires an existing reviewed PNG and receipt pair in `test/visual-baselines`. It
158
+ reads them without replacing them:
152
159
 
153
- Review requires each PNG, its schema-v4 receipt, and the exact normalized definition used to
154
- produce it. It authenticates both sides before comparing pixels. Different map/style/theme
155
- identities are intentional, but camera, viewport, target frame, physical dimensions, renderer, and
156
- resolved data identity must agree before exact or perceptual metrics are meaningful. Statuses are
157
- `comparable`, `frame-mismatch`, `dimensions-mismatch`, `runtime-mismatch`, and `data-mismatch`.
158
- Inputs are snapshotted from plain accessor-free data before asynchronous work, and both individual
159
- and aggregate PNG byte limits are enforced before hashing. Pixelmatch runs only for compatible
160
- captures; `includeDiff: false` does not allocate or encode a diff. The optional diff is contextual
161
- evidence only; this API never reads, writes, or approves a visual baseline.
162
-
163
- Every comparable review also reports `appearance: {region, left, right, rightMinusLeft}`. Profiles
164
- contain linear luminance, OKLab lightness/chroma (`mean`, `p10`, `p50`, `p90`), edge density, and
165
- local contrast. The optional region is one bounded physical-pixel rectangle from the top-left and
166
- affects only `appearance`; exact/perceptual metrics and the diff still describe the complete frame.
167
-
168
- Review documents are a schema-version-1 compatibility boundary, exposed through
169
- `tileflowVisualReviewSchemaVersion`. `tileflowVisualReviewLimits.maximumAggregatePngBytes` exposes
170
- the 256 MiB aggregate input cap; each input is also subject to the 256 MiB
171
- `tileflowVisualArtifactLimits.maximumPngBytes` cap and the shared decoded-pixel limits. Invalid,
172
- non-plain, unauthenticated, or over-limit inputs throw `TileflowVisualReviewError` with stable code
173
- `VISUAL_REVIEW_INVALID`. `createTileflowVisualReviewDocument` removes contextual `diffPng` bytes
174
- while retaining the versioned identities, status, metrics, and warnings.
175
-
176
- For an external design screenshot that has no Tileflow receipt, use the separate exploratory
177
- primitive:
160
+ <!-- docs:check -->
178
161
 
179
162
  ```ts
180
- import {analyzeTileflowCaptureReference} from '@tileflow/capture';
163
+ import {readFile} from 'node:fs/promises';
164
+ import {captureTileflowScenes, compareTileflowCaptureToBaseline} from '@tileflow/capture';
181
165
 
182
- const analysis = await analyzeTileflowCaptureReference(result.captures[0], referencePng, {
183
- region: {x: 0, y: 0, width: 1200, height: 760},
166
+ const result = await captureTileflowScenes({
167
+ scenes: ['madrid-desktop'],
168
+ allowBrowserInstall: true,
184
169
  });
185
- ```
186
-
187
- It strictly validates the bounded non-interlaced PNG, reports dimensions and up to 16 deterministic
188
- quantized palette entries, and—only for equal dimensions—exact/perceptual changed pixels, mean
189
- absolute RGBA-channel difference, a high-contrast diff PNG, and
190
- `appearance.actualMinusReference` with the same signed profile. It does not assert scene/runtime
191
- compatibility, accept a receipt for the reference, or create a baseline.
192
-
193
- ## Security and determinism boundary
170
+ const capture = result.captures[0];
171
+ if (!capture) throw new Error('Capture returned no image.');
194
172
 
195
- `tileflow.config.ts` is executable trusted repository code, not a sandbox. Chromium receives a
196
- small non-secret environment allowlist. Standalone capture fulfills only exact compiled synthetic
197
- assets and reports external HTTP(S) origins; application capture accepts only loopback URLs and
198
- does not echo query strings or DOM contents in failures. Remote tiles, glyphs, or sprites can change,
199
- so a remote-dependent result is evidence but not a globally byte-stable golden image.
173
+ const comparison = await compareTileflowCaptureToBaseline(capture, {
174
+ png: await readFile('test/visual-baselines/madrid-desktop.png'),
175
+ receipt: await readFile('test/visual-baselines/madrid-desktop.receipt.json', 'utf8'),
176
+ });
177
+ console.log(comparison.status);
178
+ ```
200
179
 
201
- See `THIRD_PARTY_NOTICES.md` for runtime license treatment and
202
- https://tileflow.dev/docs/agent-workflow for the complete CLI workflow.
180
+ Comparison validates the image and receipt before reading pixels. Results distinguish unchanged,
181
+ changed, missing, scene mismatch, and runtime mismatch. Exact RGBA inequality determines `changed`;
182
+ the perceptual metric and optional diff image are additional evidence. Never update a baseline
183
+ merely to make a failing comparison pass.
184
+
185
+ `compareTileflowCapturesForReview` compares two deliberate renders with different styles while
186
+ requiring compatible framing, renderer, and data identity. `analyzeTileflowCaptureReference` analyzes
187
+ an external PNG without claiming receipt compatibility. Neither operation approves a baseline.
188
+ See the [receipt and comparison reference](https://github.com/tileflow/tileflow-sdk/blob/main/packages/capture/docs/receipts-and-comparison.md),
189
+ also included under `docs/` in the installed package, for identities, metrics, and limits.
190
+
191
+ ## Errors, receipts, and reproducibility
192
+
193
+ `TileflowCaptureError` reports a stable code, phase, and bounded details. Phases distinguish style
194
+ validation, browser startup, resource loading, MapLibre readiness, and screenshot production.
195
+ Resource diagnostics contain safe classification and origin/status information, not credentials,
196
+ query strings, response bodies, raw browser stacks, or DOM contents.
197
+
198
+ New receipts use schema version 4. Version-2 and version-3 receipts remain readable as historical
199
+ evidence, not as newly verified version-4 captures. Import `@tileflow/capture/receipt` for receipt-only
200
+ tooling without loading the capture runtime. World selectors resolve once per capture session to
201
+ an immutable release. Remote-dependent rendering is not a guarantee of globally identical pixels
202
+ across platforms or future runs.
203
+
204
+ Read the [capture contract](https://github.com/tileflow/tileflow-sdk/blob/main/docs/contracts/local-visual-capture.md)
205
+ for durable behavior and [third-party notices](https://github.com/tileflow/tileflow-sdk/blob/main/packages/capture/THIRD_PARTY_NOTICES.md)
206
+ for runtime licenses. For release-specific behavior, prefer installed declarations and docs over `main`.
@@ -18,7 +18,7 @@ the platform-specific headless shell into its versioned per-user browser cache;
18
18
  distribution carries Chromium's license and credits. Chromium source and license information are
19
19
  available at https://www.chromium.org/chromium-projects/.
20
20
 
21
- ## MapLibre GL JS 5.24.0
21
+ ## MapLibre GL JS 6.9.0
22
22
 
23
23
  Copyright (c) 2023 MapLibre contributors. Licensed under BSD-3-Clause. The installed `maplibre-gl`
24
24
  package includes `LICENSE.txt`, including notices for code derived from Mapbox GL JS and other
package/dist/index.js CHANGED
@@ -320,8 +320,12 @@ import { getTileflowStyleFontFaces } from "@tileflow/core/runtime";
320
320
  import { assertValidTileflowStyle, TileflowStyleValidationError } from "@tileflow/dev/validation";
321
321
  var tileflowSyntheticAssetOrigin = "https://tileflow.local.invalid";
322
322
  var require3 = createRequire2(import.meta.url);
323
- var maplibreJsPath = require3.resolve("maplibre-gl/dist/maplibre-gl.js");
324
- var maplibreCssPath = require3.resolve("maplibre-gl/dist/maplibre-gl.css");
323
+ var mapLibreRuntimePaths = Object.freeze({
324
+ css: require3.resolve("maplibre-gl/dist/maplibre-gl.css"),
325
+ main: require3.resolve("maplibre-gl/dist/maplibre-gl.mjs"),
326
+ shared: require3.resolve("maplibre-gl/dist/maplibre-gl-shared.mjs"),
327
+ worker: require3.resolve("maplibre-gl/dist/maplibre-gl-worker.mjs")
328
+ });
325
329
  var tileflowCorePackagePath = require3.resolve("@tileflow/core/package.json");
326
330
  var tileflowBrowserModule = `${readFileSync2(
327
331
  join2(dirname2(tileflowCorePackagePath), "dist", "browser.js"),
@@ -351,6 +355,9 @@ async function captureStandaloneTileflowScene(input) {
351
355
  throw error;
352
356
  }
353
357
  const timeoutMs = input.timeoutMs ?? 3e4;
358
+ const mapLibreRuntimeUrls = createMapLibreRuntimeUrls(
359
+ resolveMapLibreRuntimeDocumentOrigin(input.style)
360
+ );
354
361
  let termination;
355
362
  let context;
356
363
  let phase = "browser-start";
@@ -407,6 +414,13 @@ async function captureStandaloneTileflowScene(input) {
407
414
  };
408
415
  await context.route("**/*", async (route) => {
409
416
  const url = new URL(route.request().url());
417
+ if (url.origin === mapLibreRuntimeUrls.origin) {
418
+ const runtime = getMapLibreRuntimeResponse(url.pathname, input.scene.viewport);
419
+ if (runtime) {
420
+ await route.fulfill(runtime);
421
+ return;
422
+ }
423
+ }
410
424
  if (url.origin === tileflowSyntheticAssetOrigin) {
411
425
  const fileName = decodeURIComponent(url.pathname.slice(1));
412
426
  const localTileset = url.search === "" ? localTilesets.get(fileName) : void 0;
@@ -415,7 +429,10 @@ async function captureStandaloneTileflowScene(input) {
415
429
  route.request().headers()["range"],
416
430
  localTileset
417
431
  );
418
- await route.fulfill(response);
432
+ await route.fulfill({
433
+ ...response,
434
+ headers: { "Access-Control-Allow-Origin": "*", ...response.headers }
435
+ });
419
436
  return;
420
437
  }
421
438
  const asset = url.search === "" ? assets.get(fileName) : void 0;
@@ -428,6 +445,7 @@ async function captureStandaloneTileflowScene(input) {
428
445
  await route.fulfill({
429
446
  body: typeof source === "string" ? source : Buffer.from(source),
430
447
  contentType: asset.contentType,
448
+ headers: { "Access-Control-Allow-Origin": "*" },
431
449
  status: 200
432
450
  });
433
451
  return;
@@ -457,9 +475,17 @@ async function captureStandaloneTileflowScene(input) {
457
475
  });
458
476
  page.on("requestfailed", (request) => recordResourceFailure(request.url()));
459
477
  page.setDefaultTimeout(timeoutMs);
460
- await page.setContent(renderHtml(input.scene.viewport), { waitUntil: "domcontentloaded" });
461
- await page.addStyleTag({ path: maplibreCssPath });
462
- await page.addScriptTag({ path: maplibreJsPath });
478
+ await page.goto(mapLibreRuntimeUrls.document, { waitUntil: "domcontentloaded" });
479
+ await page.addStyleTag({ url: mapLibreRuntimeUrls.css });
480
+ await page.addScriptTag({
481
+ content: `import * as maplibregl from ${JSON.stringify(mapLibreRuntimeUrls.main)};
482
+ maplibregl.setWorkerUrl(${JSON.stringify(mapLibreRuntimeUrls.worker)});
483
+ globalThis.maplibregl = maplibregl;`,
484
+ type: "module"
485
+ });
486
+ await page.waitForFunction(
487
+ () => Boolean(globalThis.maplibregl)
488
+ );
463
489
  await page.addScriptTag({ content: tileflowBrowserModule, type: "module" });
464
490
  await page.addScriptTag({ content: renderMapInPageScript });
465
491
  phase = "map-load";
@@ -559,6 +585,78 @@ async function captureStandaloneTileflowScene(input) {
559
585
  await context?.close().catch(() => void 0);
560
586
  }
561
587
  }
588
+ function createMapLibreRuntimeUrls(origin) {
589
+ return Object.freeze({
590
+ css: `${origin}/__runtime/maplibre-gl.css`,
591
+ document: `${origin}/__runtime/document.html`,
592
+ main: `${origin}/__runtime/maplibre-gl.mjs`,
593
+ origin,
594
+ shared: `${origin}/__runtime/maplibre-gl-shared.mjs`,
595
+ worker: `${origin}/__runtime/maplibre-gl-worker.mjs`
596
+ });
597
+ }
598
+ function resolveMapLibreRuntimeDocumentOrigin(style) {
599
+ const origins = /* @__PURE__ */ new Set();
600
+ const loopbackOrigins = /* @__PURE__ */ new Set();
601
+ const addOrigin = (value) => {
602
+ if (typeof value !== "string") return;
603
+ try {
604
+ const url = new URL(value);
605
+ if ((url.protocol === "http:" || url.protocol === "https:") && url.username === "" && url.password === "") {
606
+ origins.add(url.origin);
607
+ if (isLoopbackUrl(url)) loopbackOrigins.add(url.origin);
608
+ }
609
+ } catch {
610
+ }
611
+ };
612
+ addOrigin(style.glyphs);
613
+ if (Array.isArray(style.sprite)) {
614
+ for (const sprite of style.sprite) {
615
+ if (sprite && typeof sprite === "object") {
616
+ addOrigin(sprite.url);
617
+ }
618
+ }
619
+ } else {
620
+ addOrigin(style.sprite);
621
+ }
622
+ const fontFaces = style.metadata?.["tileflow:fontFaces"];
623
+ if (Array.isArray(fontFaces)) {
624
+ for (const fontFace of fontFaces) {
625
+ if (fontFace && typeof fontFace === "object") {
626
+ addOrigin(fontFace.source);
627
+ }
628
+ }
629
+ }
630
+ for (const source of Object.values(style.sources)) {
631
+ const candidate = source;
632
+ addOrigin(candidate.data);
633
+ addOrigin(candidate.url);
634
+ if (Array.isArray(candidate.tiles)) {
635
+ for (const tile of candidate.tiles) addOrigin(tile);
636
+ }
637
+ if (Array.isArray(candidate.urls)) {
638
+ for (const url of candidate.urls) addOrigin(url);
639
+ }
640
+ }
641
+ const preferredOrigins = loopbackOrigins.size > 0 ? loopbackOrigins : origins;
642
+ return [...preferredOrigins].sort(compareCodeUnits)[0] ?? tileflowSyntheticAssetOrigin;
643
+ }
644
+ function getMapLibreRuntimeResponse(pathname, viewport) {
645
+ if (pathname === "/__runtime/document.html") {
646
+ return {
647
+ body: renderHtml(viewport),
648
+ contentType: "text/html; charset=utf-8",
649
+ status: 200
650
+ };
651
+ }
652
+ const asset = pathname === "/__runtime/maplibre-gl.mjs" ? mapLibreRuntimePaths.main : pathname === "/__runtime/maplibre-gl-shared.mjs" ? mapLibreRuntimePaths.shared : pathname === "/__runtime/maplibre-gl-worker.mjs" ? mapLibreRuntimePaths.worker : pathname === "/__runtime/maplibre-gl.css" ? mapLibreRuntimePaths.css : void 0;
653
+ if (!asset) return void 0;
654
+ return {
655
+ body: readFileSync2(asset, "utf8"),
656
+ contentType: pathname === "/__runtime/maplibre-gl.css" ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8",
657
+ status: 200
658
+ };
659
+ }
562
660
  async function localTilesetCaptureResponse(rangeHeader, file) {
563
661
  const match = rangeHeader?.match(/^bytes=(\d+)-(\d*)$/u);
564
662
  if (!match) {
@@ -0,0 +1,100 @@
1
+ # Capture receipts and visual comparison
2
+
3
+ `createTileflowCaptureReceipt`, `parseTileflowCaptureReceipt`,
4
+ `validateTileflowCaptureReceipt`, and `serializeTileflowCaptureReceipt` implement the strict,
5
+ bounded schema-version-4 receipt contract. A receipt contains the concrete theme,
6
+ image/scene/style hashes, dimensions,
7
+ renderer/platform identity, the required resolved `data` identity, explicit style/data verification,
8
+ and remote-dependency state. Standalone map receipts mark configured style and data as `rendered`;
9
+ application receipts mark them as `expected-unverified` because capture cannot inspect an arbitrary
10
+ application's live MapLibre instance. Vector endpoints are represented by a query-free SHA-256
11
+ fingerprint rather than a raw URL. Receipts contain no time, user, origin, path, query, signed token,
12
+ repository, absolute filesystem path, config source, environment, or credential.
13
+
14
+ For Tileflow World, Capture resolves the map's TileJSON selector once per session, requires
15
+ `tileflow.world` to identify `world-v1` with exact release, descriptor, archive, data-contract, and
16
+ product-contract SHA-256 values, and renders every standalone scene from the returned immutable tile
17
+ template. Repeated scenes and retries reuse that one resolution; another selector in the same
18
+ session fails instead of mixing releases. Existing canonical schema-v2 and schema-v3 receipts remain
19
+ readable as historical evidence and are never reclassified as schema v4.
20
+
21
+ Capture reuses Core's exact World V1 release-ID contract: 12–128 characters matching
22
+ `^world-v1-[a-z0-9][a-z0-9._-]*[a-z0-9]$`. Selector responses and durable receipts reject rather
23
+ than normalize uppercase, whitespace, another generation, or an incomplete boundary value.
24
+
25
+ Receipt-only tooling can import these contracts from `@tileflow/capture/receipt` without loading the
26
+ Playwright capture runtime.
27
+
28
+ The following comparison fragments assume captures and PNG/receipt bytes that the caller has
29
+ already loaded and validated. See the package README for a complete baseline-file example.
30
+
31
+ ```ts
32
+ import {compareTileflowCaptureToBaseline} from '@tileflow/capture';
33
+
34
+ const comparison = await compareTileflowCaptureToBaseline(result.captures[0], {
35
+ png: baselinePng,
36
+ receipt: baselineReceiptJson,
37
+ });
38
+ ```
39
+
40
+ Comparison validates PNG CRC, hash, dimensions, pixel bounds, and receipt identity before reading
41
+ pixels. It classifies unchanged, changed, missing, scene-mismatch, and runtime-mismatch results,
42
+ reports exact RGBA changed pixels plus a fixed 0.1 perceptual metric, and returns an optional
43
+ transparent high-contrast diff PNG. Exact RGBA inequality determines `changed`.
44
+
45
+ To review two deliberate Tileflow renders that are allowed to use different maps, themes, and
46
+ styles, use the separate review primitive:
47
+
48
+ ```ts
49
+ import {
50
+ compareTileflowCapturesForReview,
51
+ createTileflowVisualReviewDocument,
52
+ } from '@tileflow/capture';
53
+
54
+ const review = await compareTileflowCapturesForReview(
55
+ {capture: leftCapture, definition: leftDefinition},
56
+ {capture: rightCapture, definition: rightDefinition},
57
+ {includeDiff: true, region: {x: 0, y: 0, width: 1200, height: 760}},
58
+ );
59
+ const document = createTileflowVisualReviewDocument(review);
60
+ ```
61
+
62
+ Review requires each PNG, its schema-v4 receipt, and the exact normalized definition used to
63
+ produce it. It authenticates both sides before comparing pixels. Different map/style/theme
64
+ identities are intentional, but camera, viewport, target frame, physical dimensions, renderer, and
65
+ resolved data identity must agree before exact or perceptual metrics are meaningful. Statuses are
66
+ `comparable`, `frame-mismatch`, `dimensions-mismatch`, `runtime-mismatch`, and `data-mismatch`.
67
+ Inputs are snapshotted from plain accessor-free data before asynchronous work, and both individual
68
+ and aggregate PNG byte limits are enforced before hashing. Pixelmatch runs only for compatible
69
+ captures; `includeDiff: false` does not allocate or encode a diff. The optional diff is contextual
70
+ evidence only; this API never reads, writes, or approves a visual baseline.
71
+
72
+ Every comparable review also reports `appearance: {region, left, right, rightMinusLeft}`. Profiles
73
+ contain linear luminance, OKLab lightness/chroma (`mean`, `p10`, `p50`, `p90`), edge density, and
74
+ local contrast. The optional region is one bounded physical-pixel rectangle from the top-left and
75
+ affects only `appearance`; exact/perceptual metrics and the diff still describe the complete frame.
76
+
77
+ Review documents are a schema-version-1 compatibility boundary, exposed through
78
+ `tileflowVisualReviewSchemaVersion`. `tileflowVisualReviewLimits.maximumAggregatePngBytes` exposes
79
+ the 256 MiB aggregate input cap; each input is also subject to the 256 MiB
80
+ `tileflowVisualArtifactLimits.maximumPngBytes` cap and the shared decoded-pixel limits. Invalid,
81
+ non-plain, unauthenticated, or over-limit inputs throw `TileflowVisualReviewError` with stable code
82
+ `VISUAL_REVIEW_INVALID`. `createTileflowVisualReviewDocument` removes contextual `diffPng` bytes
83
+ while retaining the versioned identities, status, metrics, and warnings.
84
+
85
+ For an external design screenshot that has no Tileflow receipt, use the separate exploratory
86
+ primitive:
87
+
88
+ ```ts
89
+ import {analyzeTileflowCaptureReference} from '@tileflow/capture';
90
+
91
+ const analysis = await analyzeTileflowCaptureReference(result.captures[0], referencePng, {
92
+ region: {x: 0, y: 0, width: 1200, height: 760},
93
+ });
94
+ ```
95
+
96
+ It strictly validates the bounded non-interlaced PNG, reports dimensions and up to 16 deterministic
97
+ quantized palette entries, and—only for equal dimensions—exact/perceptual changed pixels, mean
98
+ absolute RGBA-channel difference, a high-contrast diff PNG, and
99
+ `appearance.actualMinusReference` with the same signed profile. It does not assert scene/runtime
100
+ compatibility, accept a receipt for the reference, or create a baseline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tileflow/capture",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-alpha.23",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Deterministic local visual capture and comparison for Tileflow maps.",
6
6
  "engines": {
@@ -28,6 +28,7 @@
28
28
  "types": "./dist/index.d.ts",
29
29
  "files": [
30
30
  "dist",
31
+ "docs",
31
32
  "THIRD_PARTY_NOTICES.md",
32
33
  "LICENSE"
33
34
  ],
@@ -47,9 +48,9 @@
47
48
  "access": "public"
48
49
  },
49
50
  "dependencies": {
50
- "@tileflow/core": ">=0.1.0-alpha.24 <0.1.0-beta.0",
51
- "@tileflow/dev": ">=0.1.0-alpha.22 <0.1.0-beta.0",
52
- "maplibre-gl": "5.24.0",
51
+ "@tileflow/core": ">=0.1.0-alpha.25 <0.1.0-beta.0",
52
+ "@tileflow/dev": ">=0.1.0-alpha.24 <0.1.0-beta.0",
53
+ "maplibre-gl": "6.9.0",
53
54
  "pixelmatch": "7.2.0",
54
55
  "playwright": "1.62.1",
55
56
  "pngjs": "7.0.0"
@@ -59,6 +60,7 @@
59
60
  "@types/node": "^25.9.2",
60
61
  "@types/pngjs": "6.0.5",
61
62
  "@vitejs/plugin-vue": "^6.0.8",
63
+ "maplibre-gl-min": "npm:maplibre-gl@6.4.1",
62
64
  "next": "16.3.3",
63
65
  "react": "^19.2.7",
64
66
  "react-dom": "^19.2.7",
@@ -66,13 +68,13 @@
66
68
  "vite": "^8.0.16",
67
69
  "vue": "^3.5.39",
68
70
  "webpack": "5.108.3",
69
- "@tileflow/maps": "0.1.0-alpha.2",
70
- "@tileflow/next": "0.1.0-alpha.20",
71
- "@tileflow/svelte": "0.1.0-alpha.22",
72
- "@tileflow/vite": "0.1.0-alpha.20",
73
- "@tileflow/vue": "0.1.0-alpha.22",
74
- "@tileflow/react": "0.1.0-alpha.23",
75
- "@tileflow/webpack": "0.1.0-alpha.20"
71
+ "@tileflow/next": "0.1.0-alpha.22",
72
+ "@tileflow/react": "0.1.0-alpha.25",
73
+ "@tileflow/svelte": "0.1.0-alpha.24",
74
+ "@tileflow/vite": "0.1.0-alpha.22",
75
+ "@tileflow/maps": "0.1.0-alpha.4",
76
+ "@tileflow/vue": "0.1.0-alpha.24",
77
+ "@tileflow/webpack": "0.1.0-alpha.22"
76
78
  },
77
79
  "scripts": {
78
80
  "build": "tsup src/index.ts src/receipt.ts --format esm --dts --clean --platform node",