@tileflow/capture 0.1.0-alpha.17 → 0.1.0-alpha.18

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 CHANGED
@@ -1,68 +1,18 @@
1
+ import {
2
+ TileflowCaptureError,
3
+ captureErrorMessage,
4
+ createTileflowCaptureReceipt,
5
+ parseTileflowCaptureReceipt,
6
+ serializeTileflowCaptureReceipt,
7
+ tileflowCaptureReceiptLimits,
8
+ tileflowCaptureReceiptSchemaVersion,
9
+ validateTileflowCaptureReceipt
10
+ } from "./chunk-BSTB4FN6.js";
11
+
1
12
  // src/browser.ts
2
13
  import { spawn } from "child_process";
3
14
  import { chromium } from "playwright";
4
15
 
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
16
  // src/metadata.ts
67
17
  import { readFileSync } from "fs";
68
18
  import { createRequire } from "module";
@@ -346,30 +296,37 @@ import { resolve } from "path";
346
296
  import {
347
297
  compareCodeUnits as compareCodeUnits3,
348
298
  normalizeTileflowCaptureScene,
349
- serializeCanonicalJson as serializeCanonicalJson2,
299
+ parseTileflowMap,
300
+ resolveThemeSelection,
301
+ serializeCanonicalJson,
350
302
  sha256Hex,
351
- tileflowCaptureSceneNameSchema as tileflowCaptureSceneNameSchema2
303
+ tileflowCaptureSceneNameSchema
352
304
  } from "@tileflow/core";
353
305
  import {
354
- assertValidTileflowStyle as assertValidTileflowStyle2,
355
306
  createTileflowBuildArtifacts,
356
- defaultTileflowConfigPath,
357
- TileflowStyleValidationError as TileflowStyleValidationError2
358
- } from "@tileflow/dev";
307
+ defaultTileflowConfigPath
308
+ } from "@tileflow/dev/artifacts";
309
+ import { assertValidTileflowStyle as assertValidTileflowStyle2, TileflowStyleValidationError as TileflowStyleValidationError2 } from "@tileflow/dev/validation";
359
310
 
360
311
  // src/application.ts
361
312
  import { tileflowCaptureSceneLimits } from "@tileflow/core";
362
313
 
363
314
  // src/standalone.ts
315
+ import { readFileSync as readFileSync2 } from "fs";
364
316
  import { createRequire as createRequire2 } from "module";
365
- import {
366
- assertValidTileflowStyle,
367
- TileflowStyleValidationError
368
- } from "@tileflow/dev";
317
+ import { dirname as dirname2, join as join2 } from "path";
318
+ import { getTileflowStyleFontFaces } from "@tileflow/core/runtime";
319
+ import { assertValidTileflowStyle, TileflowStyleValidationError } from "@tileflow/dev/validation";
369
320
  var tileflowSyntheticAssetOrigin = "https://tileflow.local.invalid";
370
321
  var require3 = createRequire2(import.meta.url);
371
322
  var maplibreJsPath = require3.resolve("maplibre-gl/dist/maplibre-gl.js");
372
323
  var maplibreCssPath = require3.resolve("maplibre-gl/dist/maplibre-gl.css");
