@tileflow/capture 0.1.0-alpha.16 → 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/GENERATED_OUTPUT_LICENSE.md +21 -0
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +76 -12
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/TRADEMARKS.md +10 -0
- package/dist/chunk-BSTB4FN6.js +625 -0
- package/dist/index.d.ts +160 -74
- package/dist/index.js +1222 -276
- package/dist/receipt-Q7wQ68tG.d.ts +160 -0
- package/dist/receipt.d.ts +2 -0
- package/dist/receipt.js +16 -0
- package/package.json +33 -10
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
|
-
|
|
299
|
+
parseTileflowMap,
|
|
300
|
+
resolveThemeSelection,
|
|
301
|
+
serializeCanonicalJson,
|
|
350
302
|
sha256Hex,
|
|
351
|
-
tileflowCaptureSceneNameSchema
|
|
303
|
+
tileflowCaptureSceneNameSchema
|
|
352
304
|
} from "@tileflow/core";
|
|
353
305
|
import {
|
|
354
|
-
assertValidTileflowStyle as assertValidTileflowStyle2,
|
|
355
306
|
createTileflowBuildArtifacts,
|
|
356
|
-
defaultTileflowConfigPath
|
|
357
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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,162 +1289,222 @@ function compareCodeUnits2(left, right) {
|
|
|
1199
1289
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
1200
1290
|
}
|
|
1201
1291
|
|
|
1202
|
-
// src/
|
|
1292
|
+
// src/world.ts
|
|
1203
1293
|
import {
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2,
|
|
1207
|
-
tileflowCaptureSceneNameSchema
|
|
1294
|
+
isTileflowWorldReleaseId,
|
|
1295
|
+
tileflowWorldTileJsonUrl
|
|
1208
1296
|
} from "@tileflow/core";
|
|
1209
|
-
var
|
|
1210
|
-
var
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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 =
|
|
1248
|
-
|
|
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
|
|
1251
|
-
}
|
|
1252
|
-
const
|
|
1253
|
-
if (
|
|
1254
|
-
throw
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
"
|
|
1263
|
-
"
|
|
1264
|
-
"
|
|
1265
|
-
"
|
|
1266
|
-
"
|
|
1267
|
-
"
|
|
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"
|
|
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"
|
|
1291
1395
|
]);
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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.");
|
|
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
|
+
);
|
|
1317
1405
|
}
|
|
1318
|
-
const
|
|
1319
|
-
requireExactKeys(source, ["tilesetVersion"]);
|
|
1406
|
+
const tileTemplate = validateExactTileTemplate(tileJson.tiles[0], releaseId, descriptorSha256);
|
|
1320
1407
|
return {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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")
|
|
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
|
|
1346
1415
|
},
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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)
|
|
1353
1474
|
};
|
|
1354
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
|
+
}
|
|
1355
1505
|
function requireRecord(value, field) {
|
|
1356
1506
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1357
|
-
throw
|
|
1507
|
+
throw worldResolutionError(`${field} must be an object.`);
|
|
1358
1508
|
}
|
|
1359
1509
|
return value;
|
|
1360
1510
|
}
|
|
@@ -1362,55 +1512,39 @@ function requireExactKeys(value, expected) {
|
|
|
1362
1512
|
const actual = Object.keys(value).sort();
|
|
1363
1513
|
const wanted = [...expected].sort();
|
|
1364
1514
|
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
1365
|
-
throw
|
|
1515
|
+
throw worldResolutionError("Tileflow World identity contains missing or unsupported fields.");
|
|
1366
1516
|
}
|
|
1367
1517
|
}
|
|
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
1518
|
function requireHash(value, field) {
|
|
1379
|
-
if (typeof value !== "string" || !/^[a-f0-9]{64}
|
|
1380
|
-
throw
|
|
1519
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
|
|
1520
|
+
throw worldResolutionError(`Tileflow World has an invalid ${field}.`);
|
|
1381
1521
|
}
|
|
1382
1522
|
return value;
|
|
1383
1523
|
}
|
|
1384
|
-
function
|
|
1385
|
-
if (
|
|
1386
|
-
throw
|
|
1524
|
+
function requireWorldReleaseId(value) {
|
|
1525
|
+
if (!isTileflowWorldReleaseId(value)) {
|
|
1526
|
+
throw worldResolutionError("Tileflow World has an invalid releaseId.");
|
|
1387
1527
|
}
|
|
1388
1528
|
return value;
|
|
1389
1529
|
}
|
|
1390
1530
|
function requirePositiveInteger(value, field) {
|
|
1391
1531
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
1392
|
-
throw
|
|
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}.`);
|
|
1532
|
+
throw worldResolutionError(`The compiled style has an invalid ${field}.`);
|
|
1399
1533
|
}
|
|
1400
1534
|
return value;
|
|
1401
1535
|
}
|
|
1402
|
-
function
|
|
1403
|
-
if (value
|
|
1404
|
-
|
|
1405
|
-
throw invalidReceipt("The baseline receipt has an invalid source.tilesetVersion.");
|
|
1536
|
+
function requireLiteral(value, expected, field) {
|
|
1537
|
+
if (value !== expected) {
|
|
1538
|
+
throw worldResolutionError(`The compiled style has an invalid ${field}.`);
|
|
1406
1539
|
}
|
|
1407
|
-
return
|
|
1540
|
+
return expected;
|
|
1408
1541
|
}
|
|
1409
|
-
function
|
|
1410
|
-
|
|
1542
|
+
function isLoopbackHostname2(value) {
|
|
1543
|
+
const hostname = value.toLowerCase().replace(/^\[|\]$/gu, "");
|
|
1544
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
1411
1545
|
}
|
|
1412
|
-
function
|
|
1413
|
-
return new TileflowCaptureError("
|
|
1546
|
+
function worldResolutionError(message, cause) {
|
|
1547
|
+
return new TileflowCaptureError("WORLD_RESOLUTION_FAILED", message, { cause });
|
|
1414
1548
|
}
|
|
1415
1549
|
|
|
1416
1550
|
// src/capture.ts
|
|
@@ -1429,6 +1563,7 @@ function createTileflowCaptureSession(options = {}) {
|
|
|
1429
1563
|
var TileflowCaptureSessionImpl = class {
|
|
1430
1564
|
#options;
|
|
1431
1565
|
#browserManager;
|
|
1566
|
+
#worldSession;
|
|
1432
1567
|
#closed = false;
|
|
1433
1568
|
constructor(options = {}) {
|
|
1434
1569
|
this.#options = options;
|
|
@@ -1437,6 +1572,7 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1437
1572
|
onInstallProgress: options.onBrowserInstallProgress,
|
|
1438
1573
|
signal: options.signal
|
|
1439
1574
|
});
|
|
1575
|
+
this.#worldSession = new TileflowCaptureWorldSession();
|
|
1440
1576
|
}
|
|
1441
1577
|
async capture(scenes, signal) {
|
|
1442
1578
|
this.#assertOpen();
|
|
@@ -1468,7 +1604,8 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1468
1604
|
async captureArtifacts(artifacts, requestedScenes, signal) {
|
|
1469
1605
|
this.#assertOpen();
|
|
1470
1606
|
const sceneNames = selectTileflowCaptureSceneNames(artifacts, requestedScenes);
|
|
1471
|
-
const
|
|
1607
|
+
const validatedStyles = /* @__PURE__ */ new Set();
|
|
1608
|
+
const preparedStyles = /* @__PURE__ */ new Map();
|
|
1472
1609
|
for (const sceneName of sceneNames) {
|
|
1473
1610
|
const scene = this.#resolveScene(artifacts, sceneName);
|
|
1474
1611
|
if (scene.target.kind === "application" && !this.#options.appOrigin && !this.#options.appUrl) {
|
|
@@ -1477,20 +1614,23 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1477
1614
|
`Scene "${sceneName}" requires --app-origin or TILEFLOW_APP_ORIGIN.`
|
|
1478
1615
|
);
|
|
1479
1616
|
}
|
|
1480
|
-
const style =
|
|
1617
|
+
const style = artifacts.styles[scene.map]?.[scene.theme];
|
|
1481
1618
|
if (!style) {
|
|
1482
1619
|
throw new TileflowCaptureError(
|
|
1483
1620
|
"SCENE_NOT_FOUND",
|
|
1484
|
-
`Scene "${sceneName}" references unavailable map "${scene.map}".`
|
|
1621
|
+
`Scene "${sceneName}" references unavailable theme "${scene.theme}" on map "${scene.map}".`
|
|
1485
1622
|
);
|
|
1486
1623
|
}
|
|
1487
|
-
|
|
1624
|
+
const styleKey = `${scene.map}/${scene.theme}`;
|
|
1625
|
+
if (!validatedStyles.has(styleKey)) {
|
|
1626
|
+
const prepared = await this.#worldSession.prepare(style, signal);
|
|
1488
1627
|
try {
|
|
1489
|
-
assertValidTileflowStyle2(style,
|
|
1628
|
+
assertValidTileflowStyle2(prepared.style, styleKey);
|
|
1490
1629
|
} catch (error) {
|
|
1491
1630
|
throwCaptureStyleValidationError(error);
|
|
1492
1631
|
}
|
|
1493
|
-
|
|
1632
|
+
preparedStyles.set(styleKey, prepared);
|
|
1633
|
+
validatedStyles.add(styleKey);
|
|
1494
1634
|
}
|
|
1495
1635
|
}
|
|
1496
1636
|
throwIfAborted2(signal);
|
|
@@ -1500,17 +1640,23 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1500
1640
|
for (const sceneName of sceneNames) {
|
|
1501
1641
|
throwIfAborted2(signal);
|
|
1502
1642
|
const scene = this.#resolveScene(artifacts, sceneName);
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1643
|
+
const prepared = preparedStyles.get(`${scene.map}/${scene.theme}`);
|
|
1644
|
+
const style = prepared?.style;
|
|
1645
|
+
if (!style || !prepared) {
|
|
1505
1646
|
throw new TileflowCaptureError(
|
|
1506
1647
|
"SCENE_NOT_FOUND",
|
|
1507
1648
|
`Scene "${sceneName}" references unavailable map "${scene.map}".`
|
|
1508
1649
|
);
|
|
1509
1650
|
}
|
|
1651
|
+
const colorScheme = resolveThemeSelection(
|
|
1652
|
+
parseTileflowMap(artifacts.project.maps[scene.map]),
|
|
1653
|
+
scene.theme
|
|
1654
|
+
).theme.colorScheme;
|
|
1510
1655
|
const rendered = scene.target.kind === "application" ? await captureApplicationTileflowScene({
|
|
1511
1656
|
appOrigin: this.#options.appOrigin,
|
|
1512
1657
|
appUrl: this.#options.appUrl,
|
|
1513
1658
|
browser,
|
|
1659
|
+
colorScheme,
|
|
1514
1660
|
scene: { ...scene, target: scene.target },
|
|
1515
1661
|
signal,
|
|
1516
1662
|
timeoutMs: this.#options.timeoutMs
|
|
@@ -1524,27 +1670,29 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1524
1670
|
});
|
|
1525
1671
|
const [sha256, sceneSha256, styleSha256] = await Promise.all([
|
|
1526
1672
|
sha256Hex(rendered.png),
|
|
1527
|
-
sha256Hex(
|
|
1528
|
-
sha256Hex(
|
|
1673
|
+
sha256Hex(serializeCanonicalJson(scene)),
|
|
1674
|
+
sha256Hex(serializeCanonicalJson(style))
|
|
1529
1675
|
]);
|
|
1530
1676
|
const target = scene.target.kind;
|
|
1531
1677
|
const receipt = createTileflowCaptureReceipt({
|
|
1678
|
+
data: prepared.data,
|
|
1532
1679
|
dpr: scene.viewport.dpr,
|
|
1533
1680
|
height: rendered.height,
|
|
1534
1681
|
map: scene.map,
|
|
1682
|
+
theme: scene.theme,
|
|
1535
1683
|
networkDependent: rendered.networkDependent,
|
|
1536
1684
|
pngSha256: sha256,
|
|
1537
1685
|
renderer,
|
|
1538
1686
|
scene: sceneName,
|
|
1539
1687
|
sceneSha256,
|
|
1540
1688
|
styleSha256,
|
|
1541
|
-
...typeof style.metadata?.["tileflow:tilesetVersion"] === "string" ? { sourceVersion: style.metadata["tileflow:tilesetVersion"] } : {},
|
|
1542
1689
|
target,
|
|
1543
1690
|
width: rendered.width
|
|
1544
1691
|
});
|
|
1545
1692
|
captures.push({
|
|
1546
1693
|
scene: sceneName,
|
|
1547
1694
|
map: scene.map,
|
|
1695
|
+
theme: scene.theme,
|
|
1548
1696
|
target,
|
|
1549
1697
|
png: rendered.png,
|
|
1550
1698
|
sha256,
|
|
@@ -1613,7 +1761,7 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1613
1761
|
}
|
|
1614
1762
|
};
|
|
1615
1763
|
function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
|
|
1616
|
-
const availableScenes = Object.keys(artifacts.project.scenes ?? {}).filter((sceneName) =>
|
|
1764
|
+
const availableScenes = Object.keys(artifacts.project.scenes ?? {}).filter((sceneName) => tileflowCaptureSceneNameSchema.safeParse(sceneName).success).sort(compareCodeUnits3);
|
|
1617
1765
|
const sceneNames = [...new Set(requestedScenes)].sort(compareCodeUnits3);
|
|
1618
1766
|
if (sceneNames.length === 0) {
|
|
1619
1767
|
throw new TileflowCaptureError(
|
|
@@ -1622,7 +1770,7 @@ function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
|
|
|
1622
1770
|
);
|
|
1623
1771
|
}
|
|
1624
1772
|
for (const sceneName of sceneNames) {
|
|
1625
|
-
if (!
|
|
1773
|
+
if (!tileflowCaptureSceneNameSchema.safeParse(sceneName).success) {
|
|
1626
1774
|
throw new TileflowCaptureError(
|
|
1627
1775
|
"SCENE_NOT_FOUND",
|
|
1628
1776
|
"Expected a portable Tileflow capture scene name."
|
|
@@ -1643,7 +1791,26 @@ function normalizeScene(artifacts, sceneName) {
|
|
|
1643
1791
|
if (!scene) {
|
|
1644
1792
|
throw new TileflowCaptureError("SCENE_NOT_FOUND", `Unknown Tileflow scene "${sceneName}".`);
|
|
1645
1793
|
}
|
|
1646
|
-
|
|
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 };
|
|
1647
1814
|
}
|
|
1648
1815
|
function throwIfAborted2(signal) {
|
|
1649
1816
|
if (signal?.aborted) {
|
|
@@ -1665,33 +1832,119 @@ function throwCaptureStyleValidationError(error) {
|
|
|
1665
1832
|
|
|
1666
1833
|
// src/visual.ts
|
|
1667
1834
|
import pixelmatch from "pixelmatch";
|
|
1835
|
+
import { types as nodeUtilTypes } from "util";
|
|
1668
1836
|
import { PNG } from "pngjs";
|
|
1669
1837
|
import {
|
|
1670
1838
|
compareCodeUnits as compareCodeUnits4,
|
|
1671
|
-
serializeCanonicalJson as
|
|
1839
|
+
serializeCanonicalJson as serializeCanonicalJson2,
|
|
1672
1840
|
sha256Hex as sha256Hex2,
|
|
1673
|
-
tileflowCaptureSceneLimits as
|
|
1841
|
+
tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2
|
|
1674
1842
|
} from "@tileflow/core";
|
|
1675
1843
|
var tileflowVisualComparisonSchemaVersion = 1;
|
|
1676
1844
|
var tileflowVisualAnalysisSchemaVersion = 1;
|
|
1677
1845
|
var tileflowVisualPerceptualThreshold = 0.1;
|
|
1846
|
+
var tileflowVisualEdgeThreshold = 0.05;
|
|
1678
1847
|
var tileflowVisualArtifactLimits = Object.freeze({
|
|
1679
1848
|
maximumPngBytes: 256 * 1024 * 1024,
|
|
1680
1849
|
maximumPaletteColors: 16
|
|
1681
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
|
+
}
|
|
1682
1926
|
function validateTileflowVisualReferencePng(png) {
|
|
1683
1927
|
const image = decodePng(png, "reference");
|
|
1684
1928
|
return { width: image.width, height: image.height };
|
|
1685
1929
|
}
|
|
1686
|
-
async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
1930
|
+
async function analyzeTileflowCaptureReference(actualCapture, referencePng, options = {}) {
|
|
1931
|
+
const requestedRegion = snapshotTileflowVisualReferenceAnalysisOptions(options);
|
|
1687
1932
|
const receipt = validateTileflowCaptureReceipt(actualCapture.receipt);
|
|
1933
|
+
if (receipt.schemaVersion !== 4) {
|
|
1934
|
+
throw invalidBaseline("Visual reference analysis requires a schema-v4 capture receipt.");
|
|
1935
|
+
}
|
|
1688
1936
|
const actualImage = await validatePngAgainstReceipt(actualCapture.png, receipt, "actual");
|
|
1689
1937
|
const referenceImage = decodePng(referencePng, "reference");
|
|
1690
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
|
+
}
|
|
1691
1943
|
const common = {
|
|
1692
1944
|
schemaVersion: tileflowVisualAnalysisSchemaVersion,
|
|
1693
1945
|
scene: receipt.scene.name,
|
|
1694
1946
|
map: receipt.scene.map,
|
|
1947
|
+
theme: receipt.scene.theme,
|
|
1695
1948
|
target: receipt.scene.target,
|
|
1696
1949
|
dimensionsMatch,
|
|
1697
1950
|
reference: {
|
|
@@ -1713,6 +1966,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
|
1713
1966
|
exact: null,
|
|
1714
1967
|
perceptual: null,
|
|
1715
1968
|
meanAbsoluteChannelDifference: null,
|
|
1969
|
+
appearance: null,
|
|
1716
1970
|
warnings: [
|
|
1717
1971
|
"The reference and actual PNG dimensions differ; pixel metrics and diff were not computed.",
|
|
1718
1972
|
...networkWarnings(void 0, receipt)
|
|
@@ -1720,6 +1974,11 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
|
1720
1974
|
};
|
|
1721
1975
|
}
|
|
1722
1976
|
const totalPixels = actualImage.width * actualImage.height;
|
|
1977
|
+
const region = resolveTileflowVisualRegion(
|
|
1978
|
+
requestedRegion,
|
|
1979
|
+
actualImage.width,
|
|
1980
|
+
actualImage.height
|
|
1981
|
+
);
|
|
1723
1982
|
const exactChangedPixels = countExactChangedPixels(referenceImage.data, actualImage.data);
|
|
1724
1983
|
const diffData = new Uint8Array(totalPixels * 4);
|
|
1725
1984
|
const perceptualChangedPixels = pixelmatch(
|
|
@@ -1748,6 +2007,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
|
1748
2007
|
referenceImage.data,
|
|
1749
2008
|
actualImage.data
|
|
1750
2009
|
),
|
|
2010
|
+
appearance: createTileflowVisualReferenceAppearance(referenceImage, actualImage, region),
|
|
1751
2011
|
diffPng: encodeDiffPng(diffData, actualImage.width, actualImage.height),
|
|
1752
2012
|
warnings: networkWarnings(void 0, receipt)
|
|
1753
2013
|
};
|
|
@@ -1835,8 +2095,8 @@ async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
|
|
|
1835
2095
|
baselineReceipt.image.physicalHeight,
|
|
1836
2096
|
actualReceipt.image.physicalHeight
|
|
1837
2097
|
);
|
|
1838
|
-
const boundedWidth = diffWidth * diffHeight <=
|
|
1839
|
-
const boundedHeight = diffWidth * diffHeight <=
|
|
2098
|
+
const boundedWidth = diffWidth * diffHeight <= tileflowCaptureSceneLimits2.maximumPhysicalPixels ? diffWidth : actualReceipt.image.physicalWidth;
|
|
2099
|
+
const boundedHeight = diffWidth * diffHeight <= tileflowCaptureSceneLimits2.maximumPhysicalPixels ? diffHeight : actualReceipt.image.physicalHeight;
|
|
1840
2100
|
return {
|
|
1841
2101
|
...common,
|
|
1842
2102
|
status: "changed",
|
|
@@ -1901,7 +2161,7 @@ function createTileflowVisualComparisonDocument(comparison) {
|
|
|
1901
2161
|
return document;
|
|
1902
2162
|
}
|
|
1903
2163
|
function serializeTileflowVisualComparison(comparison) {
|
|
1904
|
-
return `${
|
|
2164
|
+
return `${serializeCanonicalJson2(createTileflowVisualComparisonDocument(comparison))}
|
|
1905
2165
|
`;
|
|
1906
2166
|
}
|
|
1907
2167
|
function imageIdentity(receipt) {
|
|
@@ -1947,7 +2207,7 @@ function preflightPng(png, role) {
|
|
|
1947
2207
|
`The ${role} PNG is interlaced and cannot be decoded within fixed bounds.`
|
|
1948
2208
|
);
|
|
1949
2209
|
}
|
|
1950
|
-
if (dimensions.width >
|
|
2210
|
+
if (dimensions.width > tileflowCaptureSceneLimits2.viewport.maximum * 2 || dimensions.height > tileflowCaptureSceneLimits2.viewport.maximum * 2 || dimensions.width * dimensions.height > tileflowCaptureSceneLimits2.maximumPhysicalPixels) {
|
|
1951
2211
|
throw invalidBaseline(`The ${role} PNG exceeds the visual pixel limit.`);
|
|
1952
2212
|
}
|
|
1953
2213
|
return dimensions;
|
|
@@ -2030,11 +2290,260 @@ function meanAbsoluteChannelDifference(left, right) {
|
|
|
2030
2290
|
}
|
|
2031
2291
|
return Math.round(sum / left.byteLength * 1e6) / 1e6;
|
|
2032
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
|
+
}
|
|
2033
2504
|
function pixelMetric(changedPixels, totalPixels) {
|
|
2034
2505
|
return { changedPixels, totalPixels, ratio: changedPixels / totalPixels };
|
|
2035
2506
|
}
|
|
2036
2507
|
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.
|
|
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);
|
|
2038
2547
|
}
|
|
2039
2548
|
function sameRuntimeIdentity(baseline, actual) {
|
|
2040
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;
|
|
@@ -2052,22 +2561,455 @@ function networkWarnings(baseline, actual) {
|
|
|
2052
2561
|
function invalidBaseline(message, cause) {
|
|
2053
2562
|
return new TileflowCaptureError("BASELINE_INVALID", message, { cause });
|
|
2054
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
|
+
}
|
|
2055
2992
|
export {
|
|
2056
2993
|
TileflowCaptureBrowserManager,
|
|
2057
2994
|
TileflowCaptureError,
|
|
2995
|
+
TileflowCaptureWorldSession,
|
|
2996
|
+
TileflowVisualReviewError,
|
|
2058
2997
|
analyzeTileflowCaptureReference,
|
|
2059
2998
|
captureErrorMessage,
|
|
2060
2999
|
captureTileflowScenes,
|
|
2061
3000
|
compareTileflowCaptureToBaseline,
|
|
3001
|
+
compareTileflowCapturesForReview,
|
|
2062
3002
|
createTileflowCaptureBrowserEnvironment,
|
|
2063
3003
|
createTileflowCaptureReceipt,
|
|
2064
3004
|
createTileflowCaptureRendererIdentity,
|
|
2065
3005
|
createTileflowCaptureSession,
|
|
2066
3006
|
createTileflowVisualComparisonDocument,
|
|
2067
3007
|
createTileflowVisualReferenceAnalysisDocument,
|
|
3008
|
+
createTileflowVisualReviewDocument,
|
|
2068
3009
|
launchTileflowCaptureBrowser,
|
|
2069
3010
|
parseTileflowCaptureReceipt,
|
|
2070
3011
|
resolveTileflowApplicationUrl,
|
|
3012
|
+
resolveTileflowCaptureWorldTileJson,
|
|
2071
3013
|
serializeTileflowCaptureReceipt,
|
|
2072
3014
|
serializeTileflowVisualComparison,
|
|
2073
3015
|
setupTileflowCaptureBrowser,
|
|
@@ -2078,7 +3020,11 @@ export {
|
|
|
2078
3020
|
tileflowSyntheticAssetOrigin,
|
|
2079
3021
|
tileflowVisualArtifactLimits,
|
|
2080
3022
|
tileflowVisualComparisonSchemaVersion,
|
|
3023
|
+
tileflowVisualEdgeThreshold,
|
|
2081
3024
|
tileflowVisualPerceptualThreshold,
|
|
3025
|
+
tileflowVisualReviewLimits,
|
|
3026
|
+
tileflowVisualReviewSchemaVersion,
|
|
3027
|
+
tileflowWorldCurrentTileJsonUrl,
|
|
2082
3028
|
validateTileflowCaptureReceipt,
|
|
2083
3029
|
validateTileflowVisualReferencePng
|
|
2084
3030
|
};
|