@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 +158 -154
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/index.js +104 -6
- package/docs/receipts-and-comparison.md +100 -0
- package/package.json +13 -11
package/README.md
CHANGED
|
@@ -1,34 +1,97 @@
|
|
|
1
1
|
# @tileflow/capture
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
89
|
+
<!-- docs:check -->
|
|
27
90
|
|
|
28
91
|
```ts
|
|
29
92
|
import {createTileflowCaptureSession} from '@tileflow/capture';
|
|
30
93
|
|
|
31
|
-
const session = createTileflowCaptureSession({
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
<!-- docs:check -->
|
|
55
115
|
|
|
56
116
|
```ts
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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 {
|
|
134
|
+
import {captureTileflowScenes} from '@tileflow/capture';
|
|
124
135
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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 {
|
|
163
|
+
import {readFile} from 'node:fs/promises';
|
|
164
|
+
import {captureTileflowScenes, compareTileflowCaptureToBaseline} from '@tileflow/capture';
|
|
181
165
|
|
|
182
|
-
const
|
|
183
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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`.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
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
|
|
324
|
-
|
|
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(
|
|
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.
|
|
461
|
-
await page.addStyleTag({
|
|
462
|
-
await page.addScriptTag({
|
|
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.
|
|
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.
|
|
51
|
-
"@tileflow/dev": ">=0.1.0-alpha.
|
|
52
|
-
"maplibre-gl": "
|
|
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/
|
|
70
|
-
"@tileflow/
|
|
71
|
-
"@tileflow/svelte": "0.1.0-alpha.
|
|
72
|
-
"@tileflow/vite": "0.1.0-alpha.
|
|
73
|
-
"@tileflow/
|
|
74
|
-
"@tileflow/
|
|
75
|
-
"@tileflow/webpack": "0.1.0-alpha.
|
|
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",
|