324
+ var tileflowCorePackagePath = require3.resolve("@tileflow/core/package.json");
325
+ var tileflowBrowserModule = `${readFileSync2(
326
+ join2(dirname2(tileflowCorePackagePath), "dist", "browser.js"),
327
+ "utf8"
328
+ )}
329
+ globalThis.__tileflowRegisterContourProtocol = registerTileflowContourProtocol;`;
373
330
  async function captureStandaloneTileflowScene(input) {
374
331
  if (input.scene.target.kind !== "map") {
375
332
  throw new TileflowCaptureError(
@@ -430,6 +387,7 @@ async function captureStandaloneTileflowScene(input) {
430
387
  }, timeoutMs);
431
388
  timeout.unref?.();
432
389
  const assets = new Map(input.assets.map((asset) => [asset.fileName, asset]));
390
+ const fontFaces = getTileflowStyleFontFaces(input.style);
433
391
  const remoteOrigins = /* @__PURE__ */ new Set();
434
392
  let syntheticAssetFailure;
435
393
  const resourceFailures = [];
@@ -452,8 +410,9 @@ async function captureStandaloneTileflowScene(input) {
452
410
  await route.abort("blockedbyclient");
453
411
  return;
454
412
  }
413
+ const source = asset.source;
455
414
  await route.fulfill({
456
- body: typeof asset.source === "string" ? asset.source : Buffer.from(asset.source),
415
+ body: typeof source === "string" ? source : Buffer.from(source),
457
416
  contentType: asset.contentType,
458
417
  status: 200
459
418
  });
@@ -487,6 +446,7 @@ async function captureStandaloneTileflowScene(input) {
487
446
  await page.setContent(renderHtml(input.scene.viewport), { waitUntil: "domcontentloaded" });
488
447
  await page.addStyleTag({ path: maplibreCssPath });
489
448
  await page.addScriptTag({ path: maplibreJsPath });
449
+ await page.addScriptTag({ content: tileflowBrowserModule, type: "module" });
490
450
  await page.addScriptTag({ content: renderMapInPageScript });
491
451
  phase = "map-load";
492
452
  const loadResult = await page.evaluate(
@@ -495,6 +455,7 @@ async function captureStandaloneTileflowScene(input) {
495
455
  return renderWindow.__tileflowCaptureLoad(renderInput);
496
456
  },
497
457
  {
458
+ fontFaces,
498
459
  mapEventTimeoutMs: Math.max(1, timeoutMs - 1e3),
499
460
  style: input.style
500
461
  }
@@ -599,7 +560,8 @@ function assertPhaseSucceeded(result, phase, syntheticAssetFailure, resourceFail
599
560
  });
600
561
  }
601
562
  if (result.status === "failed" || pageFailed) {
602
- throw new TileflowCaptureError("MAP_LOAD_FAILED", mapFailureMessage(phase), {
563
+ const message = result.status === "failed" && result.message ? `${mapFailureMessage(phase)} ${safeMapError(result.message)}` : mapFailureMessage(phase);
564
+ throw new TileflowCaptureError("MAP_LOAD_FAILED", message, {
603
565
  details: { phase }
604
566
  });
605
567
  }
@@ -701,6 +663,9 @@ function timeoutMessage(phase) {
701
663
  function mapFailureMessage(phase) {
702
664
  return phase === "map-load" ? "MapLibre failed while loading the compiled style." : "MapLibre failed while waiting for the committed camera to become idle.";
703
665
  }
666
+ function safeMapError(value) {
667
+ return value.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g, "resource").replace(/[^A-Za-z0-9 _.,:'"@+()[\]/=-]/g, "").trim().slice(0, 200);
668
+ }
704
669
  function safeAssetName(value) {
705
670
  return value.replace(/[^A-Za-z0-9._/-]/g, "").slice(0, 200) || "unknown";
706
671
  }
@@ -755,6 +720,11 @@ function renderHtml(viewport) {
755
720
  var renderMapInPageScript = String.raw`
756
721
  window.__tileflowCaptureLoad = async function(input) {
757
722
  try {
723
+ await loadTileflowFontFaces(input.fontFaces || []);
724
+ window.__tileflowRegisterContourProtocol?.({addProtocol: window.maplibregl.addProtocol});
725
+ window.__tileflowCaptureLoopbackSourceIds = Object.entries(input.style.sources || {})
726
+ .filter(([, source]) => isLoopbackSourceUrl(source?.url))
727
+ .map(([sourceId]) => sourceId);
758
728
  const map = new window.maplibregl.Map({
759
729
  attributionControl: false,
760
730
  container: "map",
@@ -764,11 +734,59 @@ window.__tileflowCaptureLoad = async function(input) {
764
734
  });
765
735
  window.__tileflowMap = map;
766
736
  return await waitForMapEvent(map, "load", input.mapEventTimeoutMs);
767
- } catch {
768
- return {status: "failed", reason: "error"};
737
+ } catch (error) {
738
+ return {status: "failed", reason: "error", message: String(error?.message || error || "")};
769
739
  }
770
740
  };
771
741
 
742
+ async function loadTileflowFontFaces(definitions) {
743
+ const maximumFontBytes = 1024 * 1024;
744
+ await Promise.all(definitions.map(async (definition) => {
745
+ const response = await fetch(definition.source, {
746
+ cache: "force-cache",
747
+ credentials: "same-origin",
748
+ redirect: "error"
749
+ });
750
+ if (!response.ok) throw new Error("Tileflow font failed: " + response.status);
751
+ const declaredLength = response.headers.get("content-length");
752
+ if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maximumFontBytes) {
753
+ await response.body?.cancel().catch(() => undefined);
754
+ throw new Error("Tileflow font exceeds the maximum response size.");
755
+ }
756
+ if (!response.body) throw new Error("Tileflow font response is empty.");
757
+ const reader = response.body.getReader();
758
+ const chunks = [];
759
+ let byteLength = 0;
760
+ try {
761
+ while (true) {
762
+ const {done, value} = await reader.read();
763
+ if (done) break;
764
+ if (!value) continue;
765
+ byteLength += value.byteLength;
766
+ if (byteLength > maximumFontBytes) {
767
+ await reader.cancel().catch(() => undefined);
768
+ throw new Error("Tileflow font exceeds the maximum response size.");
769
+ }
770
+ chunks.push(value);
771
+ }
772
+ } finally {
773
+ reader.releaseLock();
774
+ }
775
+ const bytes = new Uint8Array(byteLength);
776
+ let offset = 0;
777
+ for (const chunk of chunks) {
778
+ bytes.set(chunk, offset);
779
+ offset += chunk.byteLength;
780
+ }
781
+ const face = new FontFace(definition.family, bytes.buffer, {
782
+ ...(definition.style ? {style: definition.style} : {}),
783
+ ...(definition.weight ? {weight: definition.weight} : {})
784
+ });
785
+ await face.load();
786
+ document.fonts.add(face);
787
+ }));
788
+ }
789
+
772
790
  window.__tileflowCaptureIdle = async function(input) {
773
791
  const map = window.__tileflowMap;
774
792
  if (!map) return {status: "failed", reason: "error"};
@@ -820,9 +838,29 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
820
838
  cleanup();
821
839
  resolve({status: "ok"});
822
840
  };
823
- const onError = () => {
841
+ const onError = (errorEvent) => {
842
+ const message = String(errorEvent?.error?.message || errorEvent?.message || "");
843
+ const missingSourceLayer = message.match(
844
+ /^Source layer "(.+)" does not exist on source "(.+)" as specified by style layer ".+"\.$/
845
+ );
846
+ if (missingSourceLayer) {
847
+ const [, sourceLayer, sourceId] = missingSourceLayer;
848
+ const liveSource = mapInstance.getSource(sourceId);
849
+ const advertisedSourceLayers = liveSource?.vectorLayerIds;
850
+ const loopbackSource = window.__tileflowCaptureLoopbackSourceIds?.includes(sourceId);
851
+ // A layer advertised by TileJSON may legitimately be absent from one
852
+ // MVT. Local PMTiles development archives may also carry stale TileJSON
853
+ // metadata while their tile payload already contains the layer. Keep
854
+ // remote contracts strict, but let local visual captures verify pixels.
855
+ if (
856
+ loopbackSource ||
857
+ (Array.isArray(advertisedSourceLayers) && advertisedSourceLayers.includes(sourceLayer))
858
+ ) {
859
+ return;
860
+ }
861
+ }
824
862
  cleanup();
825
- resolve({status: "failed", reason: "error"});
863
+ resolve({status: "failed", reason: "error", message});
826
864
  };
827
865
  const cleanup = () => {
828
866
  window.clearTimeout(timeout);
@@ -831,10 +869,24 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
831
869
  };
832
870
 
833
871
  mapInstance.once(event, onSuccess);
834
- mapInstance.once("error", onError);
872
+ mapInstance.on("error", onError);
835
873
  });
836
874
  }
837
875
 
876
+ function isLoopbackSourceUrl(value) {
877
+ if (typeof value !== "string") return false;
878
+ try {
879
+ const hostname = new URL(value).hostname.toLowerCase();
880
+ return (
881
+ hostname === "localhost" ||
882
+ hostname === "[::1]" ||
883
+ /^127(?:\.\d{1,3}){3}$/.test(hostname)
884
+ );
885
+ } catch {
886
+ return false;
887
+ }
888
+ }
889
+
838
890
  function nextFrame() {
839
891
  return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
840
892
  }
@@ -886,7 +938,7 @@ async function captureApplicationTileflowScene(input) {
886
938
  try {
887
939
  context = await input.browser.newContext({
888
940
  acceptDownloads: false,
889
- colorScheme: "light",
941
+ colorScheme: input.colorScheme,
890
942
  deviceScaleFactor: input.scene.viewport.dpr,
891
943
  ignoreHTTPSErrors: false,
892
944
  javaScriptEnabled: true,
@@ -985,6 +1037,25 @@ async function captureApplicationTileflowScene(input) {
985
1037
  );
986
1038
  }
987
1039
  if (targetCount === 0) {
1040
+ try {
1041
+ await locator.first().waitFor({
1042
+ state: "attached",
1043
+ // The implicit map/theme locator starts matching only after the framework has resolved
1044
+ // its manifest and concrete theme. Let the operation-wide timeout remain authoritative;
1045
+ // a separate two-second attachment budget made otherwise healthy cold starts fail early.
1046
+ timeout: timeoutMs
1047
+ });
1048
+ targetCount = await locator.count();
1049
+ } catch {
1050
+ }
1051
+ }
1052
+ if (targetCount === 0) {
1053
+ if (applicationFailure || applicationOriginEscape) {
1054
+ throw new TileflowCaptureError(
1055
+ "APPLICATION_ERROR",
1056
+ "The application reported an error before its capture target became ready."
1057
+ );
1058
+ }
988
1059
  throw new TileflowCaptureError(
989
1060
  "APPLICATION_TARGET_NOT_FOUND",
990
1061
  "The application page did not contain the requested Tileflow capture target."
@@ -1003,6 +1074,11 @@ async function captureApplicationTileflowScene(input) {
1003
1074
  timeoutMs,
1004
1075
  () => applicationFailure || applicationOriginEscape
1005
1076
  );
1077
+ await assertApplicationTargetTheme(
1078
+ locator,
1079
+ input.scene.theme,
1080
+ input.scene.target.selector !== void 0
1081
+ );
1006
1082
  if (applicationFailure || applicationOriginEscape) {
1007
1083
  throw new TileflowCaptureError(
1008
1084
  "APPLICATION_ERROR",
@@ -1022,6 +1098,11 @@ async function captureApplicationTileflowScene(input) {
1022
1098
  input.scene.target.frame === "viewport" ? await page.screenshot({ animations: "disabled", type: "png" }) : await locator.screenshot({ animations: "disabled", type: "png" })
1023
1099
  );
1024
1100
  await assertApplicationTargetRemainsReady(locator, input.scene.target.selector !== void 0);
1101
+ await assertApplicationTargetTheme(
1102
+ locator,
1103
+ input.scene.theme,
1104
+ input.scene.target.selector !== void 0
1105
+ );
1025
1106
  if (applicationFailure || applicationOriginEscape) {
1026
1107
  throw new TileflowCaptureError(
1027
1108
  "APPLICATION_ERROR",
@@ -1080,6 +1161,15 @@ async function assertApplicationTargetRemainsReady(locator, allowUnmarkedSelecto
1080
1161
  "The application capture target stopped being ready while its screenshot was produced."
1081
1162
  );
1082
1163
  }
1164
+ async function assertApplicationTargetTheme(locator, expectedTheme, allowUnmarkedSelector) {
1165
+ if (!expectedTheme) return;
1166
+ const actualTheme = await locator.getAttribute("data-tileflow-theme");
1167
+ if (actualTheme === expectedTheme || actualTheme === null && allowUnmarkedSelector) return;
1168
+ throw new TileflowCaptureError(
1169
+ "APPLICATION_ERROR",
1170
+ `The application capture target resolved theme "${actualTheme ?? "(missing)"}" instead of "${expectedTheme}".`
1171
+ );
1172
+ }
1083
1173
  function webSocketHttpOrigin(url) {
1084
1174
  const comparable = new URL(url);
1085
1175
  comparable.protocol = url.protocol === "wss:" ? "https:" : "http:";
@@ -1108,7 +1198,7 @@ function createTargetLocator(page, scene) {
1108
1198
  return page.locator(`[data-tileflow-capture-id="${scene.target.captureId}"]`);
1109
1199
  }
1110
1200
  if (scene.target.selector) return page.locator(scene.target.selector);
1111
- return page.locator(`[data-tileflow-map="${scene.map}"]`);
1201
+ return page.locator(`[data-tileflow-map="${scene.map}"][data-tileflow-theme="${scene.theme}"]`);
1112
1202
  } catch (error) {
1113
1203
  throw new TileflowCaptureError(
1114
1204
  "APPLICATION_TARGET_NOT_FOUND",
@@ -1199,180 +1289,222 @@ function compareCodeUnits2(left, right) {
1199
1289
  return left < right ? -1 : left > right ? 1 : 0;
1200
1290
  }
1201
1291
 
1202
- // src/receipt.ts
1292
+ // src/world.ts
1203
1293
  import {
1204
- serializeCanonicalJson,
1205
- tileflowCaptureIdSchema,
1206
- tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2,
1207
- tileflowCaptureSceneNameSchema
1294
+ isTileflowWorldReleaseId,
1295
+ tileflowWorldTileJsonUrl
1208
1296
  } from "@tileflow/core";
1209
- var tileflowCaptureReceiptSchemaVersion = 2;
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
- data: input.data,
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.");
1297
+ var tileflowWorldCurrentTileJsonUrl = tileflowWorldTileJsonUrl;
1298
+ var maximumTileJsonBytes = 1024 * 1024;
1299
+ var TileflowCaptureWorldSession = class {
1300
+ #fetch;
1301
+ #lookupUrl;
1302
+ #resolution;
1303
+ constructor(fetchTileJson = globalThis.fetch) {
1304
+ this.#fetch = fetchTileJson;
1305
+ }
1306
+ async prepare(style, signal) {
1307
+ const metadata = requireDataMetadata(style);
1308
+ if (metadata.kind !== "tileflow-world") {
1309
+ return { data: validateVectorDataInput(metadata), style };
1310
+ }
1311
+ const source = requirePrimaryVectorSource(style);
1312
+ const lookupUrl = worldTileJsonLookupUrl(source, metadata);
1313
+ const resolution = await this.#resolveOne(lookupUrl, signal);
1314
+ const semantics = validateSemantics(metadata.semantics);
1315
+ const data = {
1316
+ ...resolution.identity,
1317
+ kind: "tileflow-world",
1318
+ schema: requireLiteral(metadata.schema, "openmaptiles", "schema"),
1319
+ schemaVersion: requirePositiveInteger(metadata.schemaVersion, "schemaVersion"),
1320
+ ...semantics ? { semantics } : {},
1321
+ sourceId: requireLiteral(metadata.sourceId, "tileflow", "sourceId")
1322
+ };
1323
+ const { tiles: _mutableTiles, url: _mutableUrl, ...sourceWithoutSelector } = source;
1324
+ return {
1325
+ data,
1326
+ style: {
1327
+ ...style,
1328
+ sources: {
1329
+ ...style.sources,
1330
+ tileflow: { ...sourceWithoutSelector, tiles: [...resolution.tiles] }
1331
+ }
1332
+ }
1333
+ };
1334
+ }
1335
+ #resolveOne(lookupUrl, signal) {
1336
+ if (this.#lookupUrl !== void 0 && this.#lookupUrl !== lookupUrl) {
1337
+ throw worldResolutionError(
1338
+ "One capture session cannot mix different Tileflow World selectors."
1339
+ );
1340
+ }
1341
+ this.#lookupUrl ??= lookupUrl;
1342
+ this.#resolution ??= resolveTileflowCaptureWorldTileJson(lookupUrl, {
1343
+ fetchTileJson: this.#fetch,
1344
+ signal
1345
+ });
1346
+ return this.#resolution;
1347
+ }
1348
+ };
1349
+ async function resolveTileflowCaptureWorldTileJson(tileJsonUrl, options = {}) {
1350
+ const normalizedUrl = validateTileJsonUrl(tileJsonUrl);
1351
+ const expectedRelease = exactWorldReferenceFromSelector(normalizedUrl);
1352
+ let response;
1353
+ try {
1354
+ response = await (options.fetchTileJson ?? globalThis.fetch)(normalizedUrl, {
1355
+ cache: "no-store",
1356
+ redirect: "error",
1357
+ ...options.signal ? { signal: options.signal } : {}
1358
+ });
1359
+ } catch (error) {
1360
+ if (options.signal?.aborted) {
1361
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
1362
+ }
1363
+ throw worldResolutionError("Tileflow World TileJSON could not be loaded.", error);
1364
+ }
1365
+ if (!response.ok) {
1366
+ throw worldResolutionError(`Tileflow World TileJSON returned HTTP ${String(response.status)}.`);
1243
1367
  }
1244
- let value;
1245
1368
  let text;
1369
+ let input;
1246
1370
  try {
1247
- text = typeof source === "string" ? source : new TextDecoder("utf-8", { fatal: true }).decode(source);
1248
- value = JSON.parse(text);
1371
+ text = await response.text();
1372
+ if (new TextEncoder().encode(text).byteLength > maximumTileJsonBytes) {
1373
+ throw new TypeError("TileJSON exceeds the supported byte limit");
1374
+ }
1375
+ input = JSON.parse(text);
1249
1376
  } 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
- "data",
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"
1377
+ throw worldResolutionError("Tileflow World TileJSON is not bounded valid JSON.", error);
1378
+ }
1379
+ const tileJson = requireRecord(input, "TileJSON");
1380
+ if (tileJson.tilejson !== "3.0.0") {
1381
+ throw worldResolutionError("Tileflow World requires TileJSON 3.0.0.");
1382
+ }
1383
+ if (!Array.isArray(tileJson.tiles) || tileJson.tiles.length !== 1) {
1384
+ throw worldResolutionError("Tileflow World TileJSON must contain one exact tile template.");
1385
+ }
1386
+ const tileflow = requireRecord(tileJson.tileflow, "TileJSON tileflow");
1387
+ const world = requireRecord(tileflow.world, "TileJSON tileflow.world");
1388
+ requireExactKeys(world, [
1389
+ "archiveSha256",
1390
+ "contractSha256",
1391
+ "dataContractSha256",
1392
+ "descriptorSha256",
1393
+ "product",
1394
+ "releaseId"
1312
1395
  ]);
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 data = requireRecord(receipt.data, "data");
1319
- requireExactKeys(data, [
1320
- "kind",
1321
- ...data.revision === void 0 ? [] : ["revision"],
1322
- "schema",
1323
- "schemaVersion",
1324
- "sourceId"
1325
- ]);
1326
- if (data.kind !== "tileflow-world" && data.kind !== "vector-tiles") {
1327
- throw invalidReceipt("The baseline receipt has an invalid data.kind.");
1328
- }
1329
- if (data.schema !== "openmaptiles" || data.sourceId !== "tileflow") {
1330
- throw invalidReceipt("The baseline receipt has an unsupported data contract.");
1396
+ if (world.product !== "world-v1") {
1397
+ throw worldResolutionError("Tileflow World TileJSON has an unsupported product.");
1398
+ }
1399
+ const releaseId = requireWorldReleaseId(world.releaseId);
1400
+ const descriptorSha256 = requireHash(world.descriptorSha256, "descriptorSha256");
1401
+ if (expectedRelease && (expectedRelease.releaseId !== releaseId || expectedRelease.descriptorSha256 !== descriptorSha256)) {
1402
+ throw worldResolutionError(
1403
+ "Tileflow World TileJSON conflicts with the requested exact release."
1404
+ );
1331
1405
  }
1406
+ const tileTemplate = validateExactTileTemplate(tileJson.tiles[0], releaseId, descriptorSha256);
1332
1407
  return {
1333
- schemaVersion: tileflowCaptureReceiptSchemaVersion,
1334
- scene: {
1335
- name,
1336
- map,
1337
- target: scene.target,
1338
- sha256: requireHash(scene.sha256, "scene.sha256")
1339
- },
1340
- style: { sha256: requireHash(style.sha256, "style.sha256") },
1341
- image: {
1342
- sha256: requireHash(image.sha256, "image.sha256"),
1343
- cssWidth,
1344
- cssHeight,
1345
- physicalWidth,
1346
- physicalHeight,
1347
- dpr: image.dpr
1348
- },
1349
- renderer: {
1350
- tileflow: requireBoundedString(renderer.tileflow, "renderer.tileflow"),
1351
- maplibre: requireBoundedString(renderer.maplibre, "renderer.maplibre"),
1352
- playwright: requireBoundedString(renderer.playwright, "renderer.playwright"),
1353
- chromiumRevision: requireBoundedString(
1354
- renderer.chromiumRevision,
1355
- "renderer.chromiumRevision"
1356
- ),
1357
- chromiumVersion: requireBoundedString(renderer.chromiumVersion, "renderer.chromiumVersion")
1358
- },
1359
- platform: {
1360
- os: requireBoundedString(platform.os, "platform.os"),
1361
- architecture: requireBoundedString(platform.architecture, "platform.architecture")
1362
- },
1363
- data: {
1364
- kind: data.kind,
1365
- ...data.revision === void 0 ? {} : { revision: requireSourceRevision(data.revision) },
1366
- schema: data.schema,
1367
- schemaVersion: requirePositiveInteger(data.schemaVersion, "data.schemaVersion"),
1368
- sourceId: data.sourceId
1408
+ identity: {
1409
+ archiveSha256: requireHash(world.archiveSha256, "archiveSha256"),
1410
+ contractSha256: requireHash(world.contractSha256, "contractSha256"),
1411
+ dataContractSha256: requireHash(world.dataContractSha256, "dataContractSha256"),
1412
+ descriptorSha256,
1413
+ product: "world-v1",
1414
+ releaseId
1369
1415
  },
1370
- networkDependent: receipt.networkDependent
1416
+ tileJsonUrl: normalizedUrl,
1417
+ tiles: [tileTemplate]
1418
+ };
1419
+ }
1420
+ function requireDataMetadata(style) {
1421
+ const data = style.metadata?.["tileflow:data"];
1422
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
1423
+ throw worldResolutionError("The compiled style has no valid Tileflow data identity.");
1424
+ }
1425
+ return data;
1426
+ }
1427
+ function validateVectorDataInput(data) {
1428
+ if (data.kind !== "vector-tiles") {
1429
+ throw worldResolutionError("The compiled style has an unsupported Tileflow data kind.");
1430
+ }
1431
+ return data;
1432
+ }
1433
+ function requirePrimaryVectorSource(style) {
1434
+ const source = style.sources.tileflow;
1435
+ if (!source || source.type !== "vector") {
1436
+ throw worldResolutionError("Tileflow World requires the primary vector source.");
1437
+ }
1438
+ return source;
1439
+ }
1440
+ function worldTileJsonLookupUrl(source, metadata) {
1441
+ if (typeof source.url === "string") return validateTileJsonUrl(source.url);
1442
+ if (typeof metadata.url === "string") return validateTileJsonUrl(metadata.url);
1443
+ throw worldResolutionError(
1444
+ "Tileflow World capture requires a current or exact TileJSON selector."
1445
+ );
1446
+ }
1447
+ function validateTileJsonUrl(value) {
1448
+ if (value.length < 1 || value.length > 2048) {
1449
+ throw worldResolutionError("Tileflow World TileJSON URL has an invalid length.");
1450
+ }
1451
+ let url;
1452
+ try {
1453
+ url = new URL(value);
1454
+ } catch (error) {
1455
+ throw worldResolutionError("Tileflow World TileJSON URL is invalid.", error);
1456
+ }
1457
+ if (url.username || url.password || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname2(url.hostname))) {
1458
+ throw worldResolutionError("Tileflow World TileJSON URL is not an approved HTTP URL.");
1459
+ }
1460
+ return url.toString();
1461
+ }
1462
+ function exactWorldReferenceFromSelector(value) {
1463
+ const url = new URL(value);
1464
+ const releaseId = url.searchParams.get("worldReleaseId");
1465
+ const descriptorSha256 = url.searchParams.get("worldDescriptorSha256");
1466
+ if (releaseId === null !== (descriptorSha256 === null)) {
1467
+ throw worldResolutionError(
1468
+ "An exact Tileflow World selector requires releaseId and descriptorSha256 together."
1469
+ );
1470
+ }
1471
+ return releaseId === null ? void 0 : {
1472
+ descriptorSha256: requireHash(descriptorSha256, "selector descriptorSha256"),
1473
+ releaseId: requireWorldReleaseId(releaseId)
1371
1474
  };
1372
1475
  }
1476
+ function validateExactTileTemplate(value, releaseId, descriptorSha256) {
1477
+ if (typeof value !== "string" || value.length < 1 || value.length > 4096) {
1478
+ throw worldResolutionError("Tileflow World has an invalid exact tile template.");
1479
+ }
1480
+ if (!value.includes("{z}") || !value.includes("{x}") || !value.includes("{y}")) {
1481
+ throw worldResolutionError("Tileflow World tile template is missing XYZ placeholders.");
1482
+ }
1483
+ let url;
1484
+ try {
1485
+ url = new URL(value.replace("{z}", "0").replace("{x}", "0").replace("{y}", "0"));
1486
+ } catch (error) {
1487
+ throw worldResolutionError("Tileflow World has an invalid exact tile template.", error);
1488
+ }
1489
+ if (url.username || url.password || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname2(url.hostname)) || url.pathname.split("/").includes("current") || !url.pathname.split("/").includes(releaseId) || url.searchParams.get("worldDescriptorSha256") !== descriptorSha256) {
1490
+ throw worldResolutionError(
1491
+ "Tileflow World TileJSON did not bind tiles to its exact release and descriptor."
1492
+ );
1493
+ }
1494
+ return value;
1495
+ }
1496
+ function validateSemantics(value) {
1497
+ if (value === void 0) return void 0;
1498
+ const semantics = requireRecord(value, "data semantics");
1499
+ requireExactKeys(semantics, ["parkLayer"]);
1500
+ if (semantics.parkLayer !== "mixed" && semantics.parkLayer !== "protected-only") {
1501
+ throw worldResolutionError("The compiled style has invalid park semantics.");
1502
+ }
1503
+ return { parkLayer: semantics.parkLayer };
1504
+ }
1373
1505
  function requireRecord(value, field) {
1374
1506
  if (!value || typeof value !== "object" || Array.isArray(value)) {
1375
- throw invalidReceipt(`The baseline receipt has an invalid ${field} object.`);
1507
+ throw worldResolutionError(`${field} must be an object.`);
1376
1508
  }
1377
1509
  return value;
1378
1510
  }
@@ -1380,54 +1512,39 @@ function requireExactKeys(value, expected) {
1380
1512
  const actual = Object.keys(value).sort();
1381
1513
  const wanted = [...expected].sort();
1382
1514
  if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
1383
- throw invalidReceipt("The baseline receipt contains missing or unsupported fields.");
1515
+ throw worldResolutionError("Tileflow World identity contains missing or unsupported fields.");
1384
1516
  }
1385
1517
  }
1386
- function requireIdentifier(value, field) {
1387
- const result = tileflowCaptureIdSchema.safeParse(value);
1388
- if (!result.success) throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1389
- return result.data;
1390
- }
1391
- function requireSceneName(value) {
1392
- const result = tileflowCaptureSceneNameSchema.safeParse(value);
1393
- if (!result.success) throw invalidReceipt("The baseline receipt has an invalid scene.name.");
1394
- return result.data;
1395
- }
1396
1518
  function requireHash(value, field) {
1397
- if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
1398
- throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1519
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
1520
+ throw worldResolutionError(`Tileflow World has an invalid ${field}.`);
1399
1521
  }
1400
1522
  return value;
1401
1523
  }
1402
- function requirePositiveFinite(value, field) {
1403
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
1404
- throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1524
+ function requireWorldReleaseId(value) {
1525
+ if (!isTileflowWorldReleaseId(value)) {
1526
+ throw worldResolutionError("Tileflow World has an invalid releaseId.");
1405
1527
  }
1406
1528
  return value;
1407
1529
  }
1408
1530
  function requirePositiveInteger(value, field) {
1409
1531
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
1410
- throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1411
- }
1412
- return value;
1413
- }
1414
- function requireBoundedString(value, field) {
1415
- if (typeof value !== "string" || value.length < 1 || value.length > 64 || !isSafeRuntimeIdentity(value)) {
1416
- throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
1532
+ throw worldResolutionError(`The compiled style has an invalid ${field}.`);
1417
1533
  }
1418
1534
  return value;
1419
1535
  }
1420
- function requireSourceRevision(value) {
1421
- if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
1422
- throw invalidReceipt("The baseline receipt has an invalid data.revision.");
1536
+ function requireLiteral(value, expected, field) {
1537
+ if (value !== expected) {
1538
+ throw worldResolutionError(`The compiled style has an invalid ${field}.`);
1423
1539
  }
1424
- return value;
1540
+ return expected;
1425
1541
  }
1426
- function isSafeRuntimeIdentity(value) {
1427
- return /^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(value);
1542
+ function isLoopbackHostname2(value) {
1543
+ const hostname = value.toLowerCase().replace(/^\[|\]$/gu, "");
1544
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
1428
1545
  }
1429
- function invalidReceipt(message, cause) {
1430
- return new TileflowCaptureError("BASELINE_INVALID", message, { cause });
1546
+ function worldResolutionError(message, cause) {
1547
+ return new TileflowCaptureError("WORLD_RESOLUTION_FAILED", message, { cause });
1431
1548
  }
1432
1549
 
1433
1550
  // src/capture.ts
@@ -1446,6 +1563,7 @@ function createTileflowCaptureSession(options = {}) {
1446
1563
  var TileflowCaptureSessionImpl = class {
1447
1564
  #options;
1448
1565
  #browserManager;
1566
+ #worldSession;
1449
1567
  #closed = false;
1450
1568
  constructor(options = {}) {
1451
1569
  this.#options = options;
@@ -1454,6 +1572,7 @@ var TileflowCaptureSessionImpl = class {
1454
1572
  onInstallProgress: options.onBrowserInstallProgress,
1455
1573
  signal: options.signal
1456
1574
  });
1575
+ this.#worldSession = new TileflowCaptureWorldSession();
1457
1576
  }
1458
1577
  async capture(scenes, signal) {
1459
1578
  this.#assertOpen();
@@ -1485,7 +1604,8 @@ var TileflowCaptureSessionImpl = class {
1485
1604
  async captureArtifacts(artifacts, requestedScenes, signal) {
1486
1605
  this.#assertOpen();
1487
1606
  const sceneNames = selectTileflowCaptureSceneNames(artifacts, requestedScenes);
1488
- const validatedMaps = /* @__PURE__ */ new Set();
1607
+ const validatedStyles = /* @__PURE__ */ new Set();
1608
+ const preparedStyles = /* @__PURE__ */ new Map();
1489
1609
  for (const sceneName of sceneNames) {
1490
1610
  const scene = this.#resolveScene(artifacts, sceneName);
1491
1611
  if (scene.target.kind === "application" && !this.#options.appOrigin && !this.#options.appUrl) {
@@ -1494,20 +1614,23 @@ var TileflowCaptureSessionImpl = class {
1494
1614
  `Scene "${sceneName}" requires --app-origin or TILEFLOW_APP_ORIGIN.`
1495
1615
  );
1496
1616
  }
1497
- const style = Object.hasOwn(artifacts.styles, scene.map) ? artifacts.styles[scene.map] : void 0;
1617
+ const style = artifacts.styles[scene.map]?.[scene.theme];
1498
1618
  if (!style) {
1499
1619
  throw new TileflowCaptureError(
1500
1620
  "SCENE_NOT_FOUND",
1501
- `Scene "${sceneName}" references unavailable map "${scene.map}".`
1621
+ `Scene "${sceneName}" references unavailable theme "${scene.theme}" on map "${scene.map}".`
1502
1622
  );
1503
1623
  }
1504
- if (!validatedMaps.has(scene.map)) {
1624
+ const styleKey = `${scene.map}/${scene.theme}`;
1625
+ if (!validatedStyles.has(styleKey)) {
1626
+ const prepared = await this.#worldSession.prepare(style, signal);
1505
1627
  try {
1506
- assertValidTileflowStyle2(style, scene.map);
1628
+ assertValidTileflowStyle2(prepared.style, styleKey);
1507
1629
  } catch (error) {
1508
1630
  throwCaptureStyleValidationError(error);
1509
1631
  }
1510
- validatedMaps.add(scene.map);
1632
+ preparedStyles.set(styleKey, prepared);
1633
+ validatedStyles.add(styleKey);
1511
1634
  }
1512
1635
  }
1513
1636
  throwIfAborted2(signal);
@@ -1517,17 +1640,23 @@ var TileflowCaptureSessionImpl = class {
1517
1640
  for (const sceneName of sceneNames) {
1518
1641
  throwIfAborted2(signal);
1519
1642
  const scene = this.#resolveScene(artifacts, sceneName);
1520
- const style = Object.hasOwn(artifacts.styles, scene.map) ? artifacts.styles[scene.map] : void 0;
1521
- if (!style) {
1643
+ const prepared = preparedStyles.get(`${scene.map}/${scene.theme}`);
1644
+ const style = prepared?.style;
1645
+ if (!style || !prepared) {
1522
1646
  throw new TileflowCaptureError(
1523
1647
  "SCENE_NOT_FOUND",
1524
1648
  `Scene "${sceneName}" references unavailable map "${scene.map}".`
1525
1649
  );
1526
1650
  }
1651
+ const colorScheme = resolveThemeSelection(
1652
+ parseTileflowMap(artifacts.project.maps[scene.map]),
1653
+ scene.theme
1654
+ ).theme.colorScheme;
1527
1655
  const rendered = scene.target.kind === "application" ? await captureApplicationTileflowScene({
1528
1656
  appOrigin: this.#options.appOrigin,
1529
1657
  appUrl: this.#options.appUrl,
1530
1658
  browser,
1659
+ colorScheme,
1531
1660
  scene: { ...scene, target: scene.target },
1532
1661
  signal,
1533
1662
  timeoutMs: this.#options.timeoutMs
@@ -1541,15 +1670,16 @@ var TileflowCaptureSessionImpl = class {
1541
1670
  });
1542
1671
  const [sha256, sceneSha256, styleSha256] = await Promise.all([
1543
1672
  sha256Hex(rendered.png),
1544
- sha256Hex(serializeCanonicalJson2(scene)),
1545
- sha256Hex(serializeCanonicalJson2(style))
1673
+ sha256Hex(serializeCanonicalJson(scene)),
1674
+ sha256Hex(serializeCanonicalJson(style))
1546
1675
  ]);
1547
1676
  const target = scene.target.kind;
1548
1677
  const receipt = createTileflowCaptureReceipt({
1549
- data: style.metadata?.["tileflow:data"],
1678
+ data: prepared.data,
1550
1679
  dpr: scene.viewport.dpr,
1551
1680
  height: rendered.height,
1552
1681
  map: scene.map,
1682
+ theme: scene.theme,
1553
1683
  networkDependent: rendered.networkDependent,
1554
1684
  pngSha256: sha256,
1555
1685
  renderer,
@@ -1562,6 +1692,7 @@ var TileflowCaptureSessionImpl = class {
1562
1692
  captures.push({
1563
1693
  scene: sceneName,
1564
1694
  map: scene.map,
1695
+ theme: scene.theme,
1565
1696
  target,
1566
1697
  png: rendered.png,
1567
1698
  sha256,
@@ -1630,7 +1761,7 @@ var TileflowCaptureSessionImpl = class {
1630
1761
  }
1631
1762
  };
1632
1763
  function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
1633
- const availableScenes = Object.keys(artifacts.project.scenes ?? {}).filter((sceneName) => tileflowCaptureSceneNameSchema2.safeParse(sceneName).success).sort(compareCodeUnits3);
1764
+ const availableScenes = Object.keys(artifacts.project.scenes ?? {}).filter((sceneName) => tileflowCaptureSceneNameSchema.safeParse(sceneName).success).sort(compareCodeUnits3);
1634
1765
  const sceneNames = [...new Set(requestedScenes)].sort(compareCodeUnits3);
1635
1766
  if (sceneNames.length === 0) {
1636
1767
  throw new TileflowCaptureError(
@@ -1639,7 +1770,7 @@ function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
1639
1770
  );
1640
1771
  }
1641
1772
  for (const sceneName of sceneNames) {
1642
- if (!tileflowCaptureSceneNameSchema2.safeParse(sceneName).success) {
1773
+ if (!tileflowCaptureSceneNameSchema.safeParse(sceneName).success) {
1643
1774
  throw new TileflowCaptureError(
1644
1775
  "SCENE_NOT_FOUND",
1645
1776
  "Expected a portable Tileflow capture scene name."
@@ -1660,7 +1791,26 @@ function normalizeScene(artifacts, sceneName) {
1660
1791
  if (!scene) {
1661
1792
  throw new TileflowCaptureError("SCENE_NOT_FOUND", `Unknown Tileflow scene "${sceneName}".`);
1662
1793
  }
1663
- return normalizeTileflowCaptureScene(scene);
1794
+ const normalized = normalizeTileflowCaptureScene(scene);
1795
+ const authoredMap = artifacts.project.maps[normalized.map];
1796
+ if (!authoredMap) {
1797
+ throw new TileflowCaptureError(
1798
+ "SCENE_NOT_FOUND",
1799
+ `Scene "${sceneName}" references unavailable map "${normalized.map}".`
1800
+ );
1801
+ }
1802
+ const map = parseTileflowMap(authoredMap);
1803
+ let theme;
1804
+ try {
1805
+ theme = resolveThemeSelection(map, normalized.theme).name;
1806
+ } catch (error) {
1807
+ throw new TileflowCaptureError(
1808
+ "SCENE_NOT_FOUND",
1809
+ `Scene "${sceneName}" has an invalid theme: ${error instanceof Error ? error.message : "unknown theme"}`,
1810
+ { cause: error }
1811
+ );
1812
+ }
1813
+ return { ...normalized, theme };
1664
1814
  }
1665
1815
  function throwIfAborted2(signal) {
1666
1816
  if (signal?.aborted) {
@@ -1682,33 +1832,119 @@ function throwCaptureStyleValidationError(error) {
1682
1832
 
1683
1833
  // src/visual.ts
1684
1834
  import pixelmatch from "pixelmatch";
1835
+ import { types as nodeUtilTypes } from "util";
1685
1836
  import { PNG } from "pngjs";
1686
1837
  import {
1687
1838
  compareCodeUnits as compareCodeUnits4,
1688
- serializeCanonicalJson as serializeCanonicalJson3,
1839
+ serializeCanonicalJson as serializeCanonicalJson2,
1689
1840
  sha256Hex as sha256Hex2,
1690
- tileflowCaptureSceneLimits as tileflowCaptureSceneLimits3
1841
+ tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2
1691
1842
  } from "@tileflow/core";
1692
1843
  var tileflowVisualComparisonSchemaVersion = 1;
1693
1844
  var tileflowVisualAnalysisSchemaVersion = 1;
1694
1845
  var tileflowVisualPerceptualThreshold = 0.1;
1846
+ var tileflowVisualEdgeThreshold = 0.05;
1695
1847
  var tileflowVisualArtifactLimits = Object.freeze({
1696
1848
  maximumPngBytes: 256 * 1024 * 1024,
1697
1849
  maximumPaletteColors: 16
1698
1850
  });
1851
+ async function analyzeTileflowCapturePairForReview(left, right, options) {
1852
+ if (options.region) {
1853
+ resolveTileflowVisualRegion(
1854
+ options.region,
1855
+ left.receipt.image.physicalWidth,
1856
+ left.receipt.image.physicalHeight
1857
+ );
1858
+ resolveTileflowVisualRegion(
1859
+ options.region,
1860
+ right.receipt.image.physicalWidth,
1861
+ right.receipt.image.physicalHeight
1862
+ );
1863
+ }
1864
+ if (!options.comparePixels) {
1865
+ const leftIdentity = await inspectPngAgainstReceipt(left.png, left.receipt, "left");
1866
+ const rightIdentity = await inspectPngAgainstReceipt(right.png, right.receipt, "right");
1867
+ return {
1868
+ left: leftIdentity,
1869
+ right: rightIdentity,
1870
+ exact: null,
1871
+ perceptual: null,
1872
+ meanAbsoluteChannelDifference: null,
1873
+ appearance: null
1874
+ };
1875
+ }
1876
+ const leftImage = await validatePngAgainstReceipt(left.png, left.receipt, "left");
1877
+ const rightImage = await validatePngAgainstReceipt(right.png, right.receipt, "right");
1878
+ if (leftImage.width !== rightImage.width || leftImage.height !== rightImage.height) {
1879
+ throw invalidBaseline("Comparable review PNGs did not decode to equal dimensions.");
1880
+ }
1881
+ const totalPixels = leftImage.width * leftImage.height;
1882
+ const region = resolveTileflowVisualRegion(options.region, leftImage.width, leftImage.height);
1883
+ const appearance = createTileflowVisualReviewAppearance(leftImage, rightImage, region);
1884
+ const exactChangedPixels = countExactChangedPixels(leftImage.data, rightImage.data);
1885
+ const diffData = options.includeDiff ? new Uint8Array(totalPixels * 4) : void 0;
1886
+ const perceptualChangedPixels = pixelmatch(
1887
+ leftImage.data,
1888
+ rightImage.data,
1889
+ diffData,
1890
+ leftImage.width,
1891
+ leftImage.height,
1892
+ {
1893
+ checkerboard: true,
1894
+ diffColor: [255, 0, 255],
1895
+ diffColorAlt: [0, 255, 255],
1896
+ diffMask: true,
1897
+ includeAA: true,
1898
+ threshold: tileflowVisualPerceptualThreshold
1899
+ }
1900
+ );
1901
+ return {
1902
+ left: reviewImageAnalysisIdentity(leftImage, left.receipt),
1903
+ right: reviewImageAnalysisIdentity(rightImage, right.receipt),
1904
+ exact: pixelMetric(exactChangedPixels, totalPixels),
1905
+ perceptual: {
1906
+ threshold: tileflowVisualPerceptualThreshold,
1907
+ ...pixelMetric(perceptualChangedPixels, totalPixels)
1908
+ },
1909
+ meanAbsoluteChannelDifference: meanAbsoluteChannelDifference(leftImage.data, rightImage.data),
1910
+ appearance,
1911
+ ...diffData ? { diffPng: encodeDiffPng(diffData, leftImage.width, leftImage.height) } : {}
1912
+ };
1913
+ }
1914
+ async function inspectPngAgainstReceipt(png, receipt, role) {
1915
+ const image = await validatePngAgainstReceipt(png, receipt, role);
1916
+ return reviewImageAnalysisIdentity(image, receipt);
1917
+ }
1918
+ function reviewImageAnalysisIdentity(image, receipt) {
1919
+ return {
1920
+ sha256: receipt.image.sha256,
1921
+ physicalWidth: image.width,
1922
+ physicalHeight: image.height,
1923
+ palette: createQuantizedPalette(image.data)
1924
+ };
1925
+ }
1699
1926
  function validateTileflowVisualReferencePng(png) {
1700
1927
  const image = decodePng(png, "reference");
1701
1928
  return { width: image.width, height: image.height };
1702
1929
  }
1703
- async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
1930
+ async function analyzeTileflowCaptureReference(actualCapture, referencePng, options = {}) {
1931
+ const requestedRegion = snapshotTileflowVisualReferenceAnalysisOptions(options);
1704
1932
  const receipt = validateTileflowCaptureReceipt(actualCapture.receipt);
1933
+ if (receipt.schemaVersion !== 4) {
1934
+ throw invalidBaseline("Visual reference analysis requires a schema-v4 capture receipt.");
1935
+ }
1705
1936
  const actualImage = await validatePngAgainstReceipt(actualCapture.png, receipt, "actual");
1706
1937
  const referenceImage = decodePng(referencePng, "reference");
1707
1938
  const dimensionsMatch = referenceImage.width === actualImage.width && referenceImage.height === actualImage.height;
1939
+ if (requestedRegion) {
1940
+ resolveTileflowVisualRegion(requestedRegion, referenceImage.width, referenceImage.height);
1941
+ resolveTileflowVisualRegion(requestedRegion, actualImage.width, actualImage.height);
1942
+ }
1708
1943
  const common = {
1709
1944
  schemaVersion: tileflowVisualAnalysisSchemaVersion,
1710
1945
  scene: receipt.scene.name,
1711
1946
  map: receipt.scene.map,
1947
+ theme: receipt.scene.theme,
1712
1948
  target: receipt.scene.target,
1713
1949
  dimensionsMatch,
1714
1950
  reference: {
@@ -1730,6 +1966,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
1730
1966
  exact: null,
1731
1967
  perceptual: null,
1732
1968
  meanAbsoluteChannelDifference: null,
1969
+ appearance: null,
1733
1970
  warnings: [
1734
1971
  "The reference and actual PNG dimensions differ; pixel metrics and diff were not computed.",
1735
1972
  ...networkWarnings(void 0, receipt)
@@ -1737,6 +1974,11 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
1737
1974
  };
1738
1975
  }
1739
1976
  const totalPixels = actualImage.width * actualImage.height;
1977
+ const region = resolveTileflowVisualRegion(
1978
+ requestedRegion,
1979
+ actualImage.width,
1980
+ actualImage.height
1981
+ );
1740
1982
  const exactChangedPixels = countExactChangedPixels(referenceImage.data, actualImage.data);
1741
1983
  const diffData = new Uint8Array(totalPixels * 4);
1742
1984
  const perceptualChangedPixels = pixelmatch(
@@ -1765,6 +2007,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
1765
2007
  referenceImage.data,
1766
2008
  actualImage.data
1767
2009
  ),
2010
+ appearance: createTileflowVisualReferenceAppearance(referenceImage, actualImage, region),
1768
2011
  diffPng: encodeDiffPng(diffData, actualImage.width, actualImage.height),
1769
2012
  warnings: networkWarnings(void 0, receipt)
1770
2013
  };
@@ -1852,8 +2095,8 @@ async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
1852
2095
  baselineReceipt.image.physicalHeight,
1853
2096
  actualReceipt.image.physicalHeight
1854
2097
  );
1855
- const boundedWidth = diffWidth * diffHeight <= tileflowCaptureSceneLimits3.maximumPhysicalPixels ? diffWidth : actualReceipt.image.physicalWidth;
1856
- const boundedHeight = diffWidth * diffHeight <= tileflowCaptureSceneLimits3.maximumPhysicalPixels ? diffHeight : actualReceipt.image.physicalHeight;
2098
+ const boundedWidth = diffWidth * diffHeight <= tileflowCaptureSceneLimits2.maximumPhysicalPixels ? diffWidth : actualReceipt.image.physicalWidth;
2099
+ const boundedHeight = diffWidth * diffHeight <= tileflowCaptureSceneLimits2.maximumPhysicalPixels ? diffHeight : actualReceipt.image.physicalHeight;
1857
2100
  return {
1858
2101
  ...common,
1859
2102
  status: "changed",
@@ -1918,7 +2161,7 @@ function createTileflowVisualComparisonDocument(comparison) {
1918
2161
  return document;
1919
2162
  }
1920
2163
  function serializeTileflowVisualComparison(comparison) {
1921
- return `${serializeCanonicalJson3(createTileflowVisualComparisonDocument(comparison))}
2164
+ return `${serializeCanonicalJson2(createTileflowVisualComparisonDocument(comparison))}
1922
2165
  `;
1923
2166
  }
1924
2167
  function imageIdentity(receipt) {
@@ -1964,7 +2207,7 @@ function preflightPng(png, role) {
1964
2207
  `The ${role} PNG is interlaced and cannot be decoded within fixed bounds.`
1965
2208
  );
1966
2209
  }
1967
- if (dimensions.width > tileflowCaptureSceneLimits3.viewport.maximum * 2 || dimensions.height > tileflowCaptureSceneLimits3.viewport.maximum * 2 || dimensions.width * dimensions.height > tileflowCaptureSceneLimits3.maximumPhysicalPixels) {
2210
+ if (dimensions.width > tileflowCaptureSceneLimits2.viewport.maximum * 2 || dimensions.height > tileflowCaptureSceneLimits2.viewport.maximum * 2 || dimensions.width * dimensions.height > tileflowCaptureSceneLimits2.maximumPhysicalPixels) {
1968
2211
  throw invalidBaseline(`The ${role} PNG exceeds the visual pixel limit.`);
1969
2212
  }
1970
2213
  return dimensions;
@@ -2047,11 +2290,260 @@ function meanAbsoluteChannelDifference(left, right) {
2047
2290
  }
2048
2291
  return Math.round(sum / left.byteLength * 1e6) / 1e6;
2049
2292
  }
2293
+ var appearanceHistogramSteps = 4096;
2294
+ var maximumOklabChroma = 0.5;
2295
+ function createTileflowVisualReferenceAppearance(reference, actual, region) {
2296
+ const referenceProfile = createAppearanceProfile(reference, region);
2297
+ const actualProfile = createAppearanceProfile(actual, region);
2298
+ return {
2299
+ region,
2300
+ reference: referenceProfile,
2301
+ actual: actualProfile,
2302
+ actualMinusReference: subtractAppearanceProfiles(actualProfile, referenceProfile)
2303
+ };
2304
+ }
2305
+ function createTileflowVisualReviewAppearance(left, right, region) {
2306
+ const leftProfile = createAppearanceProfile(left, region);
2307
+ const rightProfile = createAppearanceProfile(right, region);
2308
+ return {
2309
+ region,
2310
+ left: leftProfile,
2311
+ right: rightProfile,
2312
+ rightMinusLeft: subtractAppearanceProfiles(rightProfile, leftProfile)
2313
+ };
2314
+ }
2315
+ function createAppearanceProfile(image, region) {
2316
+ const linearLuminanceHistogram = createAppearanceHistogram(1);
2317
+ const oklabLightnessHistogram = createAppearanceHistogram(1);
2318
+ const oklabChromaHistogram = createAppearanceHistogram(maximumOklabChroma);
2319
+ const previousRow = new Float64Array(region.width);
2320
+ const totalPixels = region.width * region.height;
2321
+ let linearLuminanceSum = 0;
2322
+ let oklabLightnessSum = 0;
2323
+ let oklabChromaSum = 0;
2324
+ let neighborCount = 0;
2325
+ let edgeCount = 0;
2326
+ let localContrastSum = 0;
2327
+ for (let localY = 0; localY < region.height; localY += 1) {
2328
+ const y = region.y + localY;
2329
+ let leftLuminance = 0;
2330
+ for (let localX = 0; localX < region.width; localX += 1) {
2331
+ const x = region.x + localX;
2332
+ const offset = (y * image.width + x) * 4;
2333
+ const alpha = image.data[offset + 3] / 255;
2334
+ const red = srgbToLinear(compositeSrgbOnWhite(image.data[offset], alpha));
2335
+ const green = srgbToLinear(compositeSrgbOnWhite(image.data[offset + 1], alpha));
2336
+ const blue = srgbToLinear(compositeSrgbOnWhite(image.data[offset + 2], alpha));
2337
+ const linearLuminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
2338
+ const [oklabLightness, oklabA, oklabB] = linearSrgbToOklab(red, green, blue);
2339
+ const oklabChroma = Math.hypot(oklabA, oklabB);
2340
+ linearLuminanceSum += linearLuminance;
2341
+ oklabLightnessSum += oklabLightness;
2342
+ oklabChromaSum += oklabChroma;
2343
+ addAppearanceHistogramValue(linearLuminanceHistogram, linearLuminance);
2344
+ addAppearanceHistogramValue(oklabLightnessHistogram, oklabLightness);
2345
+ addAppearanceHistogramValue(oklabChromaHistogram, oklabChroma);
2346
+ if (localX > 0) {
2347
+ const difference = Math.abs(linearLuminance - leftLuminance);
2348
+ localContrastSum += difference;
2349
+ neighborCount += 1;
2350
+ if (difference >= tileflowVisualEdgeThreshold) edgeCount += 1;
2351
+ }
2352
+ if (localY > 0) {
2353
+ const difference = Math.abs(linearLuminance - previousRow[localX]);
2354
+ localContrastSum += difference;
2355
+ neighborCount += 1;
2356
+ if (difference >= tileflowVisualEdgeThreshold) edgeCount += 1;
2357
+ }
2358
+ leftLuminance = linearLuminance;
2359
+ previousRow[localX] = linearLuminance;
2360
+ }
2361
+ }
2362
+ return {
2363
+ linearLuminance: appearanceStatistics(
2364
+ linearLuminanceSum,
2365
+ totalPixels,
2366
+ linearLuminanceHistogram
2367
+ ),
2368
+ oklabLightness: appearanceStatistics(oklabLightnessSum, totalPixels, oklabLightnessHistogram),
2369
+ oklabChroma: appearanceStatistics(oklabChromaSum, totalPixels, oklabChromaHistogram),
2370
+ edgeDensity: roundAppearanceMetric(neighborCount === 0 ? 0 : edgeCount / neighborCount),
2371
+ localContrast: roundAppearanceMetric(
2372
+ neighborCount === 0 ? 0 : localContrastSum / neighborCount
2373
+ )
2374
+ };
2375
+ }
2376
+ function createAppearanceHistogram(maximum) {
2377
+ return { counts: new Uint32Array(appearanceHistogramSteps + 1), maximum };
2378
+ }
2379
+ function addAppearanceHistogramValue(histogram, value) {
2380
+ const normalized = Math.max(0, Math.min(1, value / histogram.maximum));
2381
+ histogram.counts[Math.round(normalized * appearanceHistogramSteps)] += 1;
2382
+ }
2383
+ function appearanceStatistics(sum, total, histogram) {
2384
+ return {
2385
+ mean: roundAppearanceMetric(sum / total),
2386
+ percentiles: {
2387
+ p10: appearancePercentile(histogram, total, 0.1),
2388
+ p50: appearancePercentile(histogram, total, 0.5),
2389
+ p90: appearancePercentile(histogram, total, 0.9)
2390
+ }
2391
+ };
2392
+ }
2393
+ function appearancePercentile(histogram, total, percentile) {
2394
+ const position = (total - 1) * percentile;
2395
+ const lowerRank = Math.floor(position);
2396
+ const upperRank = Math.ceil(position);
2397
+ const lower = appearanceHistogramValueAtRank(histogram, lowerRank);
2398
+ const upper = appearanceHistogramValueAtRank(histogram, upperRank);
2399
+ return roundAppearanceMetric(lower + (upper - lower) * (position - lowerRank));
2400
+ }
2401
+ function appearanceHistogramValueAtRank(histogram, rank) {
2402
+ let cumulative = 0;
2403
+ for (let index = 0; index < histogram.counts.length; index += 1) {
2404
+ cumulative += histogram.counts[index];
2405
+ if (cumulative > rank) {
2406
+ return index / appearanceHistogramSteps * histogram.maximum;
2407
+ }
2408
+ }
2409
+ return histogram.maximum;
2410
+ }
2411
+ function subtractAppearanceProfiles(minuend, subtrahend) {
2412
+ return {
2413
+ linearLuminance: subtractAppearanceStatistics(
2414
+ minuend.linearLuminance,
2415
+ subtrahend.linearLuminance
2416
+ ),
2417
+ oklabLightness: subtractAppearanceStatistics(minuend.oklabLightness, subtrahend.oklabLightness),
2418
+ oklabChroma: subtractAppearanceStatistics(minuend.oklabChroma, subtrahend.oklabChroma),
2419
+ edgeDensity: subtractAppearanceMetric(minuend.edgeDensity, subtrahend.edgeDensity),
2420
+ localContrast: subtractAppearanceMetric(minuend.localContrast, subtrahend.localContrast)
2421
+ };
2422
+ }
2423
+ function subtractAppearanceStatistics(minuend, subtrahend) {
2424
+ return {
2425
+ mean: subtractAppearanceMetric(minuend.mean, subtrahend.mean),
2426
+ percentiles: {
2427
+ p10: subtractAppearanceMetric(minuend.percentiles.p10, subtrahend.percentiles.p10),
2428
+ p50: subtractAppearanceMetric(minuend.percentiles.p50, subtrahend.percentiles.p50),
2429
+ p90: subtractAppearanceMetric(minuend.percentiles.p90, subtrahend.percentiles.p90)
2430
+ }
2431
+ };
2432
+ }
2433
+ function subtractAppearanceMetric(minuend, subtrahend) {
2434
+ return roundAppearanceMetric(minuend - subtrahend);
2435
+ }
2436
+ function roundAppearanceMetric(value) {
2437
+ const rounded = Math.round(value * 1e6) / 1e6;
2438
+ return Object.is(rounded, -0) ? 0 : rounded;
2439
+ }
2440
+ function compositeSrgbOnWhite(channel, alpha) {
2441
+ return channel / 255 * alpha + (1 - alpha);
2442
+ }
2443
+ function srgbToLinear(channel) {
2444
+ return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
2445
+ }
2446
+ function linearSrgbToOklab(red, green, blue) {
2447
+ const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
2448
+ const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
2449
+ const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
2450
+ return [
2451
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
2452
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
2453
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
2454
+ ];
2455
+ }
2456
+ function snapshotTileflowVisualReferenceAnalysisOptions(options) {
2457
+ const record = requirePlainVisualRecord(options, "Visual reference analysis options");
2458
+ const keys = Object.keys(record);
2459
+ if (keys.some((key) => key !== "region")) {
2460
+ throw invalidBaseline("Visual reference analysis options contain unsupported fields.");
2461
+ }
2462
+ return record.region === void 0 ? void 0 : snapshotTileflowVisualRegion(record.region, "Visual reference analysis region");
2463
+ }
2464
+ function snapshotTileflowVisualRegion(value, label) {
2465
+ const record = requirePlainVisualRecord(value, label);
2466
+ const keys = Object.keys(record);
2467
+ if (keys.length !== 4 || keys.some((key) => key !== "x" && key !== "y" && key !== "width" && key !== "height")) {
2468
+ throw invalidBaseline(`${label} must contain exactly x, y, width, and height.`);
2469
+ }
2470
+ return {
2471
+ x: record.x,
2472
+ y: record.y,
2473
+ width: record.width,
2474
+ height: record.height
2475
+ };
2476
+ }
2477
+ function requirePlainVisualRecord(value, label) {
2478
+ if (value === null || typeof value !== "object" || nodeUtilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) {
2479
+ throw invalidBaseline(`${label} must be a plain data object.`);
2480
+ }
2481
+ const descriptors = Object.getOwnPropertyDescriptors(value);
2482
+ const entries = [];
2483
+ for (const key of Reflect.ownKeys(descriptors)) {
2484
+ if (typeof key !== "string") {
2485
+ throw invalidBaseline(`${label} must not contain symbol fields.`);
2486
+ }
2487
+ const descriptor = descriptors[key];
2488
+ if (!("value" in descriptor) || !descriptor.enumerable) {
2489
+ throw invalidBaseline(`${label} must not contain accessors or hidden fields.`);
2490
+ }
2491
+ entries.push([key, descriptor.value]);
2492
+ }
2493
+ return Object.fromEntries(entries);
2494
+ }
2495
+ function resolveTileflowVisualRegion(requested, physicalWidth, physicalHeight) {
2496
+ const region = requested ?? { x: 0, y: 0, width: physicalWidth, height: physicalHeight };
2497
+ if (!Number.isSafeInteger(region.x) || !Number.isSafeInteger(region.y) || !Number.isSafeInteger(region.width) || !Number.isSafeInteger(region.height) || region.x < 0 || region.y < 0 || region.width <= 0 || region.height <= 0 || region.x > physicalWidth - region.width || region.y > physicalHeight - region.height) {
2498
+ throw invalidBaseline(
2499
+ "The visual region must be a positive integer rectangle within the physical PNG bounds."
2500
+ );
2501
+ }
2502
+ return { ...region };
2503
+ }
2050
2504
  function pixelMetric(changedPixels, totalPixels) {
2051
2505
  return { changedPixels, totalPixels, ratio: changedPixels / totalPixels };
2052
2506
  }
2053
2507
  function sameSceneIdentity(baseline, actual) {
2054
- 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 && serializeCanonicalJson3(baseline.data) === serializeCanonicalJson3(actual.data);
2508
+ 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 && sameDataIdentity(baseline.data, actual.data);
2509
+ }
2510
+ function sameDataIdentity(baseline, actual) {
2511
+ if (baseline.kind !== actual.kind || baseline.schema !== actual.schema || baseline.schemaVersion !== actual.schemaVersion || baseline.sourceId !== actual.sourceId) {
2512
+ return false;
2513
+ }
2514
+ const baselineExactWorld = exactWorldIdentity(baseline);
2515
+ const actualExactWorld = exactWorldIdentity(actual);
2516
+ if (baselineExactWorld || actualExactWorld) {
2517
+ if (!baselineExactWorld || !actualExactWorld) return false;
2518
+ return baselineExactWorld.product === actualExactWorld.product && baselineExactWorld.releaseId === actualExactWorld.releaseId && baselineExactWorld.descriptorSha256 === actualExactWorld.descriptorSha256 && baselineExactWorld.archiveSha256 === actualExactWorld.archiveSha256 && baselineExactWorld.dataContractSha256 === actualExactWorld.dataContractSha256 && baselineExactWorld.contractSha256 === actualExactWorld.contractSha256 && sameOptionalSemantics(baselineExactWorld.semantics, actualExactWorld.semantics);
2519
+ }
2520
+ const baselineLegacy = baseline;
2521
+ const actualLegacy = actual;
2522
+ if (baselineLegacy.generation !== actualLegacy.generation || baselineLegacy.revision !== actualLegacy.revision || !sameOptionalSemantics(baselineLegacy.semantics, actualLegacy.semantics)) {
2523
+ return false;
2524
+ }
2525
+ if (baselineLegacy.source && (!actualLegacy.source || baselineLegacy.source.kind !== actualLegacy.source.kind || baselineLegacy.source.sha256 !== actualLegacy.source.sha256)) {
2526
+ return false;
2527
+ }
2528
+ if (baselineLegacy.capabilities && (!actualLegacy.capabilities || !isRecordSubset(baselineLegacy.capabilities, actualLegacy.capabilities))) {
2529
+ return false;
2530
+ }
2531
+ if (baselineLegacy.bindings) {
2532
+ if (!actualLegacy.bindings) return false;
2533
+ if (!isRecordSubset(baselineLegacy.bindings.fields, actualLegacy.bindings.fields) || !isRecordSubset(baselineLegacy.bindings.layers, actualLegacy.bindings.layers)) {
2534
+ return false;
2535
+ }
2536
+ }
2537
+ return true;
2538
+ }
2539
+ function exactWorldIdentity(value) {
2540
+ return value.kind === "tileflow-world" && "product" in value ? value : void 0;
2541
+ }
2542
+ function sameOptionalSemantics(baseline, actual) {
2543
+ return baseline === void 0 || baseline.parkLayer === actual?.parkLayer;
2544
+ }
2545
+ function isRecordSubset(baseline, actual) {
2546
+ return Object.entries(baseline).every(([key, value]) => actual[key] === value);
2055
2547
  }
2056
2548
  function sameRuntimeIdentity(baseline, actual) {
2057
2549
  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;
@@ -2069,22 +2561,455 @@ function networkWarnings(baseline, actual) {
2069
2561
  function invalidBaseline(message, cause) {
2070
2562
  return new TileflowCaptureError("BASELINE_INVALID", message, { cause });
2071
2563
  }
2564
+
2565
+ // src/review.ts
2566
+ import { types as nodeUtilTypes2 } from "util";
2567
+ import {
2568
+ normalizeTileflowCaptureScene as normalizeTileflowCaptureScene2,
2569
+ serializeCanonicalJson as serializeCanonicalJson3,
2570
+ sha256Hex as sha256Hex3
2571
+ } from "@tileflow/core";
2572
+ var tileflowVisualReviewSchemaVersion = 1;
2573
+ var TileflowVisualReviewError = class extends Error {
2574
+ code = "VISUAL_REVIEW_INVALID";
2575
+ constructor(message, options) {
2576
+ super(message, options);
2577
+ this.name = "TileflowVisualReviewError";
2578
+ }
2579
+ };
2580
+ async function compareTileflowCapturesForReview(leftInput, rightInput, options = {}) {
2581
+ const normalizedOptions = validateReviewOptions(options);
2582
+ const [leftSnapshot, rightSnapshot] = snapshotReviewInputs(leftInput, rightInput);
2583
+ const [left, right] = await Promise.all([
2584
+ validateReviewCapture(leftSnapshot, "left"),
2585
+ validateReviewCapture(rightSnapshot, "right")
2586
+ ]);
2587
+ const frameMatch = sameCanonicalValue(left.comparableFrame, right.comparableFrame);
2588
+ const dimensionsMatch = left.receipt.image.physicalWidth === right.receipt.image.physicalWidth && left.receipt.image.physicalHeight === right.receipt.image.physicalHeight;
2589
+ const rendererMatch = sameCanonicalValue(
2590
+ runtimeIdentity2(left.receipt),
2591
+ runtimeIdentity2(right.receipt)
2592
+ );
2593
+ const dataMatch = sameCanonicalValue(left.receipt.data, right.receipt.data);
2594
+ const status = reviewStatus({ dataMatch, dimensionsMatch, frameMatch, rendererMatch });
2595
+ const comparable = status === "comparable";
2596
+ const warnings = reviewWarnings(status, left.receipt, right.receipt);
2597
+ let analysis;
2598
+ try {
2599
+ analysis = await analyzeTileflowCapturePairForReview(
2600
+ { png: left.png, receipt: left.receipt },
2601
+ { png: right.png, receipt: right.receipt },
2602
+ {
2603
+ comparePixels: comparable,
2604
+ includeDiff: comparable && normalizedOptions.includeDiff,
2605
+ ...normalizedOptions.region ? { region: normalizedOptions.region } : {}
2606
+ }
2607
+ );
2608
+ } catch (error) {
2609
+ const detail = error instanceof Error ? ` ${error.message}` : "";
2610
+ throw invalidReview(`The review PNGs could not be validated.${detail}`, error);
2611
+ }
2612
+ return {
2613
+ schemaVersion: tileflowVisualReviewSchemaVersion,
2614
+ kind: "style-review",
2615
+ status,
2616
+ left: reviewSide(left, analysis.left.palette),
2617
+ right: reviewSide(right, analysis.right.palette),
2618
+ frameMatch,
2619
+ dimensionsMatch,
2620
+ rendererMatch,
2621
+ dataMatch,
2622
+ exact: comparable ? analysis.exact : null,
2623
+ perceptual: comparable ? analysis.perceptual : null,
2624
+ meanAbsoluteChannelDifference: comparable ? analysis.meanAbsoluteChannelDifference : null,
2625
+ appearance: comparable ? analysis.appearance : null,
2626
+ ...comparable && analysis.diffPng ? { diffPng: analysis.diffPng } : {},
2627
+ warnings
2628
+ };
2629
+ }
2630
+ function createTileflowVisualReviewDocument(comparison) {
2631
+ const { diffPng: _diffPng, ...document } = comparison;
2632
+ return document;
2633
+ }
2634
+ var tileflowVisualReviewLimits = Object.freeze({
2635
+ maximumAggregatePngBytes: tileflowVisualArtifactLimits.maximumPngBytes
2636
+ });
2637
+ var reviewCaptureKeys = [
2638
+ "scene",
2639
+ "map",
2640
+ "theme",
2641
+ "target",
2642
+ "png",
2643
+ "sha256",
2644
+ "sceneSha256",
2645
+ "styleSha256",
2646
+ "width",
2647
+ "height",
2648
+ "dpr",
2649
+ "networkDependent",
2650
+ "renderer",
2651
+ "receipt",
2652
+ "warnings"
2653
+ ];
2654
+ var maximumReviewDataNodes = 4096;
2655
+ var maximumReviewDataDepth = 32;
2656
+ function snapshotReviewInputs(leftInput, rightInput) {
2657
+ const left = inspectReviewInput(leftInput, "left");
2658
+ const right = inspectReviewInput(rightInput, "right");
2659
+ validateTileflowVisualReviewPngByteLengths(left.pngByteLength, right.pngByteLength);
2660
+ return [snapshotInspectedReviewInput(left, "left"), snapshotInspectedReviewInput(right, "right")];
2661
+ }
2662
+ function inspectReviewInput(input, role) {
2663
+ const wrapper = readPlainDataRecord(input, `${role} review input`, ["capture", "definition"]);
2664
+ const capture = readPlainDataRecord(wrapper.capture, `${role} capture`, reviewCaptureKeys);
2665
+ const definition = clonePlainDataValue(
2666
+ wrapper.definition,
2667
+ `${role} definition`
2668
+ );
2669
+ const renderer = clonePlainDataValue(
2670
+ capture.renderer,
2671
+ `${role} capture.renderer`
2672
+ );
2673
+ const receipt = clonePlainDataValue(
2674
+ capture.receipt,
2675
+ `${role} capture.receipt`
2676
+ );
2677
+ const warnings = clonePlainDataValue(capture.warnings, `${role} capture.warnings`);
2678
+ const png = requireReviewPng(capture.png, role);
2679
+ return {
2680
+ capture: {
2681
+ scene: capture.scene,
2682
+ map: capture.map,
2683
+ theme: capture.theme,
2684
+ target: capture.target,
2685
+ png: png.source,
2686
+ sha256: capture.sha256,
2687
+ sceneSha256: capture.sceneSha256,
2688
+ styleSha256: capture.styleSha256,
2689
+ width: capture.width,
2690
+ height: capture.height,
2691
+ dpr: capture.dpr,
2692
+ networkDependent: capture.networkDependent,
2693
+ renderer,
2694
+ receipt,
2695
+ warnings
2696
+ },
2697
+ pngByteLength: png.byteLength,
2698
+ definition
2699
+ };
2700
+ }
2701
+ function snapshotInspectedReviewInput(input, role) {
2702
+ let png;
2703
+ try {
2704
+ png = new Uint8Array(input.pngByteLength);
2705
+ png.set(input.capture.png);
2706
+ } catch (error) {
2707
+ throw invalidReview(`The ${role} PNG could not be snapshotted safely.`, error);
2708
+ }
2709
+ if (png.byteLength !== input.pngByteLength) {
2710
+ throw invalidReview(`The ${role} PNG changed while it was being snapshotted.`);
2711
+ }
2712
+ return { capture: { ...input.capture, png }, definition: input.definition };
2713
+ }
2714
+ function validateTileflowVisualReviewPngByteLengths(leftByteLength, rightByteLength) {
2715
+ for (const [role, byteLength] of [
2716
+ ["left", leftByteLength],
2717
+ ["right", rightByteLength]
2718
+ ]) {
2719
+ if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
2720
+ throw invalidReview(`The ${role} PNG has an invalid byte length.`);
2721
+ }
2722
+ if (byteLength > tileflowVisualArtifactLimits.maximumPngBytes) {
2723
+ throw invalidReview(`The ${role} PNG exceeds the visual artifact byte limit.`);
2724
+ }
2725
+ }
2726
+ if (leftByteLength + rightByteLength > tileflowVisualReviewLimits.maximumAggregatePngBytes) {
2727
+ throw invalidReview("The review PNGs exceed the aggregate visual artifact byte limit.");
2728
+ }
2729
+ }
2730
+ function requireReviewPng(value, role) {
2731
+ if (value !== null && typeof value === "object" && nodeUtilTypes2.isProxy(value)) {
2732
+ throw invalidReview(`The ${role} PNG must not use an executable proxy.`);
2733
+ }
2734
+ const isBuffer = Buffer.isBuffer(value);
2735
+ if (!(value instanceof Uint8Array) || !isBuffer && Object.getPrototypeOf(value) !== Uint8Array.prototype) {
2736
+ throw invalidReview(`The ${role} PNG must be a plain Uint8Array or Buffer.`);
2737
+ }
2738
+ if (typeof SharedArrayBuffer !== "undefined" && value.buffer instanceof SharedArrayBuffer) {
2739
+ throw invalidReview(`The ${role} PNG must not use shared mutable memory.`);
2740
+ }
2741
+ let byteLength;
2742
+ try {
2743
+ byteLength = value.byteLength;
2744
+ } catch (error) {
2745
+ throw invalidReview(`The ${role} PNG has an invalid byte buffer.`, error);
2746
+ }
2747
+ return { source: value, byteLength };
2748
+ }
2749
+ function validateReviewOptions(options) {
2750
+ const record = readPlainDataRecord(options, "review options");
2751
+ const keys = Object.keys(record);
2752
+ if (keys.some((key) => key !== "includeDiff" && key !== "region")) {
2753
+ throw invalidReview("The review options contain unsupported fields.");
2754
+ }
2755
+ if (record.includeDiff !== void 0 && typeof record.includeDiff !== "boolean") {
2756
+ throw invalidReview("The review includeDiff option must be boolean.");
2757
+ }
2758
+ if (record.region === void 0) {
2759
+ return { includeDiff: record.includeDiff !== false };
2760
+ }
2761
+ const region = readPlainDataRecord(record.region, "review region", ["x", "y", "width", "height"]);
2762
+ return {
2763
+ includeDiff: record.includeDiff !== false,
2764
+ region: {
2765
+ x: region.x,
2766
+ y: region.y,
2767
+ width: region.width,
2768
+ height: region.height
2769
+ }
2770
+ };
2771
+ }
2772
+ function clonePlainDataValue(value, path, state = {
2773
+ ancestors: /* @__PURE__ */ new WeakSet(),
2774
+ nodes: 0
2775
+ }, depth = 0) {
2776
+ if (value === null || value === void 0 || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2777
+ return value;
2778
+ }
2779
+ if (typeof value !== "object") {
2780
+ throw invalidReview(`The ${path} value must contain only plain data.`);
2781
+ }
2782
+ if (nodeUtilTypes2.isProxy(value)) {
2783
+ throw invalidReview(`The ${path} value must not use an executable proxy.`);
2784
+ }
2785
+ if (depth > maximumReviewDataDepth || ++state.nodes > maximumReviewDataNodes) {
2786
+ throw invalidReview(`The ${path} value exceeds the bounded plain-data shape.`);
2787
+ }
2788
+ if (state.ancestors.has(value)) {
2789
+ throw invalidReview(`The ${path} value must not contain a cycle.`);
2790
+ }
2791
+ state.ancestors.add(value);
2792
+ try {
2793
+ if (Array.isArray(value)) {
2794
+ if (Object.getPrototypeOf(value) !== Array.prototype || value.length > maximumReviewDataNodes) {
2795
+ throw invalidReview(`The ${path} value must use a plain bounded array.`);
2796
+ }
2797
+ const descriptors = Object.getOwnPropertyDescriptors(value);
2798
+ const indices = Object.keys(descriptors).filter((key) => key !== "length");
2799
+ if (indices.length !== value.length) {
2800
+ throw invalidReview(`The ${path} value must use a dense plain array.`);
2801
+ }
2802
+ const clone = new Array(value.length);
2803
+ for (const key of Reflect.ownKeys(descriptors)) {
2804
+ if (key === "length") continue;
2805
+ if (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(key)) {
2806
+ throw invalidReview(`The ${path} value contains an unsupported array field.`);
2807
+ }
2808
+ const index = Number(key);
2809
+ if (!Number.isSafeInteger(index) || index >= value.length) {
2810
+ throw invalidReview(`The ${path} value contains an out-of-bounds array field.`);
2811
+ }
2812
+ const descriptor = descriptors[key];
2813
+ if (!("value" in descriptor) || !descriptor.enumerable) {
2814
+ throw invalidReview(`The ${path} value contains an executable array field.`);
2815
+ }
2816
+ clone[index] = clonePlainDataValue(descriptor.value, `${path}[${key}]`, state, depth + 1);
2817
+ }
2818
+ return clone;
2819
+ }
2820
+ const record = readPlainDataRecord(value, path);
2821
+ state.nodes += Object.keys(record).length;
2822
+ if (state.nodes > maximumReviewDataNodes) {
2823
+ throw invalidReview(`The ${path} value exceeds the bounded plain-data shape.`);
2824
+ }
2825
+ return Object.fromEntries(
2826
+ Object.entries(record).map(([key, child]) => [
2827
+ key,
2828
+ clonePlainDataValue(child, `${path}.${key}`, state, depth + 1)
2829
+ ])
2830
+ );
2831
+ } finally {
2832
+ state.ancestors.delete(value);
2833
+ }
2834
+ }
2835
+ function readPlainDataRecord(value, path, expectedKeys) {
2836
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2837
+ throw invalidReview(`The ${path} must be a plain data object.`);
2838
+ }
2839
+ if (nodeUtilTypes2.isProxy(value)) {
2840
+ throw invalidReview(`The ${path} must not use an executable proxy.`);
2841
+ }
2842
+ const prototype = Object.getPrototypeOf(value);
2843
+ if (prototype !== Object.prototype && prototype !== null) {
2844
+ throw invalidReview(`The ${path} must not use a custom prototype.`);
2845
+ }
2846
+ const descriptors = Object.getOwnPropertyDescriptors(value);
2847
+ const entries = [];
2848
+ for (const key of Reflect.ownKeys(descriptors)) {
2849
+ if (typeof key !== "string") {
2850
+ throw invalidReview(`The ${path} contains an unsupported symbol field.`);
2851
+ }
2852
+ const descriptor = descriptors[key];
2853
+ if (!("value" in descriptor) || !descriptor.enumerable) {
2854
+ throw invalidReview(`The ${path} contains an accessor or non-enumerable field.`);
2855
+ }
2856
+ entries.push([key, descriptor.value]);
2857
+ }
2858
+ if (expectedKeys) {
2859
+ const actual = entries.map(([key]) => key).sort(compareCodeUnits5);
2860
+ const wanted = [...expectedKeys].sort(compareCodeUnits5);
2861
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
2862
+ throw invalidReview(`The ${path} contains missing or unsupported fields.`);
2863
+ }
2864
+ }
2865
+ return Object.fromEntries(entries);
2866
+ }
2867
+ async function validateReviewCapture(input, role) {
2868
+ let normalized;
2869
+ try {
2870
+ normalized = normalizeTileflowCaptureScene2(input.definition);
2871
+ } catch (error) {
2872
+ throw invalidReview(`The ${role} review definition is invalid.`, error);
2873
+ }
2874
+ const definition = normalized;
2875
+ if (!sameCanonicalValue(input.definition, definition)) {
2876
+ throw invalidReview(`The ${role} review definition must be the exact normalized shape.`);
2877
+ }
2878
+ let parsedReceipt;
2879
+ try {
2880
+ parsedReceipt = validateTileflowCaptureReceipt(input.capture.receipt);
2881
+ } catch (error) {
2882
+ throw invalidReview(`The ${role} capture receipt is invalid.`, error);
2883
+ }
2884
+ const receipt = parsedReceipt;
2885
+ if (receipt.schemaVersion !== 4) {
2886
+ throw invalidReview(`The ${role} review capture requires a schema-v4 receipt.`);
2887
+ }
2888
+ if (definition.map !== receipt.scene.map || definition.theme !== receipt.scene.theme || definition.target.kind !== receipt.scene.target) {
2889
+ throw invalidReview(`The ${role} review identity does not match its capture receipt.`);
2890
+ }
2891
+ const capturesViewport = definition.target.kind === "map" || definition.target.frame === "viewport";
2892
+ if (definition.viewport.dpr !== receipt.image.dpr || capturesViewport && (definition.viewport.width !== receipt.image.cssWidth || definition.viewport.height !== receipt.image.cssHeight)) {
2893
+ throw invalidReview(`The ${role} review frame does not match its captured dimensions.`);
2894
+ }
2895
+ if (input.capture.scene !== receipt.scene.name || input.capture.map !== receipt.scene.map || input.capture.theme !== receipt.scene.theme || input.capture.target !== receipt.scene.target || input.capture.sha256 !== receipt.image.sha256 || input.capture.sceneSha256 !== receipt.scene.sha256 || input.capture.styleSha256 !== receipt.style.sha256 || input.capture.width !== receipt.image.cssWidth || input.capture.height !== receipt.image.cssHeight || input.capture.dpr !== receipt.image.dpr || input.capture.networkDependent !== receipt.networkDependent || !sameCanonicalValue(input.capture.renderer, receipt.renderer)) {
2896
+ throw invalidReview(`The ${role} capture metadata does not match its receipt.`);
2897
+ }
2898
+ const definitionSha256 = await sha256Hex3(serializeCanonicalJson3(definition));
2899
+ if (definitionSha256 !== receipt.scene.sha256) {
2900
+ throw invalidReview(`The ${role} review definition does not match its capture receipt.`);
2901
+ }
2902
+ return {
2903
+ definition,
2904
+ comparableFrame: reviewFrameIdentity(definition),
2905
+ png: input.capture.png,
2906
+ receipt
2907
+ };
2908
+ }
2909
+ function reviewSide(input, palette) {
2910
+ return {
2911
+ scene: input.receipt.scene,
2912
+ style: input.receipt.style,
2913
+ frame: input.comparableFrame,
2914
+ image: imageIdentity2(input.receipt),
2915
+ runtime: runtimeIdentity2(input.receipt),
2916
+ data: input.receipt.data,
2917
+ verification: input.receipt.verification,
2918
+ palette
2919
+ };
2920
+ }
2921
+ function reviewFrameIdentity(definition) {
2922
+ return {
2923
+ camera: definition.camera,
2924
+ viewport: definition.viewport,
2925
+ target: definition.target.kind === "map" ? { kind: "map" } : { kind: "application", frame: definition.target.frame }
2926
+ };
2927
+ }
2928
+ function imageIdentity2(receipt) {
2929
+ return {
2930
+ sha256: receipt.image.sha256,
2931
+ cssWidth: receipt.image.cssWidth,
2932
+ cssHeight: receipt.image.cssHeight,
2933
+ physicalWidth: receipt.image.physicalWidth,
2934
+ physicalHeight: receipt.image.physicalHeight,
2935
+ dpr: receipt.image.dpr,
2936
+ networkDependent: receipt.networkDependent
2937
+ };
2938
+ }
2939
+ function runtimeIdentity2(receipt) {
2940
+ return { renderer: receipt.renderer, platform: receipt.platform };
2941
+ }
2942
+ function reviewStatus(input) {
2943
+ if (!input.frameMatch) return "frame-mismatch";
2944
+ if (!input.dimensionsMatch) return "dimensions-mismatch";
2945
+ if (!input.rendererMatch) return "runtime-mismatch";
2946
+ if (!input.dataMatch) return "data-mismatch";
2947
+ return "comparable";
2948
+ }
2949
+ function reviewWarnings(status, left, right) {
2950
+ const warnings = [];
2951
+ if (left.networkDependent) {
2952
+ warnings.push("The left capture used remote resources and may not be globally byte-stable.");
2953
+ }
2954
+ if (right.networkDependent) {
2955
+ warnings.push("The right capture used remote resources and may not be globally byte-stable.");
2956
+ }
2957
+ if (left.verification.style !== "rendered" || right.verification.style !== "rendered") {
2958
+ warnings.push(
2959
+ "At least one application capture has expected-but-unverified style and data identity."
2960
+ );
2961
+ }
2962
+ if (status === "frame-mismatch") {
2963
+ warnings.push("The review frames differ; pixel metrics and contextual diff were not computed.");
2964
+ } else if (status === "dimensions-mismatch") {
2965
+ warnings.push(
2966
+ "The rendered dimensions differ; pixel metrics and contextual diff were not computed."
2967
+ );
2968
+ } else if (status === "runtime-mismatch") {
2969
+ warnings.push(
2970
+ "The renderer runtimes differ; pixel metrics and contextual diff were not computed."
2971
+ );
2972
+ } else if (status === "data-mismatch") {
2973
+ warnings.push(
2974
+ "The exact data identities differ; pixel metrics and contextual diff were not computed."
2975
+ );
2976
+ } else {
2977
+ warnings.push(
2978
+ "Pixel metrics and the contextual diff describe two deliberate renders; they do not approve either image or create a baseline."
2979
+ );
2980
+ }
2981
+ return [...new Set(warnings)].sort(compareCodeUnits5);
2982
+ }
2983
+ function sameCanonicalValue(left, right) {
2984
+ return serializeCanonicalJson3(left) === serializeCanonicalJson3(right);
2985
+ }
2986
+ function compareCodeUnits5(left, right) {
2987
+ return left < right ? -1 : left > right ? 1 : 0;
2988
+ }
2989
+ function invalidReview(message, cause) {
2990
+ return new TileflowVisualReviewError(message, cause === void 0 ? void 0 : { cause });
2991
+ }
2072
2992
  export {
2073
2993
  TileflowCaptureBrowserManager,
2074
2994
  TileflowCaptureError,
2995
+ TileflowCaptureWorldSession,
2996
+ TileflowVisualReviewError,
2075
2997
  analyzeTileflowCaptureReference,
2076
2998
  captureErrorMessage,
2077
2999
  captureTileflowScenes,
2078
3000
  compareTileflowCaptureToBaseline,
3001
+ compareTileflowCapturesForReview,
2079
3002
  createTileflowCaptureBrowserEnvironment,
2080
3003
  createTileflowCaptureReceipt,
2081
3004
  createTileflowCaptureRendererIdentity,
2082
3005
  createTileflowCaptureSession,
2083
3006
  createTileflowVisualComparisonDocument,
2084
3007
  createTileflowVisualReferenceAnalysisDocument,
3008
+ createTileflowVisualReviewDocument,
2085
3009
  launchTileflowCaptureBrowser,
2086
3010
  parseTileflowCaptureReceipt,
2087
3011
  resolveTileflowApplicationUrl,
3012
+ resolveTileflowCaptureWorldTileJson,
2088
3013
  serializeTileflowCaptureReceipt,
2089
3014
  serializeTileflowVisualComparison,
2090
3015
  setupTileflowCaptureBrowser,
@@ -2095,7 +3020,11 @@ export {
2095
3020
  tileflowSyntheticAssetOrigin,
2096
3021
  tileflowVisualArtifactLimits,
2097
3022
  tileflowVisualComparisonSchemaVersion,
3023
+ tileflowVisualEdgeThreshold,
2098
3024
  tileflowVisualPerceptualThreshold,
3025
+ tileflowVisualReviewLimits,
3026
+ tileflowVisualReviewSchemaVersion,
3027
+ tileflowWorldCurrentTileJsonUrl,
2099
3028
  validateTileflowCaptureReceipt,
2100
3029
  validateTileflowVisualReferencePng
2101
3030
  };