@tileflow/capture 0.0.0-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2084 @@
1
+ // src/browser.ts
2
+ import { spawn } from "child_process";
3
+ import { chromium } from "playwright";
4
+
5
+ // src/errors.ts
6
+ var TileflowCaptureError = class extends Error {
7
+ code;
8
+ details;
9
+ constructor(code, message, options) {
10
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
11
+ this.name = "TileflowCaptureError";
12
+ this.code = code;
13
+ this.details = normalizeDetails(options?.details);
14
+ }
15
+ };
16
+ function captureErrorMessage(error, fallback) {
17
+ if (error instanceof TileflowCaptureError) {
18
+ return error.message;
19
+ }
20
+ return fallback;
21
+ }
22
+ function normalizeDetails(details) {
23
+ if (!details) return void 0;
24
+ const diagnostics = (details.diagnostics ?? []).map((diagnostic) => ({
25
+ message: boundText(diagnostic.message.replace(/[\r\n]+/g, " ").trim(), 300),
26
+ path: boundText(diagnostic.path.replaceAll("\\", "/"), 300)
27
+ })).sort((left, right) => {
28
+ const leftKey = `${left.path}\0${left.message}`;
29
+ const rightKey = `${right.path}\0${right.message}`;
30
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
31
+ }).slice(0, 32);
32
+ const resources = (details.resources ?? []).map(normalizeResource).filter((resource) => resource !== void 0).sort((left, right) => {
33
+ const leftKey = `${left.kind}\0${left.origin}\0${left.status ?? ""}\0${left.context ?? ""}`;
34
+ const rightKey = `${right.kind}\0${right.origin}\0${right.status ?? ""}\0${right.context ?? ""}`;
35
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
36
+ }).filter(
37
+ (resource, index, values) => index === 0 || JSON.stringify(resource) !== JSON.stringify(values[index - 1])
38
+ ).slice(0, 8);
39
+ const normalized = {
40
+ ...details.phase ? { phase: details.phase } : {},
41
+ ...diagnostics.length > 0 ? { diagnostics } : {},
42
+ ...resources.length > 0 ? { resources } : {}
43
+ };
44
+ return Object.keys(normalized).length > 0 ? normalized : void 0;
45
+ }
46
+ function normalizeResource(resource) {
47
+ let origin;
48
+ try {
49
+ origin = new URL(resource.origin).origin;
50
+ } catch {
51
+ return void 0;
52
+ }
53
+ const status = resource.status !== void 0 && Number.isInteger(resource.status) && resource.status >= 100 && resource.status <= 599 ? resource.status : void 0;
54
+ const context = resource.context ? boundText(resource.context.replace(/[^A-Za-z0-9 _.,:@+-]/g, "").trim(), 128) : void 0;
55
+ return {
56
+ ...context ? { context } : {},
57
+ kind: resource.kind,
58
+ origin,
59
+ ...status === void 0 ? {} : { status }
60
+ };
61
+ }
62
+ function boundText(value, limit) {
63
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}\u2026`;
64
+ }
65
+
66
+ // src/metadata.ts
67
+ import { readFileSync } from "fs";
68
+ import { createRequire } from "module";
69
+ import { dirname, join } from "path";
70
+ var require2 = createRequire(import.meta.url);
71
+ var capturePackage = readJson(new URL("../package.json", import.meta.url));
72
+ var playwrightPackagePath = require2.resolve("playwright/package.json");
73
+ var playwrightPackage = readJson(playwrightPackagePath);
74
+ var requireFromPlaywright = createRequire(playwrightPackagePath);
75
+ var playwrightCorePackagePath = requireFromPlaywright.resolve("playwright-core/package.json");
76
+ var browsers = readJson(
77
+ join(dirname(playwrightCorePackagePath), "browsers.json")
78
+ );
79
+ var maplibrePackage = readJson(require2.resolve("maplibre-gl/package.json"));
80
+ var chromiumHeadlessShell = browsers.browsers.find(
81
+ (browser) => browser.name === "chromium-headless-shell"
82
+ );
83
+ if (!chromiumHeadlessShell) {
84
+ throw new Error("The installed Playwright package does not declare a Chromium headless shell.");
85
+ }
86
+ var tileflowCaptureRuntime = Object.freeze({
87
+ tileflow: capturePackage.version,
88
+ maplibre: maplibrePackage.version,
89
+ playwright: playwrightPackage.version,
90
+ chromiumRevision: chromiumHeadlessShell.revision,
91
+ chromiumVersion: chromiumHeadlessShell.browserVersion ?? "unknown"
92
+ });
93
+ function createTileflowCaptureRendererIdentity(browser) {
94
+ return {
95
+ ...tileflowCaptureRuntime,
96
+ chromiumVersion: browser?.version() ?? tileflowCaptureRuntime.chromiumVersion
97
+ };
98
+ }
99
+ function getPlaywrightCliPath() {
100
+ return join(dirname(playwrightPackagePath), "cli.js");
101
+ }
102
+ function readJson(path) {
103
+ return JSON.parse(readFileSync(path, "utf8"));
104
+ }
105
+
106
+ // src/browser.ts
107
+ var chromiumEnvironmentAllowlist = [
108
+ "APPDATA",
109
+ "HOME",
110
+ "LANG",
111
+ "LC_ALL",
112
+ "LOCALAPPDATA",
113
+ "PATH",
114
+ "SystemRoot",
115
+ "TEMP",
116
+ "TMP",
117
+ "TMPDIR",
118
+ "TZ",
119
+ "USERPROFILE",
120
+ "WINDIR",
121
+ "XDG_CACHE_HOME",
122
+ "XDG_CONFIG_HOME",
123
+ "XDG_RUNTIME_DIR"
124
+ ];
125
+ var installerEnvironmentAllowlist = [
126
+ ...chromiumEnvironmentAllowlist,
127
+ "ALL_PROXY",
128
+ "HTTP_PROXY",
129
+ "HTTPS_PROXY",
130
+ "NO_PROXY",
131
+ "all_proxy",
132
+ "http_proxy",
133
+ "https_proxy",
134
+ "no_proxy",
135
+ "NODE_EXTRA_CA_CERTS",
136
+ "PLAYWRIGHT_BROWSERS_PATH",
137
+ "PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST",
138
+ "PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT",
139
+ "PLAYWRIGHT_DOWNLOAD_HOST"
140
+ ];
141
+ function createTileflowCaptureBrowserEnvironment(source = process.env) {
142
+ const environment = {
143
+ HOME: source.HOME || source.USERPROFILE || source.TMPDIR || source.TEMP || source.TMP || "/tmp",
144
+ LANG: source.LANG || "C.UTF-8"
145
+ };
146
+ for (const key of chromiumEnvironmentAllowlist) {
147
+ const value = source[key];
148
+ if (value) {
149
+ environment[key] = value;
150
+ }
151
+ }
152
+ return environment;
153
+ }
154
+ async function launchTileflowCaptureBrowser(options = {}) {
155
+ return launchTileflowCaptureBrowserWithDependencies(options, {
156
+ install: installPinnedBrowser,
157
+ launch: launchPinnedBrowser
158
+ });
159
+ }
160
+ async function launchTileflowCaptureBrowserWithDependencies(options, dependencies) {
161
+ throwIfAborted(options.signal);
162
+ try {
163
+ return await dependencies.launch();
164
+ } catch (error) {
165
+ if (!isMissingBrowserError(error)) {
166
+ throw new TileflowCaptureError(
167
+ "BROWSER_START_FAILED",
168
+ "Pinned Chromium could not start for Tileflow capture.",
169
+ { cause: error }
170
+ );
171
+ }
172
+ if (options.allowInstall === false) {
173
+ throw new TileflowCaptureError(
174
+ "BROWSER_MISSING",
175
+ "Pinned Chromium is not installed. Run `tileflow setup capture` or allow browser installation.",
176
+ { cause: error }
177
+ );
178
+ }
179
+ options.onInstallProgress?.("installing");
180
+ await dependencies.install(options.signal);
181
+ throwIfAborted(options.signal);
182
+ options.onInstallProgress?.("installed");
183
+ try {
184
+ return await dependencies.launch();
185
+ } catch (retryError) {
186
+ throw new TileflowCaptureError(
187
+ "BROWSER_START_FAILED",
188
+ "Pinned Chromium was installed but could not start for Tileflow capture.",
189
+ { cause: retryError }
190
+ );
191
+ }
192
+ }
193
+ }
194
+ async function setupTileflowCaptureBrowser(options = {}) {
195
+ const browser = await launchTileflowCaptureBrowser(options);
196
+ try {
197
+ return createTileflowCaptureRendererIdentity(browser);
198
+ } finally {
199
+ await browser.close();
200
+ }
201
+ }
202
+ var TileflowCaptureBrowserManager = class {
203
+ #dependencies;
204
+ #options;
205
+ #browserPromise;
206
+ #closed = false;
207
+ constructor(options = {}, dependencies = {
208
+ install: installPinnedBrowser,
209
+ launch: launchPinnedBrowser
210
+ }) {
211
+ this.#options = options;
212
+ this.#dependencies = dependencies;
213
+ }
214
+ getBrowser(signal = this.#options.signal) {
215
+ if (this.#closed) {
216
+ return Promise.reject(
217
+ new TileflowCaptureError("BROWSER_START_FAILED", "Tileflow capture session is closed.")
218
+ );
219
+ }
220
+ try {
221
+ throwIfAborted(signal);
222
+ } catch (error) {
223
+ return Promise.reject(error);
224
+ }
225
+ this.#browserPromise ??= launchTileflowCaptureBrowserWithDependencies(
226
+ { ...this.#options, signal },
227
+ this.#dependencies
228
+ ).catch((error) => {
229
+ this.#browserPromise = void 0;
230
+ throw error;
231
+ });
232
+ return this.#browserPromise;
233
+ }
234
+ async close() {
235
+ if (this.#closed) {
236
+ return;
237
+ }
238
+ this.#closed = true;
239
+ const browserPromise = this.#browserPromise;
240
+ this.#browserPromise = void 0;
241
+ if (!browserPromise) {
242
+ return;
243
+ }
244
+ try {
245
+ const browser = await browserPromise;
246
+ await browser.close();
247
+ } catch {
248
+ }
249
+ }
250
+ };
251
+ function launchPinnedBrowser() {
252
+ return chromium.launch({
253
+ args: ["--disable-dev-shm-usage"],
254
+ chromiumSandbox: true,
255
+ env: createTileflowCaptureBrowserEnvironment(),
256
+ handleSIGHUP: false,
257
+ handleSIGINT: false,
258
+ handleSIGTERM: false,
259
+ headless: true
260
+ });
261
+ }
262
+ async function installPinnedBrowser(signal) {
263
+ throwIfAborted(signal);
264
+ const invocation = getPlaywrightInstallerInvocation();
265
+ await new Promise((resolve2, reject) => {
266
+ const child = spawn(invocation.command, invocation.args, {
267
+ env: createTileflowCaptureBrowserInstallerEnvironment(),
268
+ shell: false,
269
+ stdio: "ignore",
270
+ windowsHide: true
271
+ });
272
+ let settled = false;
273
+ const finish = (callback) => {
274
+ if (settled) {
275
+ return;
276
+ }
277
+ settled = true;
278
+ signal?.removeEventListener("abort", onAbort);
279
+ callback();
280
+ };
281
+ const onAbort = () => {
282
+ child.kill("SIGTERM");
283
+ finish(() => reject(new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.")));
284
+ };
285
+ signal?.addEventListener("abort", onAbort, { once: true });
286
+ child.once("error", (error) => {
287
+ finish(
288
+ () => reject(
289
+ new TileflowCaptureError(
290
+ "BROWSER_INSTALL_FAILED",
291
+ "Pinned Chromium installation could not start.",
292
+ { cause: error }
293
+ )
294
+ )
295
+ );
296
+ });
297
+ child.once("exit", (code, exitSignal) => {
298
+ finish(() => {
299
+ if (code === 0) {
300
+ resolve2();
301
+ return;
302
+ }
303
+ reject(
304
+ new TileflowCaptureError(
305
+ "BROWSER_INSTALL_FAILED",
306
+ `Pinned Chromium installation failed${exitSignal ? ` after ${exitSignal}` : ""}.`
307
+ )
308
+ );
309
+ });
310
+ });
311
+ });
312
+ }
313
+ function createTileflowCaptureBrowserInstallerEnvironment(source = process.env) {
314
+ return pickEnvironment(installerEnvironmentAllowlist, source);
315
+ }
316
+ function getPlaywrightInstallerInvocation() {
317
+ return {
318
+ command: process.execPath,
319
+ args: [getPlaywrightCliPath(), "install", "--only-shell", "chromium"]
320
+ };
321
+ }
322
+ function pickEnvironment(keys, source = process.env) {
323
+ const environment = {};
324
+ for (const key of keys) {
325
+ const value = source[key];
326
+ if (value) {
327
+ environment[key] = value;
328
+ }
329
+ }
330
+ environment.HOME ||= environment.USERPROFILE || environment.TMPDIR || environment.TEMP || environment.TMP || "/tmp";
331
+ environment.LANG ||= "C.UTF-8";
332
+ return environment;
333
+ }
334
+ function isMissingBrowserError(error) {
335
+ const message = error instanceof Error ? error.message : String(error);
336
+ return message.includes("Executable doesn't exist") || message.includes("Executable does not exist");
337
+ }
338
+ function throwIfAborted(signal) {
339
+ if (signal?.aborted) {
340
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
341
+ }
342
+ }
343
+
344
+ // src/capture.ts
345
+ import { resolve } from "path";
346
+ import {
347
+ compareCodeUnits as compareCodeUnits3,
348
+ normalizeTileflowCaptureScene,
349
+ serializeCanonicalJson as serializeCanonicalJson2,
350
+ sha256Hex,
351
+ tileflowCaptureSceneNameSchema as tileflowCaptureSceneNameSchema2
352
+ } from "@tileflow/core";
353
+ import {
354
+ assertValidTileflowStyle as assertValidTileflowStyle2,
355
+ createTileflowBuildArtifacts,
356
+ defaultTileflowConfigPath,
357
+ TileflowStyleValidationError as TileflowStyleValidationError2
358
+ } from "@tileflow/dev";
359
+
360
+ // src/application.ts
361
+ import { tileflowCaptureSceneLimits } from "@tileflow/core";
362
+
363
+ // src/standalone.ts
364
+ import { createRequire as createRequire2 } from "module";
365
+ import {
366
+ assertValidTileflowStyle,
367
+ TileflowStyleValidationError
368
+ } from "@tileflow/dev";
369
+ var tileflowSyntheticAssetOrigin = "https://tileflow.local.invalid";
370
+ var require3 = createRequire2(import.meta.url);
371
+ var maplibreJsPath = require3.resolve("maplibre-gl/dist/maplibre-gl.js");
372
+ var maplibreCssPath = require3.resolve("maplibre-gl/dist/maplibre-gl.css");
373
+ async function captureStandaloneTileflowScene(input) {
374
+ if (input.scene.target.kind !== "map") {
375
+ throw new TileflowCaptureError(
376
+ "APPLICATION_ORIGIN_REQUIRED",
377
+ "Application scenes require an explicit loopback application origin."
378
+ );
379
+ }
380
+ try {
381
+ assertValidTileflowStyle(input.style, input.scene.map);
382
+ } catch (error) {
383
+ if (error instanceof TileflowStyleValidationError) {
384
+ throw new TileflowCaptureError("STYLE_INVALID", error.message, {
385
+ cause: error,
386
+ details: {
387
+ diagnostics: error.issues,
388
+ phase: "style-validation"
389
+ }
390
+ });
391
+ }
392
+ throw error;
393
+ }
394
+ const timeoutMs = input.timeoutMs ?? 3e4;
395
+ let termination;
396
+ let context;
397
+ let phase = "browser-start";
398
+ let timeout;
399
+ const onAbort = () => {
400
+ termination = "aborted";
401
+ void context?.close().catch(() => void 0);
402
+ };
403
+ if (input.signal?.aborted) {
404
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
405
+ }
406
+ try {
407
+ context = await input.browser.newContext({
408
+ acceptDownloads: false,
409
+ colorScheme: "light",
410
+ deviceScaleFactor: input.scene.viewport.dpr,
411
+ ignoreHTTPSErrors: false,
412
+ javaScriptEnabled: true,
413
+ locale: "en-US",
414
+ reducedMotion: "reduce",
415
+ serviceWorkers: "block",
416
+ timezoneId: "UTC",
417
+ viewport: {
418
+ height: input.scene.viewport.height,
419
+ width: input.scene.viewport.width
420
+ }
421
+ });
422
+ input.signal?.addEventListener("abort", onAbort, { once: true });
423
+ if (input.signal?.aborted) {
424
+ onAbort();
425
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
426
+ }
427
+ timeout = setTimeout(() => {
428
+ termination = "timeout";
429
+ void context?.close().catch(() => void 0);
430
+ }, timeoutMs);
431
+ timeout.unref?.();
432
+ const assets = new Map(input.assets.map((asset) => [asset.fileName, asset]));
433
+ const remoteOrigins = /* @__PURE__ */ new Set();
434
+ let syntheticAssetFailure;
435
+ const resourceFailures = [];
436
+ let pageFailed = false;
437
+ const recordResourceFailure = (url, status) => {
438
+ const diagnostic = classifyResource(url, status);
439
+ if (!diagnostic || resourceFailures.length >= 16) return;
440
+ const key = JSON.stringify(diagnostic);
441
+ if (!resourceFailures.some((item) => JSON.stringify(item) === key)) {
442
+ resourceFailures.push(diagnostic);
443
+ }
444
+ };
445
+ await context.route("**/*", async (route) => {
446
+ const url = new URL(route.request().url());
447
+ if (url.origin === tileflowSyntheticAssetOrigin) {
448
+ const fileName = decodeURIComponent(url.pathname.slice(1));
449
+ const asset = url.search === "" ? assets.get(fileName) : void 0;
450
+ if (!asset) {
451
+ syntheticAssetFailure = fileName || "unknown";
452
+ await route.abort("blockedbyclient");
453
+ return;
454
+ }
455
+ await route.fulfill({
456
+ body: typeof asset.source === "string" ? asset.source : Buffer.from(asset.source),
457
+ contentType: asset.contentType,
458
+ status: 200
459
+ });
460
+ return;
461
+ }
462
+ if (url.protocol === "http:" || url.protocol === "https:") {
463
+ if (!isLoopbackUrl(url)) remoteOrigins.add(url.origin);
464
+ await route.continue();
465
+ return;
466
+ }
467
+ pageFailed = true;
468
+ await route.abort("blockedbyclient");
469
+ });
470
+ await context.routeWebSocket(/.*/, async (socket) => {
471
+ pageFailed = true;
472
+ await socket.close({ code: 1008, reason: "WebSockets are disabled during capture" });
473
+ });
474
+ const page = await context.newPage();
475
+ page.on("dialog", (dialog) => void dialog.dismiss());
476
+ page.on("popup", (popup) => void popup.close());
477
+ page.on("pageerror", () => {
478
+ pageFailed = true;
479
+ });
480
+ page.on("response", (response) => {
481
+ if (response.status() >= 400) {
482
+ recordResourceFailure(response.url(), response.status());
483
+ }
484
+ });
485
+ page.on("requestfailed", (request) => recordResourceFailure(request.url()));
486
+ page.setDefaultTimeout(timeoutMs);
487
+ await page.setContent(renderHtml(input.scene.viewport), { waitUntil: "domcontentloaded" });
488
+ await page.addStyleTag({ path: maplibreCssPath });
489
+ await page.addScriptTag({ path: maplibreJsPath });
490
+ await page.addScriptTag({ content: renderMapInPageScript });
491
+ phase = "map-load";
492
+ const loadResult = await page.evaluate(
493
+ (renderInput) => {
494
+ const renderWindow = window;
495
+ return renderWindow.__tileflowCaptureLoad(renderInput);
496
+ },
497
+ {
498
+ mapEventTimeoutMs: Math.max(1, timeoutMs - 1e3),
499
+ style: input.style
500
+ }
501
+ );
502
+ assertPhaseSucceeded(loadResult, phase, syntheticAssetFailure, resourceFailures, pageFailed);
503
+ phase = "map-idle";
504
+ const idleResult = await page.evaluate(
505
+ (renderInput) => {
506
+ const renderWindow = window;
507
+ return renderWindow.__tileflowCaptureIdle(renderInput);
508
+ },
509
+ {
510
+ camera: input.scene.camera,
511
+ mapEventTimeoutMs: Math.max(1, timeoutMs - 1e3)
512
+ }
513
+ );
514
+ assertPhaseSucceeded(idleResult, phase, syntheticAssetFailure, resourceFailures, pageFailed);
515
+ phase = "screenshot";
516
+ let png;
517
+ try {
518
+ png = new Uint8Array(
519
+ await page.locator("#map").screenshot({ animations: "disabled", type: "png" })
520
+ );
521
+ } catch (error) {
522
+ throw new TileflowCaptureError(
523
+ "SCREENSHOT_FAILED",
524
+ "Tileflow could not produce the map PNG.",
525
+ {
526
+ cause: error,
527
+ details: { phase }
528
+ }
529
+ );
530
+ }
531
+ assertPostScreenshotSucceeded(syntheticAssetFailure, resourceFailures, pageFailed);
532
+ assertPngDimensions(
533
+ png,
534
+ input.scene.viewport.width * input.scene.viewport.dpr,
535
+ input.scene.viewport.height * input.scene.viewport.dpr
536
+ );
537
+ const warnings = [...remoteOrigins].sort(compareCodeUnits).map((origin) => `Capture requested remote resources from ${origin}.`);
538
+ return {
539
+ height: input.scene.viewport.height,
540
+ networkDependent: remoteOrigins.size > 0,
541
+ png,
542
+ warnings,
543
+ width: input.scene.viewport.width
544
+ };
545
+ } catch (error) {
546
+ if (termination === "aborted") {
547
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
548
+ }
549
+ if (termination === "timeout") {
550
+ throw new TileflowCaptureError("CAPTURE_TIMEOUT", timeoutMessage(phase), {
551
+ cause: error,
552
+ details: { phase }
553
+ });
554
+ }
555
+ if (error instanceof TileflowCaptureError) {
556
+ throw error;
557
+ }
558
+ if (phase === "browser-start") {
559
+ throw new TileflowCaptureError(
560
+ "BROWSER_START_FAILED",
561
+ "Tileflow could not create an isolated capture page.",
562
+ { cause: error, details: { phase } }
563
+ );
564
+ }
565
+ if (phase === "screenshot") {
566
+ throw new TileflowCaptureError(
567
+ "SCREENSHOT_FAILED",
568
+ "Tileflow could not produce the map PNG.",
569
+ {
570
+ cause: error,
571
+ details: { phase }
572
+ }
573
+ );
574
+ }
575
+ throw new TileflowCaptureError("MAP_LOAD_FAILED", mapFailureMessage(phase), {
576
+ cause: error,
577
+ details: { phase }
578
+ });
579
+ } finally {
580
+ if (timeout) {
581
+ clearTimeout(timeout);
582
+ }
583
+ input.signal?.removeEventListener("abort", onAbort);
584
+ await context?.close().catch(() => void 0);
585
+ }
586
+ }
587
+ function assertPhaseSucceeded(result, phase, syntheticAssetFailure, resourceFailures, pageFailed) {
588
+ if (syntheticAssetFailure) {
589
+ throw new TileflowCaptureError(
590
+ "SYNTHETIC_ASSET_NOT_FOUND",
591
+ `Compiled capture asset is unavailable: ${safeAssetName(syntheticAssetFailure)}`,
592
+ { details: { phase: "resource-load" } }
593
+ );
594
+ }
595
+ if (resourceFailures.length > 0) throwResourceFailure(resourceFailures);
596
+ if (result.status === "failed" && result.reason === "timeout") {
597
+ throw new TileflowCaptureError("CAPTURE_TIMEOUT", timeoutMessage(phase), {
598
+ details: { phase }
599
+ });
600
+ }
601
+ if (result.status === "failed" || pageFailed) {
602
+ throw new TileflowCaptureError("MAP_LOAD_FAILED", mapFailureMessage(phase), {
603
+ details: { phase }
604
+ });
605
+ }
606
+ }
607
+ function assertPostScreenshotSucceeded(syntheticAssetFailure, resourceFailures, pageFailed) {
608
+ if (syntheticAssetFailure) {
609
+ throw new TileflowCaptureError(
610
+ "SYNTHETIC_ASSET_NOT_FOUND",
611
+ `Compiled capture asset is unavailable: ${safeAssetName(syntheticAssetFailure)}`,
612
+ { details: { phase: "resource-load" } }
613
+ );
614
+ }
615
+ if (resourceFailures.length > 0) throwResourceFailure(resourceFailures);
616
+ if (pageFailed) {
617
+ throw new TileflowCaptureError(
618
+ "SCREENSHOT_FAILED",
619
+ "The capture page failed while producing the map PNG.",
620
+ { details: { phase: "screenshot" } }
621
+ );
622
+ }
623
+ }
624
+ function throwResourceFailure(resources) {
625
+ const first = [...resources].sort(compareResources)[0];
626
+ const label = resourceLabel(first.kind);
627
+ const status = first.status === void 0 ? "failed" : `returned HTTP ${first.status}`;
628
+ throw new TileflowCaptureError(
629
+ "RESOURCE_FAILED",
630
+ `${label} resource ${status} from ${first.origin}.`,
631
+ {
632
+ details: {
633
+ phase: "resource-load",
634
+ resources
635
+ }
636
+ }
637
+ );
638
+ }
639
+ function classifyResource(value, status) {
640
+ let url;
641
+ try {
642
+ url = new URL(value);
643
+ } catch {
644
+ return void 0;
645
+ }
646
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
647
+ const path = url.pathname.toLowerCase();
648
+ let kind = "other-http";
649
+ let context;
650
+ if (/(?:^|\/)(?:fonts?|glyphs?)(?:\/|$)/.test(path)) {
651
+ kind = "glyph";
652
+ context = fontStackContext(url.pathname);
653
+ } else if (/sprite(?:@2x)?\.json$/.test(path)) {
654
+ kind = "sprite-json";
655
+ } else if (/sprite(?:@2x)?\.(?:png|webp)$/.test(path)) {
656
+ kind = "sprite-image";
657
+ } else if (/(?:tiles|tilejson)\.json$/.test(path) || path.includes("/tilejson/")) {
658
+ kind = "tilejson";
659
+ } else if (/\.pbf$/.test(path) || /\/tiles?\/\d+\/\d+\/\d+/.test(path)) {
660
+ kind = "vector-tile";
661
+ }
662
+ return {
663
+ ...context ? { context } : {},
664
+ kind,
665
+ origin: url.origin,
666
+ ...status === void 0 ? {} : { status }
667
+ };
668
+ }
669
+ function fontStackContext(path) {
670
+ const segments = path.split("/").filter(Boolean);
671
+ const index = segments.findIndex((segment) => /^(?:fonts?|glyphs?)$/i.test(segment));
672
+ if (index < 0 || !segments[index + 1]) return void 0;
673
+ try {
674
+ const stack = decodeURIComponent(segments[index + 1]).replace(/[^A-Za-z0-9 _.,:@+-]/g, "").trim().slice(0, 96);
675
+ return stack ? `fontStack: ${stack}` : void 0;
676
+ } catch {
677
+ return void 0;
678
+ }
679
+ }
680
+ function compareResources(left, right) {
681
+ const leftKey = `${left.kind}\0${left.origin}\0${left.status ?? ""}\0${left.context ?? ""}`;
682
+ const rightKey = `${right.kind}\0${right.origin}\0${right.status ?? ""}\0${right.context ?? ""}`;
683
+ return compareCodeUnits(leftKey, rightKey);
684
+ }
685
+ function resourceLabel(kind) {
686
+ return {
687
+ glyph: "Glyph",
688
+ "other-http": "HTTP",
689
+ "sprite-image": "Sprite image",
690
+ "sprite-json": "Sprite JSON",
691
+ tilejson: "TileJSON",
692
+ "vector-tile": "Vector tile"
693
+ }[kind];
694
+ }
695
+ function timeoutMessage(phase) {
696
+ if (phase === "map-load") return "MapLibre did not finish loading before the capture timeout.";
697
+ if (phase === "map-idle") return "MapLibre did not become idle before the capture timeout.";
698
+ if (phase === "screenshot") return "PNG production exceeded the capture timeout.";
699
+ return "Tileflow capture exceeded its timeout while creating the browser page.";
700
+ }
701
+ function mapFailureMessage(phase) {
702
+ return phase === "map-load" ? "MapLibre failed while loading the compiled style." : "MapLibre failed while waiting for the committed camera to become idle.";
703
+ }
704
+ function safeAssetName(value) {
705
+ return value.replace(/[^A-Za-z0-9._/-]/g, "").slice(0, 200) || "unknown";
706
+ }
707
+ function isLoopbackUrl(url) {
708
+ const hostname = url.hostname.toLowerCase();
709
+ return hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
710
+ }
711
+ function readPngDimensions(png) {
712
+ if (png.byteLength < 24 || png[0] !== 137 || png[1] !== 80 || png[2] !== 78 || png[3] !== 71 || png[4] !== 13 || png[5] !== 10 || png[6] !== 26 || png[7] !== 10) {
713
+ throw new TileflowCaptureError("INVALID_PNG", "Capture output is not a valid PNG image.");
714
+ }
715
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
716
+ return { width: view.getUint32(16), height: view.getUint32(20) };
717
+ }
718
+ function assertPngDimensions(png, width, height) {
719
+ let actual;
720
+ try {
721
+ actual = readPngDimensions(png);
722
+ } catch (error) {
723
+ throw new TileflowCaptureError("INVALID_PNG", "Capture output is not a valid PNG image.", {
724
+ cause: error,
725
+ details: { phase: "screenshot" }
726
+ });
727
+ }
728
+ if (actual.width !== width || actual.height !== height) {
729
+ throw new TileflowCaptureError(
730
+ "INVALID_PNG",
731
+ `Capture PNG dimensions are ${actual.width}x${actual.height}; expected ${width}x${height}.`,
732
+ { details: { phase: "screenshot" } }
733
+ );
734
+ }
735
+ }
736
+ function renderHtml(viewport) {
737
+ return `<!doctype html>
738
+ <html lang="en">
739
+ <head>
740
+ <meta charset="utf-8" />
741
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
742
+ <style>
743
+ html, body, #map {
744
+ width: ${viewport.width}px;
745
+ height: ${viewport.height}px;
746
+ margin: 0;
747
+ overflow: hidden;
748
+ }
749
+ body { background: transparent; }
750
+ </style>
751
+ </head>
752
+ <body><div id="map"></div></body>
753
+ </html>`;
754
+ }
755
+ var renderMapInPageScript = String.raw`
756
+ window.__tileflowCaptureLoad = async function(input) {
757
+ try {
758
+ const map = new window.maplibregl.Map({
759
+ attributionControl: false,
760
+ container: "map",
761
+ fadeDuration: 0,
762
+ interactive: false,
763
+ style: input.style
764
+ });
765
+ window.__tileflowMap = map;
766
+ return await waitForMapEvent(map, "load", input.mapEventTimeoutMs);
767
+ } catch {
768
+ return {status: "failed", reason: "error"};
769
+ }
770
+ };
771
+
772
+ window.__tileflowCaptureIdle = async function(input) {
773
+ const map = window.__tileflowMap;
774
+ if (!map) return {status: "failed", reason: "error"};
775
+
776
+ try {
777
+ applyCamera(map, input.camera);
778
+ const result = await waitForMapEvent(map, "idle", input.mapEventTimeoutMs);
779
+ if (result.status === "failed") return result;
780
+ await nextFrame();
781
+ await nextFrame();
782
+ return {status: "ok"};
783
+ } catch {
784
+ return {status: "failed", reason: "error"};
785
+ }
786
+ };
787
+
788
+ function applyCamera(mapInstance, camera) {
789
+ if (camera.type === "bounds") {
790
+ mapInstance.fitBounds(
791
+ [
792
+ [camera.bounds[0], camera.bounds[1]],
793
+ [camera.bounds[2], camera.bounds[3]]
794
+ ],
795
+ {
796
+ bearing: camera.bearing,
797
+ duration: 0,
798
+ padding: camera.padding,
799
+ pitch: camera.pitch
800
+ }
801
+ );
802
+ return;
803
+ }
804
+
805
+ mapInstance.jumpTo({
806
+ bearing: camera.bearing,
807
+ center: camera.center,
808
+ pitch: camera.pitch,
809
+ zoom: camera.zoom
810
+ });
811
+ }
812
+
813
+ function waitForMapEvent(mapInstance, event, timeoutMs) {
814
+ return new Promise((resolve) => {
815
+ const timeout = window.setTimeout(() => {
816
+ cleanup();
817
+ resolve({status: "failed", reason: "timeout"});
818
+ }, timeoutMs);
819
+ const onSuccess = () => {
820
+ cleanup();
821
+ resolve({status: "ok"});
822
+ };
823
+ const onError = () => {
824
+ cleanup();
825
+ resolve({status: "failed", reason: "error"});
826
+ };
827
+ const cleanup = () => {
828
+ window.clearTimeout(timeout);
829
+ mapInstance.off(event, onSuccess);
830
+ mapInstance.off("error", onError);
831
+ };
832
+
833
+ mapInstance.once(event, onSuccess);
834
+ mapInstance.once("error", onError);
835
+ });
836
+ }
837
+
838
+ function nextFrame() {
839
+ return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
840
+ }
841
+ `;
842
+ function compareCodeUnits(left, right) {
843
+ return left < right ? -1 : left > right ? 1 : 0;
844
+ }
845
+
846
+ // src/application.ts
847
+ var maximumApplicationUrlLength = tileflowCaptureSceneLimits.applicationPathLength + 512;
848
+ function resolveTileflowApplicationUrl(input) {
849
+ if (input.appOrigin && input.appUrl) {
850
+ throw new TileflowCaptureError(
851
+ "APPLICATION_ORIGIN_INVALID",
852
+ "Use either an application origin or a full application URL, not both."
853
+ );
854
+ }
855
+ if (input.appUrl) {
856
+ const url2 = parseLoopbackHttpUrl(input.appUrl, false);
857
+ return { origin: url2.origin, url: url2.toString() };
858
+ }
859
+ if (!input.appOrigin) {
860
+ throw new TileflowCaptureError(
861
+ "APPLICATION_ORIGIN_REQUIRED",
862
+ "Application capture requires --app-origin, --url, TILEFLOW_APP_ORIGIN, or a programmatic equivalent."
863
+ );
864
+ }
865
+ const origin = parseLoopbackHttpUrl(input.appOrigin, true);
866
+ const url = parseLoopbackHttpUrl(new URL(input.path, `${origin.origin}/`).toString(), false);
867
+ return { origin: origin.origin, url: url.toString() };
868
+ }
869
+ async function captureApplicationTileflowScene(input) {
870
+ const application = resolveTileflowApplicationUrl({
871
+ appOrigin: input.appOrigin,
872
+ appUrl: input.appUrl,
873
+ path: input.scene.target.path
874
+ });
875
+ const timeoutMs = input.timeoutMs ?? 3e4;
876
+ let context;
877
+ let termination;
878
+ let timeout;
879
+ const onAbort = () => {
880
+ termination = "aborted";
881
+ void context?.close().catch(() => void 0);
882
+ };
883
+ if (input.signal?.aborted) {
884
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
885
+ }
886
+ try {
887
+ context = await input.browser.newContext({
888
+ acceptDownloads: false,
889
+ colorScheme: "light",
890
+ deviceScaleFactor: input.scene.viewport.dpr,
891
+ ignoreHTTPSErrors: false,
892
+ javaScriptEnabled: true,
893
+ locale: "en-US",
894
+ reducedMotion: "reduce",
895
+ serviceWorkers: "block",
896
+ timezoneId: "UTC",
897
+ viewport: {
898
+ height: input.scene.viewport.height,
899
+ width: input.scene.viewport.width
900
+ }
901
+ });
902
+ input.signal?.addEventListener("abort", onAbort, { once: true });
903
+ if (input.signal?.aborted) {
904
+ onAbort();
905
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
906
+ }
907
+ timeout = setTimeout(() => {
908
+ termination = "timeout";
909
+ void context?.close().catch(() => void 0);
910
+ }, timeoutMs);
911
+ timeout.unref?.();
912
+ const page = await context.newPage();
913
+ const remoteOrigins = /* @__PURE__ */ new Set();
914
+ let applicationFailure = false;
915
+ let applicationOriginEscape = false;
916
+ page.setDefaultTimeout(timeoutMs);
917
+ await page.route("**/*", async (route) => {
918
+ const request = route.request();
919
+ try {
920
+ if (request.isNavigationRequest() && request.frame().parentFrame() === null && !isApprovedApplicationDocumentUrl(request.url(), application.origin)) {
921
+ applicationOriginEscape = true;
922
+ await route.abort("blockedbyclient");
923
+ return;
924
+ }
925
+ } catch {
926
+ }
927
+ await route.continue();
928
+ });
929
+ page.on("dialog", (dialog) => void dialog.dismiss());
930
+ page.on("popup", (popup) => void popup.close());
931
+ page.on("console", (message) => {
932
+ if (message.type() === "error") applicationFailure = true;
933
+ });
934
+ page.on("pageerror", () => {
935
+ applicationFailure = true;
936
+ });
937
+ page.on("websocket", (socket) => {
938
+ try {
939
+ const url = new URL(socket.url());
940
+ if ((url.protocol === "ws:" || url.protocol === "wss:") && webSocketHttpOrigin(url) !== application.origin) {
941
+ remoteOrigins.add(url.origin);
942
+ }
943
+ } catch {
944
+ }
945
+ });
946
+ page.on("request", (request) => {
947
+ try {
948
+ const url = new URL(request.url());
949
+ if ((url.protocol === "http:" || url.protocol === "https:") && url.origin !== application.origin) {
950
+ remoteOrigins.add(url.origin);
951
+ }
952
+ } catch {
953
+ }
954
+ });
955
+ page.on("response", (response) => {
956
+ if (response.status() >= 400 && new URL(response.url()).origin === application.origin) {
957
+ applicationFailure = true;
958
+ }
959
+ });
960
+ let navigationResponse;
961
+ try {
962
+ navigationResponse = await page.goto(application.url, { waitUntil: "domcontentloaded" });
963
+ } catch (error) {
964
+ throw new TileflowCaptureError(
965
+ "APPLICATION_NAVIGATION_FAILED",
966
+ "The loopback application page could not be loaded.",
967
+ { cause: error }
968
+ );
969
+ }
970
+ if (!navigationResponse || navigationResponse.status() >= 400 || !isApprovedApplicationDocumentUrl(navigationResponse.url(), application.origin) || !isApprovedApplicationDocumentUrl(page.url(), application.origin) || applicationOriginEscape) {
971
+ throw new TileflowCaptureError(
972
+ "APPLICATION_NAVIGATION_FAILED",
973
+ "The loopback application page did not return a successful document on the approved origin."
974
+ );
975
+ }
976
+ const locator = createTargetLocator(page, input.scene);
977
+ let targetCount;
978
+ try {
979
+ targetCount = await locator.count();
980
+ } catch (error) {
981
+ throw new TileflowCaptureError(
982
+ "APPLICATION_TARGET_NOT_FOUND",
983
+ "The application capture selector is invalid.",
984
+ { cause: error }
985
+ );
986
+ }
987
+ if (targetCount === 0) {
988
+ throw new TileflowCaptureError(
989
+ "APPLICATION_TARGET_NOT_FOUND",
990
+ "The application page did not contain the requested Tileflow capture target."
991
+ );
992
+ }
993
+ if (targetCount !== 1) {
994
+ throw new TileflowCaptureError(
995
+ "APPLICATION_TARGET_AMBIGUOUS",
996
+ `The application page contained ${targetCount} matching Tileflow capture targets; expected exactly one.`
997
+ );
998
+ }
999
+ await locator.waitFor({ state: "visible" });
1000
+ await waitForApplicationTarget(
1001
+ locator,
1002
+ input.scene.target.selector !== void 0,
1003
+ timeoutMs,
1004
+ () => applicationFailure || applicationOriginEscape
1005
+ );
1006
+ if (applicationFailure || applicationOriginEscape) {
1007
+ throw new TileflowCaptureError(
1008
+ "APPLICATION_ERROR",
1009
+ "The application reported an error before its capture target became ready."
1010
+ );
1011
+ }
1012
+ if (!isApprovedApplicationDocumentUrl(page.url(), application.origin)) {
1013
+ throw new TileflowCaptureError(
1014
+ "APPLICATION_NAVIGATION_FAILED",
1015
+ "The application page left the approved loopback origin before capture."
1016
+ );
1017
+ }
1018
+ if (input.scene.target.frame === "map") {
1019
+ await assertApplicationTargetPixelBounds(locator, input.scene.viewport.dpr);
1020
+ }
1021
+ const png = new Uint8Array(
1022
+ input.scene.target.frame === "viewport" ? await page.screenshot({ animations: "disabled", type: "png" }) : await locator.screenshot({ animations: "disabled", type: "png" })
1023
+ );
1024
+ await assertApplicationTargetRemainsReady(locator, input.scene.target.selector !== void 0);
1025
+ if (applicationFailure || applicationOriginEscape) {
1026
+ throw new TileflowCaptureError(
1027
+ "APPLICATION_ERROR",
1028
+ "The application reported an error before capture completed."
1029
+ );
1030
+ }
1031
+ if (!isApprovedApplicationDocumentUrl(page.url(), application.origin)) {
1032
+ throw new TileflowCaptureError(
1033
+ "APPLICATION_NAVIGATION_FAILED",
1034
+ "The application page left the approved loopback origin during capture."
1035
+ );
1036
+ }
1037
+ const physical = readPngDimensions(png);
1038
+ if (physical.width > tileflowCaptureSceneLimits.viewport.maximum * 2 || physical.height > tileflowCaptureSceneLimits.viewport.maximum * 2 || physical.width * physical.height > tileflowCaptureSceneLimits.maximumPhysicalPixels) {
1039
+ throw new TileflowCaptureError(
1040
+ "RENDER_FAILED",
1041
+ "The application capture target exceeded the bounded visual pixel limit."
1042
+ );
1043
+ }
1044
+ const warnings = [...remoteOrigins].sort(compareCodeUnits2).map((origin) => `Application capture requested remote resources from ${origin}.`);
1045
+ return {
1046
+ height: physical.height / input.scene.viewport.dpr,
1047
+ networkDependent: remoteOrigins.size > 0,
1048
+ png,
1049
+ warnings,
1050
+ width: physical.width / input.scene.viewport.dpr
1051
+ };
1052
+ } catch (error) {
1053
+ if (termination === "aborted") {
1054
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
1055
+ }
1056
+ if (termination === "timeout") {
1057
+ throw new TileflowCaptureError(
1058
+ "CAPTURE_TIMEOUT",
1059
+ "Tileflow capture timed out before the application target became idle.",
1060
+ { cause: error }
1061
+ );
1062
+ }
1063
+ if (error instanceof TileflowCaptureError) throw error;
1064
+ throw new TileflowCaptureError(
1065
+ "APPLICATION_ERROR",
1066
+ "The application target failed before capture completed.",
1067
+ { cause: error }
1068
+ );
1069
+ } finally {
1070
+ if (timeout) clearTimeout(timeout);
1071
+ input.signal?.removeEventListener("abort", onAbort);
1072
+ await context?.close().catch(() => void 0);
1073
+ }
1074
+ }
1075
+ async function assertApplicationTargetRemainsReady(locator, allowUnmarkedSelector) {
1076
+ const state = await locator.getAttribute("data-tileflow-state");
1077
+ if (state === "idle" || state === null && allowUnmarkedSelector) return;
1078
+ throw new TileflowCaptureError(
1079
+ "APPLICATION_ERROR",
1080
+ "The application capture target stopped being ready while its screenshot was produced."
1081
+ );
1082
+ }
1083
+ function webSocketHttpOrigin(url) {
1084
+ const comparable = new URL(url);
1085
+ comparable.protocol = url.protocol === "wss:" ? "https:" : "http:";
1086
+ return comparable.origin;
1087
+ }
1088
+ async function assertApplicationTargetPixelBounds(locator, dpr) {
1089
+ const box = await locator.boundingBox();
1090
+ if (!box || !Number.isFinite(box.width) || !Number.isFinite(box.height)) {
1091
+ throw new TileflowCaptureError(
1092
+ "RENDER_FAILED",
1093
+ "The application capture target did not have stable bounded dimensions."
1094
+ );
1095
+ }
1096
+ const physicalWidth = Math.ceil(box.width * dpr);
1097
+ const physicalHeight = Math.ceil(box.height * dpr);
1098
+ if (physicalWidth <= 0 || physicalHeight <= 0 || physicalWidth > tileflowCaptureSceneLimits.viewport.maximum * 2 || physicalHeight > tileflowCaptureSceneLimits.viewport.maximum * 2 || physicalWidth * physicalHeight > tileflowCaptureSceneLimits.maximumPhysicalPixels) {
1099
+ throw new TileflowCaptureError(
1100
+ "RENDER_FAILED",
1101
+ "The application capture target exceeded the bounded visual pixel limit."
1102
+ );
1103
+ }
1104
+ }
1105
+ function createTargetLocator(page, scene) {
1106
+ try {
1107
+ if (scene.target.captureId) {
1108
+ return page.locator(`[data-tileflow-capture-id="${scene.target.captureId}"]`);
1109
+ }
1110
+ if (scene.target.selector) return page.locator(scene.target.selector);
1111
+ return page.locator(`[data-tileflow-map="${scene.map}"]`);
1112
+ } catch (error) {
1113
+ throw new TileflowCaptureError(
1114
+ "APPLICATION_TARGET_NOT_FOUND",
1115
+ "The application capture selector is invalid.",
1116
+ { cause: error }
1117
+ );
1118
+ }
1119
+ }
1120
+ async function waitForApplicationTarget(locator, allowUnmarkedSelector, timeoutMs, hasApplicationFailure) {
1121
+ const deadline = Date.now() + timeoutMs;
1122
+ while (Date.now() < deadline) {
1123
+ if (hasApplicationFailure()) {
1124
+ throw new TileflowCaptureError(
1125
+ "APPLICATION_ERROR",
1126
+ "The application reported an error before its capture target became ready."
1127
+ );
1128
+ }
1129
+ const state = await locator.getAttribute("data-tileflow-state");
1130
+ if (state === "error") {
1131
+ throw new TileflowCaptureError(
1132
+ "APPLICATION_ERROR",
1133
+ "The application capture target entered the error state."
1134
+ );
1135
+ }
1136
+ if (state === "idle" || state === null && allowUnmarkedSelector) {
1137
+ await locator.evaluate(
1138
+ () => new Promise(
1139
+ (resolveReady) => requestAnimationFrame(() => requestAnimationFrame(() => resolveReady()))
1140
+ )
1141
+ );
1142
+ return;
1143
+ }
1144
+ await new Promise((resolveWait) => setTimeout(resolveWait, 20));
1145
+ }
1146
+ throw new TileflowCaptureError(
1147
+ "CAPTURE_TIMEOUT",
1148
+ "The application capture target did not become idle."
1149
+ );
1150
+ }
1151
+ function parseLoopbackHttpUrl(value, requireOriginOnly) {
1152
+ if (value !== value.trim() || value.length > maximumApplicationUrlLength || hasControlCharacter(value)) {
1153
+ throw new TileflowCaptureError(
1154
+ "APPLICATION_ORIGIN_INVALID",
1155
+ "Application capture requires a bounded loopback HTTP(S) URL without surrounding whitespace or control characters."
1156
+ );
1157
+ }
1158
+ let url;
1159
+ try {
1160
+ url = new URL(value);
1161
+ } catch (error) {
1162
+ throw new TileflowCaptureError(
1163
+ "APPLICATION_ORIGIN_INVALID",
1164
+ "Application capture requires a valid loopback HTTP(S) URL.",
1165
+ { cause: error }
1166
+ );
1167
+ }
1168
+ if (url.protocol !== "http:" && url.protocol !== "https:" || !isLoopbackHostname(url.hostname) || url.username !== "" || url.password !== "" || url.hash !== "" || requireOriginOnly && (url.pathname !== "/" || url.search !== "")) {
1169
+ throw new TileflowCaptureError(
1170
+ "APPLICATION_ORIGIN_INVALID",
1171
+ requireOriginOnly ? "Application origin must be an HTTP(S) loopback origin without credentials, path, query, or fragment." : "Application URL must use HTTP(S) loopback without credentials or a fragment."
1172
+ );
1173
+ }
1174
+ return url;
1175
+ }
1176
+ function isApprovedApplicationDocumentUrl(value, origin) {
1177
+ try {
1178
+ return parseLoopbackHttpUrl(value, false).origin === origin;
1179
+ } catch {
1180
+ return false;
1181
+ }
1182
+ }
1183
+ function hasControlCharacter(value) {
1184
+ for (let index = 0; index < value.length; index += 1) {
1185
+ const code = value.charCodeAt(index);
1186
+ if (code <= 31 || code === 127) return true;
1187
+ }
1188
+ return false;
1189
+ }
1190
+ function isLoopbackHostname(hostname) {
1191
+ const normalized = hostname.toLowerCase();
1192
+ if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "[::1]") {
1193
+ return true;
1194
+ }
1195
+ const octets = normalized.split(".");
1196
+ return octets.length === 4 && octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) && Number(octets[0]) === 127;
1197
+ }
1198
+ function compareCodeUnits2(left, right) {
1199
+ return left < right ? -1 : left > right ? 1 : 0;
1200
+ }
1201
+
1202
+ // src/receipt.ts
1203
+ import {
1204
+ serializeCanonicalJson,
1205
+ tileflowCaptureIdSchema,
1206
+ tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2,
1207
+ tileflowCaptureSceneNameSchema
1208
+ } from "@tileflow/core";
1209
+ var tileflowCaptureReceiptSchemaVersion = 1;
1210
+ var tileflowCaptureReceiptLimits = Object.freeze({ maximumBytes: 64 * 1024 });
1211
+ function createTileflowCaptureReceipt(input) {
1212
+ return validateTileflowCaptureReceipt({
1213
+ schemaVersion: tileflowCaptureReceiptSchemaVersion,
1214
+ scene: {
1215
+ name: input.scene,
1216
+ map: input.map,
1217
+ target: input.target,
1218
+ sha256: input.sceneSha256
1219
+ },
1220
+ style: { sha256: input.styleSha256 },
1221
+ image: {
1222
+ sha256: input.pngSha256,
1223
+ cssWidth: input.width,
1224
+ cssHeight: input.height,
1225
+ physicalWidth: input.width * input.dpr,
1226
+ physicalHeight: input.height * input.dpr,
1227
+ dpr: input.dpr
1228
+ },
1229
+ renderer: input.renderer,
1230
+ platform: { os: process.platform, architecture: process.arch },
1231
+ source: { tilesetVersion: input.sourceVersion ?? null },
1232
+ networkDependent: input.networkDependent
1233
+ });
1234
+ }
1235
+ function serializeTileflowCaptureReceipt(receipt) {
1236
+ return `${serializeCanonicalJson(validateTileflowCaptureReceipt(receipt))}
1237
+ `;
1238
+ }
1239
+ function parseTileflowCaptureReceipt(source) {
1240
+ const byteLength = typeof source === "string" ? new TextEncoder().encode(source).byteLength : source.byteLength;
1241
+ if (byteLength > tileflowCaptureReceiptLimits.maximumBytes) {
1242
+ throw invalidReceipt("The baseline receipt exceeds the supported byte limit.");
1243
+ }
1244
+ let value;
1245
+ let text;
1246
+ try {
1247
+ text = typeof source === "string" ? source : new TextDecoder("utf-8", { fatal: true }).decode(source);
1248
+ value = JSON.parse(text);
1249
+ } catch (error) {
1250
+ throw invalidReceipt("The baseline receipt is not valid UTF-8 JSON.", error);
1251
+ }
1252
+ const receipt = validateTileflowCaptureReceipt(value);
1253
+ if (text.trim() !== serializeCanonicalJson(receipt)) {
1254
+ throw invalidReceipt("The baseline receipt is not canonical JSON.");
1255
+ }
1256
+ return receipt;
1257
+ }
1258
+ function validateTileflowCaptureReceipt(value) {
1259
+ const receipt = requireRecord(value, "receipt");
1260
+ const commonKeys = [
1261
+ "schemaVersion",
1262
+ "scene",
1263
+ "style",
1264
+ "image",
1265
+ "renderer",
1266
+ "platform",
1267
+ "source",
1268
+ "networkDependent"
1269
+ ];
1270
+ if (receipt.schemaVersion !== tileflowCaptureReceiptSchemaVersion) {
1271
+ throw invalidReceipt("The baseline receipt schema version is unsupported.");
1272
+ }
1273
+ requireExactKeys(receipt, commonKeys);
1274
+ const scene = requireRecord(receipt.scene, "scene");
1275
+ requireExactKeys(scene, ["name", "map", "target", "sha256"]);
1276
+ const name = requireSceneName(scene.name);
1277
+ const map = requireIdentifier(scene.map, "scene.map");
1278
+ if (scene.target !== "map" && scene.target !== "application") {
1279
+ throw invalidReceipt("The baseline receipt has an invalid scene target.");
1280
+ }
1281
+ const style = requireRecord(receipt.style, "style");
1282
+ requireExactKeys(style, ["sha256"]);
1283
+ const image = requireRecord(receipt.image, "image");
1284
+ requireExactKeys(image, [
1285
+ "sha256",
1286
+ "cssWidth",
1287
+ "cssHeight",
1288
+ "physicalWidth",
1289
+ "physicalHeight",
1290
+ "dpr"
1291
+ ]);
1292
+ const cssWidth = requirePositiveFinite(image.cssWidth, "image.cssWidth");
1293
+ const cssHeight = requirePositiveFinite(image.cssHeight, "image.cssHeight");
1294
+ const physicalWidth = requirePositiveInteger(image.physicalWidth, "image.physicalWidth");
1295
+ const physicalHeight = requirePositiveInteger(image.physicalHeight, "image.physicalHeight");
1296
+ if (image.dpr !== 1 && image.dpr !== 2) {
1297
+ throw invalidReceipt("The baseline receipt has an invalid image DPR.");
1298
+ }
1299
+ if (cssWidth * image.dpr !== physicalWidth || cssHeight * image.dpr !== physicalHeight) {
1300
+ throw invalidReceipt("The baseline receipt image dimensions are inconsistent.");
1301
+ }
1302
+ if (physicalWidth > tileflowCaptureSceneLimits2.viewport.maximum * 2 || physicalHeight > tileflowCaptureSceneLimits2.viewport.maximum * 2 || physicalWidth * physicalHeight > tileflowCaptureSceneLimits2.maximumPhysicalPixels) {
1303
+ throw invalidReceipt("The baseline receipt image exceeds the supported pixel limit.");
1304
+ }
1305
+ const renderer = requireRecord(receipt.renderer, "renderer");
1306
+ requireExactKeys(renderer, [
1307
+ "tileflow",
1308
+ "maplibre",
1309
+ "playwright",
1310
+ "chromiumRevision",
1311
+ "chromiumVersion"
1312
+ ]);
1313
+ const platform = requireRecord(receipt.platform, "platform");
1314
+ requireExactKeys(platform, ["os", "architecture"]);
1315
+ if (typeof receipt.networkDependent !== "boolean") {
1316
+ throw invalidReceipt("The baseline receipt has an invalid network dependency flag.");
1317
+ }
1318
+ const source = requireRecord(receipt.source, "source");
1319
+ requireExactKeys(source, ["tilesetVersion"]);
1320
+ return {
1321
+ schemaVersion: tileflowCaptureReceiptSchemaVersion,
1322
+ scene: {
1323
+ name,
1324
+ map,
1325
+ target: scene.target,
1326
+ sha256: requireHash(scene.sha256, "scene.sha256")
1327
+ },
1328
+ style: { sha256: requireHash(style.sha256, "style.sha256") },
1329
+ image: {
1330
+ sha256: requireHash(image.sha256, "image.sha256"),
1331
+ cssWidth,
1332
+ cssHeight,
1333
+ physicalWidth,
1334
+ physicalHeight,
1335
+ dpr: image.dpr
1336
+ },
1337
+ renderer: {
1338
+ tileflow: requireBoundedString(renderer.tileflow, "renderer.tileflow"),
1339
+ maplibre: requireBoundedString(renderer.maplibre, "renderer.maplibre"),
1340
+ playwright: requireBoundedString(renderer.playwright, "renderer.playwright"),
1341
+ chromiumRevision: requireBoundedString(
1342
+ renderer.chromiumRevision,
1343
+ "renderer.chromiumRevision"
1344
+ ),
1345
+ chromiumVersion: requireBoundedString(renderer.chromiumVersion, "renderer.chromiumVersion")
1346
+ },
1347
+ platform: {
1348
+ os: requireBoundedString(platform.os, "platform.os"),
1349
+ architecture: requireBoundedString(platform.architecture, "platform.architecture")
1350
+ },
1351
+ source: { tilesetVersion: requireNullableSourceVersion(source.tilesetVersion) },
1352
+ networkDependent: receipt.networkDependent
1353
+ };
1354
+ }
1355
+ function requireRecord(value, field) {
1356
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1357
+ throw invalidReceipt(`The baseline receipt has an invalid ${field} object.`);
1358
+ }
1359
+ return value;
1360
+ }
1361
+ function requireExactKeys(value, expected) {
1362
+ const actual = Object.keys(value).sort();
1363
+ const wanted = [...expected].sort();
1364
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
1365
+ throw invalidReceipt("The baseline receipt contains missing or unsupported fields.");
1366
+ }
1367
+ }
1368
+ function requireIdentifier(value, field) {
1369
+ const result = tileflowCaptureIdSchema.safeParse(value);
1370
+ if (!result.success) throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1371
+ return result.data;
1372
+ }
1373
+ function requireSceneName(value) {
1374
+ const result = tileflowCaptureSceneNameSchema.safeParse(value);
1375
+ if (!result.success) throw invalidReceipt("The baseline receipt has an invalid scene.name.");
1376
+ return result.data;
1377
+ }
1378
+ function requireHash(value, field) {
1379
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
1380
+ throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1381
+ }
1382
+ return value;
1383
+ }
1384
+ function requirePositiveFinite(value, field) {
1385
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
1386
+ throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1387
+ }
1388
+ return value;
1389
+ }
1390
+ function requirePositiveInteger(value, field) {
1391
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
1392
+ throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1393
+ }
1394
+ return value;
1395
+ }
1396
+ function requireBoundedString(value, field) {
1397
+ if (typeof value !== "string" || value.length < 1 || value.length > 64 || !isSafeRuntimeIdentity(value)) {
1398
+ throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1399
+ }
1400
+ return value;
1401
+ }
1402
+ function requireNullableSourceVersion(value) {
1403
+ if (value === null) return null;
1404
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
1405
+ throw invalidReceipt("The baseline receipt has an invalid source.tilesetVersion.");
1406
+ }
1407
+ return value;
1408
+ }
1409
+ function isSafeRuntimeIdentity(value) {
1410
+ return /^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(value);
1411
+ }
1412
+ function invalidReceipt(message, cause) {
1413
+ return new TileflowCaptureError("BASELINE_INVALID", message, { cause });
1414
+ }
1415
+
1416
+ // src/capture.ts
1417
+ var tileflowCaptureResultSchemaVersion = 1;
1418
+ async function captureTileflowScenes(options) {
1419
+ const session = createTileflowCaptureSession(options);
1420
+ try {
1421
+ return await session.capture(options.scenes, options.signal);
1422
+ } finally {
1423
+ await session.close();
1424
+ }
1425
+ }
1426
+ function createTileflowCaptureSession(options = {}) {
1427
+ return new TileflowCaptureSessionImpl(options);
1428
+ }
1429
+ var TileflowCaptureSessionImpl = class {
1430
+ #options;
1431
+ #browserManager;
1432
+ #closed = false;
1433
+ constructor(options = {}) {
1434
+ this.#options = options;
1435
+ this.#browserManager = new TileflowCaptureBrowserManager({
1436
+ allowInstall: options.allowBrowserInstall,
1437
+ onInstallProgress: options.onBrowserInstallProgress,
1438
+ signal: options.signal
1439
+ });
1440
+ }
1441
+ async capture(scenes, signal) {
1442
+ this.#assertOpen();
1443
+ const artifacts = await this.#createArtifacts();
1444
+ return this.captureArtifacts(artifacts, scenes, signal ?? this.#options.signal);
1445
+ }
1446
+ async captureAll(signal) {
1447
+ this.#assertOpen();
1448
+ const artifacts = await this.#createArtifacts();
1449
+ return this.captureArtifacts(
1450
+ artifacts,
1451
+ Object.keys(artifacts.project.scenes ?? {}),
1452
+ signal ?? this.#options.signal
1453
+ );
1454
+ }
1455
+ async captureDefinitions(scenes, signal) {
1456
+ this.#assertOpen();
1457
+ const artifacts = await this.#createArtifacts();
1458
+ const withDefinitions = {
1459
+ ...artifacts,
1460
+ project: { ...artifacts.project, scenes }
1461
+ };
1462
+ return this.captureArtifacts(
1463
+ withDefinitions,
1464
+ Object.keys(scenes),
1465
+ signal ?? this.#options.signal
1466
+ );
1467
+ }
1468
+ async captureArtifacts(artifacts, requestedScenes, signal) {
1469
+ this.#assertOpen();
1470
+ const sceneNames = selectTileflowCaptureSceneNames(artifacts, requestedScenes);
1471
+ const validatedMaps = /* @__PURE__ */ new Set();
1472
+ for (const sceneName of sceneNames) {
1473
+ const scene = this.#resolveScene(artifacts, sceneName);
1474
+ if (scene.target.kind === "application" && !this.#options.appOrigin && !this.#options.appUrl) {
1475
+ throw new TileflowCaptureError(
1476
+ "APPLICATION_ORIGIN_REQUIRED",
1477
+ `Scene "${sceneName}" requires --app-origin or TILEFLOW_APP_ORIGIN.`
1478
+ );
1479
+ }
1480
+ const style = Object.hasOwn(artifacts.styles, scene.map) ? artifacts.styles[scene.map] : void 0;
1481
+ if (!style) {
1482
+ throw new TileflowCaptureError(
1483
+ "SCENE_NOT_FOUND",
1484
+ `Scene "${sceneName}" references unavailable map "${scene.map}".`
1485
+ );
1486
+ }
1487
+ if (!validatedMaps.has(scene.map)) {
1488
+ try {
1489
+ assertValidTileflowStyle2(style, scene.map);
1490
+ } catch (error) {
1491
+ throwCaptureStyleValidationError(error);
1492
+ }
1493
+ validatedMaps.add(scene.map);
1494
+ }
1495
+ }
1496
+ throwIfAborted2(signal);
1497
+ const browser = await this.#browserManager.getBrowser(signal);
1498
+ const renderer = createTileflowCaptureRendererIdentity(browser);
1499
+ const captures = [];
1500
+ for (const sceneName of sceneNames) {
1501
+ throwIfAborted2(signal);
1502
+ const scene = this.#resolveScene(artifacts, sceneName);
1503
+ const style = Object.hasOwn(artifacts.styles, scene.map) ? artifacts.styles[scene.map] : void 0;
1504
+ if (!style) {
1505
+ throw new TileflowCaptureError(
1506
+ "SCENE_NOT_FOUND",
1507
+ `Scene "${sceneName}" references unavailable map "${scene.map}".`
1508
+ );
1509
+ }
1510
+ const rendered = scene.target.kind === "application" ? await captureApplicationTileflowScene({
1511
+ appOrigin: this.#options.appOrigin,
1512
+ appUrl: this.#options.appUrl,
1513
+ browser,
1514
+ scene: { ...scene, target: scene.target },
1515
+ signal,
1516
+ timeoutMs: this.#options.timeoutMs
1517
+ }) : await captureStandaloneTileflowScene({
1518
+ assets: artifacts.assets,
1519
+ browser,
1520
+ scene,
1521
+ signal,
1522
+ style,
1523
+ timeoutMs: this.#options.timeoutMs
1524
+ });
1525
+ const [sha256, sceneSha256, styleSha256] = await Promise.all([
1526
+ sha256Hex(rendered.png),
1527
+ sha256Hex(serializeCanonicalJson2(scene)),
1528
+ sha256Hex(serializeCanonicalJson2(style))
1529
+ ]);
1530
+ const target = scene.target.kind;
1531
+ const receipt = createTileflowCaptureReceipt({
1532
+ dpr: scene.viewport.dpr,
1533
+ height: rendered.height,
1534
+ map: scene.map,
1535
+ networkDependent: rendered.networkDependent,
1536
+ pngSha256: sha256,
1537
+ renderer,
1538
+ scene: sceneName,
1539
+ sceneSha256,
1540
+ styleSha256,
1541
+ ...typeof style.metadata?.["tileflow:tilesetVersion"] === "string" ? { sourceVersion: style.metadata["tileflow:tilesetVersion"] } : {},
1542
+ target,
1543
+ width: rendered.width
1544
+ });
1545
+ captures.push({
1546
+ scene: sceneName,
1547
+ map: scene.map,
1548
+ target,
1549
+ png: rendered.png,
1550
+ sha256,
1551
+ sceneSha256,
1552
+ styleSha256,
1553
+ width: rendered.width,
1554
+ height: rendered.height,
1555
+ dpr: scene.viewport.dpr,
1556
+ networkDependent: rendered.networkDependent,
1557
+ renderer,
1558
+ receipt,
1559
+ warnings: rendered.warnings.sort(compareCodeUnits3)
1560
+ });
1561
+ }
1562
+ return { schemaVersion: tileflowCaptureResultSchemaVersion, captures };
1563
+ }
1564
+ async close() {
1565
+ if (this.#closed) {
1566
+ return;
1567
+ }
1568
+ this.#closed = true;
1569
+ await this.#browserManager.close();
1570
+ }
1571
+ #createArtifacts() {
1572
+ const cwd = resolve(this.#options.cwd ?? process.cwd());
1573
+ return createTileflowBuildArtifacts({
1574
+ assetBaseUrl: tileflowSyntheticAssetOrigin,
1575
+ config: this.#options.config ?? defaultTileflowConfigPath,
1576
+ cwd
1577
+ }).catch((error) => throwCaptureStyleValidationError(error));
1578
+ }
1579
+ #assertOpen() {
1580
+ if (this.#closed) {
1581
+ throw new TileflowCaptureError("BROWSER_START_FAILED", "Tileflow capture session is closed.");
1582
+ }
1583
+ }
1584
+ #resolveScene(artifacts, sceneName) {
1585
+ const scene = normalizeScene(artifacts, sceneName);
1586
+ const hasApplicationOverrides = this.#options.appUrl !== void 0 || this.#options.selector !== void 0 || this.#options.frame !== void 0;
1587
+ if (!hasApplicationOverrides) return scene;
1588
+ if (scene.target.kind !== "application" && !this.#options.appUrl) {
1589
+ throw new TileflowCaptureError(
1590
+ "APPLICATION_ORIGIN_REQUIRED",
1591
+ "Application selector/frame overrides require an application scene or --url."
1592
+ );
1593
+ }
1594
+ const existing = scene.target.kind === "application" ? scene.target : void 0;
1595
+ let path = existing?.path ?? "/";
1596
+ if (this.#options.appUrl) {
1597
+ const resolved = resolveTileflowApplicationUrl({ appUrl: this.#options.appUrl, path });
1598
+ const url = new URL(resolved.url);
1599
+ path = `${url.pathname}${url.search}`;
1600
+ }
1601
+ const selector = this.#options.selector ?? existing?.selector;
1602
+ const captureId = this.#options.selector ? void 0 : existing?.captureId;
1603
+ return {
1604
+ ...scene,
1605
+ target: {
1606
+ kind: "application",
1607
+ path,
1608
+ ...captureId ? { captureId } : {},
1609
+ ...selector ? { selector } : {},
1610
+ frame: this.#options.frame ?? existing?.frame ?? "map"
1611
+ }
1612
+ };
1613
+ }
1614
+ };
1615
+ function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
1616
+ const availableScenes = Object.keys(artifacts.project.scenes ?? {}).filter((sceneName) => tileflowCaptureSceneNameSchema2.safeParse(sceneName).success).sort(compareCodeUnits3);
1617
+ const sceneNames = [...new Set(requestedScenes)].sort(compareCodeUnits3);
1618
+ if (sceneNames.length === 0) {
1619
+ throw new TileflowCaptureError(
1620
+ "SCENE_NOT_FOUND",
1621
+ "Select at least one capture scene or request every configured scene explicitly."
1622
+ );
1623
+ }
1624
+ for (const sceneName of sceneNames) {
1625
+ if (!tileflowCaptureSceneNameSchema2.safeParse(sceneName).success) {
1626
+ throw new TileflowCaptureError(
1627
+ "SCENE_NOT_FOUND",
1628
+ "Expected a portable Tileflow capture scene name."
1629
+ );
1630
+ }
1631
+ if (!Object.hasOwn(artifacts.project.scenes ?? {}, sceneName)) {
1632
+ const suffix = availableScenes.length > 0 ? ` Available scenes: ${availableScenes.join(", ")}.` : " The config defines no capture scenes.";
1633
+ throw new TileflowCaptureError(
1634
+ "SCENE_NOT_FOUND",
1635
+ `Unknown Tileflow capture scene "${sceneName}".${suffix}`
1636
+ );
1637
+ }
1638
+ }
1639
+ return sceneNames;
1640
+ }
1641
+ function normalizeScene(artifacts, sceneName) {
1642
+ const scene = Object.hasOwn(artifacts.project.scenes ?? {}, sceneName) ? artifacts.project.scenes?.[sceneName] : void 0;
1643
+ if (!scene) {
1644
+ throw new TileflowCaptureError("SCENE_NOT_FOUND", `Unknown Tileflow scene "${sceneName}".`);
1645
+ }
1646
+ return normalizeTileflowCaptureScene(scene);
1647
+ }
1648
+ function throwIfAborted2(signal) {
1649
+ if (signal?.aborted) {
1650
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
1651
+ }
1652
+ }
1653
+ function throwCaptureStyleValidationError(error) {
1654
+ if (error instanceof TileflowStyleValidationError2) {
1655
+ throw new TileflowCaptureError("STYLE_INVALID", error.message, {
1656
+ cause: error,
1657
+ details: {
1658
+ diagnostics: error.issues,
1659
+ phase: "style-validation"
1660
+ }
1661
+ });
1662
+ }
1663
+ throw error;
1664
+ }
1665
+
1666
+ // src/visual.ts
1667
+ import pixelmatch from "pixelmatch";
1668
+ import { PNG } from "pngjs";
1669
+ import {
1670
+ compareCodeUnits as compareCodeUnits4,
1671
+ serializeCanonicalJson as serializeCanonicalJson3,
1672
+ sha256Hex as sha256Hex2,
1673
+ tileflowCaptureSceneLimits as tileflowCaptureSceneLimits3
1674
+ } from "@tileflow/core";
1675
+ var tileflowVisualComparisonSchemaVersion = 1;
1676
+ var tileflowVisualAnalysisSchemaVersion = 1;
1677
+ var tileflowVisualPerceptualThreshold = 0.1;
1678
+ var tileflowVisualArtifactLimits = Object.freeze({
1679
+ maximumPngBytes: 256 * 1024 * 1024,
1680
+ maximumPaletteColors: 16
1681
+ });
1682
+ function validateTileflowVisualReferencePng(png) {
1683
+ const image = decodePng(png, "reference");
1684
+ return { width: image.width, height: image.height };
1685
+ }
1686
+ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
1687
+ const receipt = validateTileflowCaptureReceipt(actualCapture.receipt);
1688
+ const actualImage = await validatePngAgainstReceipt(actualCapture.png, receipt, "actual");
1689
+ const referenceImage = decodePng(referencePng, "reference");
1690
+ const dimensionsMatch = referenceImage.width === actualImage.width && referenceImage.height === actualImage.height;
1691
+ const common = {
1692
+ schemaVersion: tileflowVisualAnalysisSchemaVersion,
1693
+ scene: receipt.scene.name,
1694
+ map: receipt.scene.map,
1695
+ target: receipt.scene.target,
1696
+ dimensionsMatch,
1697
+ reference: {
1698
+ sha256: await sha256Hex2(referencePng),
1699
+ physicalWidth: referenceImage.width,
1700
+ physicalHeight: referenceImage.height,
1701
+ palette: createQuantizedPalette(referenceImage.data)
1702
+ },
1703
+ actual: {
1704
+ sha256: receipt.image.sha256,
1705
+ physicalWidth: actualImage.width,
1706
+ physicalHeight: actualImage.height,
1707
+ palette: createQuantizedPalette(actualImage.data)
1708
+ }
1709
+ };
1710
+ if (!dimensionsMatch) {
1711
+ return {
1712
+ ...common,
1713
+ exact: null,
1714
+ perceptual: null,
1715
+ meanAbsoluteChannelDifference: null,
1716
+ warnings: [
1717
+ "The reference and actual PNG dimensions differ; pixel metrics and diff were not computed.",
1718
+ ...networkWarnings(void 0, receipt)
1719
+ ].sort(compareCodeUnits4)
1720
+ };
1721
+ }
1722
+ const totalPixels = actualImage.width * actualImage.height;
1723
+ const exactChangedPixels = countExactChangedPixels(referenceImage.data, actualImage.data);
1724
+ const diffData = new Uint8Array(totalPixels * 4);
1725
+ const perceptualChangedPixels = pixelmatch(
1726
+ referenceImage.data,
1727
+ actualImage.data,
1728
+ diffData,
1729
+ actualImage.width,
1730
+ actualImage.height,
1731
+ {
1732
+ checkerboard: true,
1733
+ diffColor: [255, 0, 255],
1734
+ diffColorAlt: [0, 255, 255],
1735
+ diffMask: true,
1736
+ includeAA: true,
1737
+ threshold: tileflowVisualPerceptualThreshold
1738
+ }
1739
+ );
1740
+ return {
1741
+ ...common,
1742
+ exact: pixelMetric(exactChangedPixels, totalPixels),
1743
+ perceptual: {
1744
+ threshold: tileflowVisualPerceptualThreshold,
1745
+ ...pixelMetric(perceptualChangedPixels, totalPixels)
1746
+ },
1747
+ meanAbsoluteChannelDifference: meanAbsoluteChannelDifference(
1748
+ referenceImage.data,
1749
+ actualImage.data
1750
+ ),
1751
+ diffPng: encodeDiffPng(diffData, actualImage.width, actualImage.height),
1752
+ warnings: networkWarnings(void 0, receipt)
1753
+ };
1754
+ }
1755
+ function createTileflowVisualReferenceAnalysisDocument(analysis) {
1756
+ const { diffPng: _diffPng, ...document } = analysis;
1757
+ return document;
1758
+ }
1759
+ async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
1760
+ const actualReceipt = validateTileflowCaptureReceipt(actualCapture.receipt);
1761
+ const actual = imageIdentity(actualReceipt);
1762
+ const common = {
1763
+ schemaVersion: tileflowVisualComparisonSchemaVersion,
1764
+ scene: actualReceipt.scene.name,
1765
+ map: actualReceipt.scene.map,
1766
+ target: actualReceipt.scene.target,
1767
+ actual,
1768
+ actualScene: actualReceipt.scene,
1769
+ actualRuntime: runtimeIdentity(actualReceipt)
1770
+ };
1771
+ const actualImage = await validatePngAgainstReceipt(actualCapture.png, actualReceipt, "actual");
1772
+ if (!baseline) {
1773
+ return {
1774
+ ...common,
1775
+ status: "missing-baseline",
1776
+ changeKind: null,
1777
+ baselineScene: null,
1778
+ baselineRuntime: null,
1779
+ baseline: null,
1780
+ dimensionsMatch: null,
1781
+ exact: null,
1782
+ perceptual: null,
1783
+ rendererMatch: null,
1784
+ sceneMatch: null,
1785
+ warnings: networkWarnings(void 0, actualReceipt)
1786
+ };
1787
+ }
1788
+ const baselineReceipt = typeof baseline.receipt === "string" || baseline.receipt instanceof Uint8Array ? parseTileflowCaptureReceipt(baseline.receipt) : validateTileflowCaptureReceipt(baseline.receipt);
1789
+ const baselineImage = await validatePngAgainstReceipt(baseline.png, baselineReceipt, "baseline");
1790
+ const baselineIdentity = imageIdentity(baselineReceipt);
1791
+ const baselineScene = baselineReceipt.scene;
1792
+ const baselineRuntime = runtimeIdentity(baselineReceipt);
1793
+ const warnings = networkWarnings(baselineReceipt, actualReceipt);
1794
+ const sceneMatch = sameSceneIdentity(baselineReceipt, actualReceipt);
1795
+ if (!sceneMatch) {
1796
+ return {
1797
+ ...common,
1798
+ status: "scene-mismatch",
1799
+ changeKind: null,
1800
+ baselineScene,
1801
+ baselineRuntime,
1802
+ baseline: baselineIdentity,
1803
+ dimensionsMatch: null,
1804
+ exact: null,
1805
+ perceptual: null,
1806
+ rendererMatch: null,
1807
+ sceneMatch: false,
1808
+ warnings
1809
+ };
1810
+ }
1811
+ const rendererMatch = sameRuntimeIdentity(baselineReceipt, actualReceipt);
1812
+ if (!rendererMatch) {
1813
+ return {
1814
+ ...common,
1815
+ status: "runtime-mismatch",
1816
+ changeKind: null,
1817
+ baselineScene,
1818
+ baselineRuntime,
1819
+ baseline: baselineIdentity,
1820
+ dimensionsMatch: null,
1821
+ exact: null,
1822
+ perceptual: null,
1823
+ rendererMatch: false,
1824
+ sceneMatch: true,
1825
+ warnings
1826
+ };
1827
+ }
1828
+ const dimensionsMatch = baselineReceipt.image.physicalWidth === actualReceipt.image.physicalWidth && baselineReceipt.image.physicalHeight === actualReceipt.image.physicalHeight;
1829
+ if (!dimensionsMatch) {
1830
+ const diffWidth = Math.max(
1831
+ baselineReceipt.image.physicalWidth,
1832
+ actualReceipt.image.physicalWidth
1833
+ );
1834
+ const diffHeight = Math.max(
1835
+ baselineReceipt.image.physicalHeight,
1836
+ actualReceipt.image.physicalHeight
1837
+ );
1838
+ const boundedWidth = diffWidth * diffHeight <= tileflowCaptureSceneLimits3.maximumPhysicalPixels ? diffWidth : actualReceipt.image.physicalWidth;
1839
+ const boundedHeight = diffWidth * diffHeight <= tileflowCaptureSceneLimits3.maximumPhysicalPixels ? diffHeight : actualReceipt.image.physicalHeight;
1840
+ return {
1841
+ ...common,
1842
+ status: "changed",
1843
+ changeKind: "dimensions",
1844
+ baselineScene,
1845
+ baselineRuntime,
1846
+ baseline: baselineIdentity,
1847
+ dimensionsMatch: false,
1848
+ exact: null,
1849
+ perceptual: null,
1850
+ rendererMatch: true,
1851
+ sceneMatch: true,
1852
+ diffPng: encodeSolidDiffPng(boundedWidth, boundedHeight),
1853
+ warnings: [
1854
+ ...warnings,
1855
+ "The PNG dimensions differ; exact and perceptual pixel metrics were not computed."
1856
+ ].sort(compareCodeUnits4)
1857
+ };
1858
+ }
1859
+ const width = actualImage.width;
1860
+ const height = actualImage.height;
1861
+ const totalPixels = width * height;
1862
+ const exactChangedPixels = countExactChangedPixels(baselineImage.data, actualImage.data);
1863
+ const diffData = new Uint8Array(totalPixels * 4);
1864
+ const perceptualChangedPixels = pixelmatch(
1865
+ baselineImage.data,
1866
+ actualImage.data,
1867
+ diffData,
1868
+ width,
1869
+ height,
1870
+ {
1871
+ checkerboard: true,
1872
+ diffColor: [255, 0, 255],
1873
+ diffColorAlt: [0, 255, 255],
1874
+ diffMask: true,
1875
+ includeAA: true,
1876
+ threshold: tileflowVisualPerceptualThreshold
1877
+ }
1878
+ );
1879
+ const status = exactChangedPixels === 0 ? "unchanged" : "changed";
1880
+ return {
1881
+ ...common,
1882
+ status,
1883
+ changeKind: status === "changed" ? "pixels" : null,
1884
+ baselineScene,
1885
+ baselineRuntime,
1886
+ baseline: baselineIdentity,
1887
+ dimensionsMatch: true,
1888
+ exact: pixelMetric(exactChangedPixels, totalPixels),
1889
+ perceptual: {
1890
+ threshold: tileflowVisualPerceptualThreshold,
1891
+ ...pixelMetric(perceptualChangedPixels, totalPixels)
1892
+ },
1893
+ rendererMatch: true,
1894
+ sceneMatch: true,
1895
+ diffPng: encodeDiffPng(diffData, width, height),
1896
+ warnings
1897
+ };
1898
+ }
1899
+ function createTileflowVisualComparisonDocument(comparison) {
1900
+ const { diffPng: _diffPng, ...document } = comparison;
1901
+ return document;
1902
+ }
1903
+ function serializeTileflowVisualComparison(comparison) {
1904
+ return `${serializeCanonicalJson3(createTileflowVisualComparisonDocument(comparison))}
1905
+ `;
1906
+ }
1907
+ function imageIdentity(receipt) {
1908
+ return {
1909
+ sha256: receipt.image.sha256,
1910
+ cssWidth: receipt.image.cssWidth,
1911
+ cssHeight: receipt.image.cssHeight,
1912
+ physicalWidth: receipt.image.physicalWidth,
1913
+ physicalHeight: receipt.image.physicalHeight,
1914
+ dpr: receipt.image.dpr,
1915
+ networkDependent: receipt.networkDependent
1916
+ };
1917
+ }
1918
+ function runtimeIdentity(receipt) {
1919
+ return { renderer: receipt.renderer, platform: receipt.platform };
1920
+ }
1921
+ async function validatePngAgainstReceipt(png, receipt, role) {
1922
+ const image = decodePng(png, role);
1923
+ if (image.width !== receipt.image.physicalWidth || image.height !== receipt.image.physicalHeight) {
1924
+ throw invalidBaseline(`The ${role} PNG dimensions do not match its receipt.`);
1925
+ }
1926
+ if (await sha256Hex2(png) !== receipt.image.sha256) {
1927
+ throw invalidBaseline(`The ${role} PNG hash does not match its receipt.`);
1928
+ }
1929
+ return image;
1930
+ }
1931
+ function preflightPng(png, role) {
1932
+ if (png.byteLength > tileflowVisualArtifactLimits.maximumPngBytes) {
1933
+ throw invalidBaseline(`The ${role} PNG exceeds the visual artifact byte limit.`);
1934
+ }
1935
+ let dimensions;
1936
+ try {
1937
+ dimensions = readPngDimensions(png);
1938
+ } catch (error) {
1939
+ throw invalidBaseline(`The ${role} image is not a valid PNG.`, error);
1940
+ }
1941
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
1942
+ if (png.byteLength < 29 || view.getUint32(8) !== 13 || png[12] !== 73 || png[13] !== 72 || png[14] !== 68 || png[15] !== 82) {
1943
+ throw invalidBaseline(`The ${role} image does not begin with a canonical PNG header.`);
1944
+ }
1945
+ if (png[28] !== 0) {
1946
+ throw invalidBaseline(
1947
+ `The ${role} PNG is interlaced and cannot be decoded within fixed bounds.`
1948
+ );
1949
+ }
1950
+ if (dimensions.width > tileflowCaptureSceneLimits3.viewport.maximum * 2 || dimensions.height > tileflowCaptureSceneLimits3.viewport.maximum * 2 || dimensions.width * dimensions.height > tileflowCaptureSceneLimits3.maximumPhysicalPixels) {
1951
+ throw invalidBaseline(`The ${role} PNG exceeds the visual pixel limit.`);
1952
+ }
1953
+ return dimensions;
1954
+ }
1955
+ function decodePng(png, role) {
1956
+ preflightPng(png, role);
1957
+ try {
1958
+ const decoded = PNG.sync.read(Buffer.from(png), { checkCRC: true, skipRescale: false });
1959
+ if (decoded.data.byteLength !== decoded.width * decoded.height * 4) {
1960
+ throw new Error("Decoded PNG did not produce bounded RGBA pixels.");
1961
+ }
1962
+ return decoded;
1963
+ } catch (error) {
1964
+ throw invalidBaseline(`The ${role} PNG could not be decoded safely.`, error);
1965
+ }
1966
+ }
1967
+ function encodeDiffPng(data, width, height) {
1968
+ const image = new PNG({ height, width });
1969
+ image.data = Buffer.from(data);
1970
+ return new Uint8Array(
1971
+ PNG.sync.write(image, {
1972
+ bitDepth: 8,
1973
+ colorType: 6,
1974
+ deflateLevel: 9,
1975
+ deflateStrategy: 3,
1976
+ filterType: 4,
1977
+ inputColorType: 6,
1978
+ inputHasAlpha: true
1979
+ })
1980
+ );
1981
+ }
1982
+ function encodeSolidDiffPng(width, height) {
1983
+ const data = new Uint8Array(width * height * 4);
1984
+ for (let offset = 0; offset < data.byteLength; offset += 4) {
1985
+ data.set([255, 0, 255, 255], offset);
1986
+ }
1987
+ return encodeDiffPng(data, width, height);
1988
+ }
1989
+ function countExactChangedPixels(left, right) {
1990
+ if (left.byteLength !== right.byteLength || left.byteLength % 4 !== 0) {
1991
+ throw invalidBaseline("Comparable PNGs did not decode to equal RGBA dimensions.");
1992
+ }
1993
+ let changed = 0;
1994
+ for (let index = 0; index < left.byteLength; index += 4) {
1995
+ if (left[index] !== right[index] || left[index + 1] !== right[index + 1] || left[index + 2] !== right[index + 2] || left[index + 3] !== right[index + 3]) {
1996
+ changed += 1;
1997
+ }
1998
+ }
1999
+ return changed;
2000
+ }
2001
+ function createQuantizedPalette(data) {
2002
+ if (data.byteLength === 0 || data.byteLength % 4 !== 0) {
2003
+ throw invalidBaseline("The decoded PNG did not contain bounded RGBA pixels.");
2004
+ }
2005
+ const counts = /* @__PURE__ */ new Map();
2006
+ for (let offset = 0; offset < data.byteLength; offset += 4) {
2007
+ const alpha = data[offset + 3];
2008
+ const red = compositeOnWhite(data[offset], alpha);
2009
+ const green = compositeOnWhite(data[offset + 1], alpha);
2010
+ const blue = compositeOnWhite(data[offset + 2], alpha);
2011
+ const color = `#${quantizeChannel(red)}${quantizeChannel(green)}${quantizeChannel(blue)}`;
2012
+ counts.set(color, (counts.get(color) ?? 0) + 1);
2013
+ }
2014
+ const totalPixels = data.byteLength / 4;
2015
+ return [...counts].sort((left, right) => right[1] - left[1] || compareCodeUnits4(left[0], right[0])).slice(0, tileflowVisualArtifactLimits.maximumPaletteColors).map(([color, count]) => ({ color, count, ratio: count / totalPixels }));
2016
+ }
2017
+ function compositeOnWhite(channel, alpha) {
2018
+ return Math.round((channel * alpha + 255 * (255 - alpha)) / 255);
2019
+ }
2020
+ function quantizeChannel(channel) {
2021
+ return (Math.round(channel / 17) * 17).toString(16).padStart(2, "0");
2022
+ }
2023
+ function meanAbsoluteChannelDifference(left, right) {
2024
+ if (left.byteLength !== right.byteLength || left.byteLength === 0) {
2025
+ throw invalidBaseline("Comparable PNGs did not decode to equal RGBA dimensions.");
2026
+ }
2027
+ let sum = 0;
2028
+ for (let index = 0; index < left.byteLength; index += 1) {
2029
+ sum += Math.abs(left[index] - right[index]);
2030
+ }
2031
+ return Math.round(sum / left.byteLength * 1e6) / 1e6;
2032
+ }
2033
+ function pixelMetric(changedPixels, totalPixels) {
2034
+ return { changedPixels, totalPixels, ratio: changedPixels / totalPixels };
2035
+ }
2036
+ function sameSceneIdentity(baseline, actual) {
2037
+ return baseline.scene.name === actual.scene.name && baseline.scene.map === actual.scene.map && baseline.scene.target === actual.scene.target && baseline.scene.sha256 === actual.scene.sha256 && baseline.source.tilesetVersion === actual.source.tilesetVersion;
2038
+ }
2039
+ function sameRuntimeIdentity(baseline, actual) {
2040
+ return baseline.renderer.tileflow === actual.renderer.tileflow && baseline.renderer.maplibre === actual.renderer.maplibre && baseline.renderer.playwright === actual.renderer.playwright && baseline.renderer.chromiumRevision === actual.renderer.chromiumRevision && baseline.renderer.chromiumVersion === actual.renderer.chromiumVersion && baseline.platform.os === actual.platform.os && baseline.platform.architecture === actual.platform.architecture;
2041
+ }
2042
+ function networkWarnings(baseline, actual) {
2043
+ const warnings = [];
2044
+ if (baseline?.networkDependent) {
2045
+ warnings.push("The baseline used remote resources and may not be globally byte-stable.");
2046
+ }
2047
+ if (actual.networkDependent) {
2048
+ warnings.push("The actual capture used remote resources and may not be globally byte-stable.");
2049
+ }
2050
+ return warnings.sort(compareCodeUnits4);
2051
+ }
2052
+ function invalidBaseline(message, cause) {
2053
+ return new TileflowCaptureError("BASELINE_INVALID", message, { cause });
2054
+ }
2055
+ export {
2056
+ TileflowCaptureBrowserManager,
2057
+ TileflowCaptureError,
2058
+ analyzeTileflowCaptureReference,
2059
+ captureErrorMessage,
2060
+ captureTileflowScenes,
2061
+ compareTileflowCaptureToBaseline,
2062
+ createTileflowCaptureBrowserEnvironment,
2063
+ createTileflowCaptureReceipt,
2064
+ createTileflowCaptureRendererIdentity,
2065
+ createTileflowCaptureSession,
2066
+ createTileflowVisualComparisonDocument,
2067
+ createTileflowVisualReferenceAnalysisDocument,
2068
+ launchTileflowCaptureBrowser,
2069
+ parseTileflowCaptureReceipt,
2070
+ resolveTileflowApplicationUrl,
2071
+ serializeTileflowCaptureReceipt,
2072
+ serializeTileflowVisualComparison,
2073
+ setupTileflowCaptureBrowser,
2074
+ tileflowCaptureReceiptLimits,
2075
+ tileflowCaptureReceiptSchemaVersion,
2076
+ tileflowCaptureResultSchemaVersion,
2077
+ tileflowCaptureRuntime,
2078
+ tileflowSyntheticAssetOrigin,
2079
+ tileflowVisualArtifactLimits,
2080
+ tileflowVisualComparisonSchemaVersion,
2081
+ tileflowVisualPerceptualThreshold,
2082
+ validateTileflowCaptureReceipt,
2083
+ validateTileflowVisualReferencePng
2084
+ };