@tileflow/capture 0.1.0-alpha.17 → 0.1.0-alpha.19
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 -72
- package/dist/index.js +1329 -310
- package/dist/receipt-Q7wQ68tG.d.ts +160 -0
- package/dist/receipt.d.ts +2 -0
- package/dist/receipt.js +16 -0
- package/package.json +32 -9
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,39 @@ import { resolve } from "path";
|
|
|
346
296
|
import {
|
|
347
297
|
compareCodeUnits as compareCodeUnits3,
|
|
348
298
|
normalizeTileflowCaptureScene,
|
|
349
|
-
|
|
299
|
+
parseResolvedTileflowMap,
|
|
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";
|
|
316
|
+
import { open } from "fs/promises";
|
|
364
317
|
import { createRequire as createRequire2 } from "module";
|
|
365
|
-
import {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
} from "@tileflow/dev";
|
|
318
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
319
|
+
import { getTileflowStyleFontFaces } from "@tileflow/core/runtime";
|
|
320
|
+
import { assertValidTileflowStyle, TileflowStyleValidationError } from "@tileflow/dev/validation";
|
|
369
321
|
var tileflowSyntheticAssetOrigin = "https://tileflow.local.invalid";
|
|
370
322
|
var require3 = createRequire2(import.meta.url);
|
|
371
323
|
var maplibreJsPath = require3.resolve("maplibre-gl/dist/maplibre-gl.js");
|
|
372
324
|
var maplibreCssPath = require3.resolve("maplibre-gl/dist/maplibre-gl.css");
|
|
325
|
+
var tileflowCorePackagePath = require3.resolve("@tileflow/core/package.json");
|
|
326
|
+
var tileflowBrowserModule = `${readFileSync2(
|
|
327
|
+
join2(dirname2(tileflowCorePackagePath), "dist", "browser.js"),
|
|
328
|
+
"utf8"
|
|
329
|
+
)}
|
|
330
|
+
globalThis.__tileflowRegisterContourProtocol = registerTileflowContourProtocol;
|
|
331
|
+
globalThis.__tileflowRegisterPmtilesProtocol = registerTileflowPmtilesProtocol;`;
|
|
373
332
|
async function captureStandaloneTileflowScene(input) {
|
|
374
333
|
if (input.scene.target.kind !== "map") {
|
|
375
334
|
throw new TileflowCaptureError(
|
|
@@ -430,6 +389,10 @@ async function captureStandaloneTileflowScene(input) {
|
|
|
430
389
|
}, timeoutMs);
|
|
431
390
|
timeout.unref?.();
|
|
432
391
|
const assets = new Map(input.assets.map((asset) => [asset.fileName, asset]));
|
|
392
|
+
const localTilesets = new Map(
|
|
393
|
+
(input.localTilesets ?? []).map((tileset) => [tileset.fileName, tileset])
|
|
394
|
+
);
|
|
395
|
+
const fontFaces = getTileflowStyleFontFaces(input.style);
|
|
433
396
|
const remoteOrigins = /* @__PURE__ */ new Set();
|
|
434
397
|
let syntheticAssetFailure;
|
|
435
398
|
const resourceFailures = [];
|
|
@@ -446,14 +409,24 @@ async function captureStandaloneTileflowScene(input) {
|
|
|
446
409
|
const url = new URL(route.request().url());
|
|
447
410
|
if (url.origin === tileflowSyntheticAssetOrigin) {
|
|
448
411
|
const fileName = decodeURIComponent(url.pathname.slice(1));
|
|
412
|
+
const localTileset = url.search === "" ? localTilesets.get(fileName) : void 0;
|
|
413
|
+
if (localTileset) {
|
|
414
|
+
const response = await localTilesetCaptureResponse(
|
|
415
|
+
route.request().headers()["range"],
|
|
416
|
+
localTileset
|
|
417
|
+
);
|
|
418
|
+
await route.fulfill(response);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
449
421
|
const asset = url.search === "" ? assets.get(fileName) : void 0;
|
|
450
422
|
if (!asset) {
|
|
451
423
|
syntheticAssetFailure = fileName || "unknown";
|
|
452
424
|
await route.abort("blockedbyclient");
|
|
453
425
|
return;
|
|
454
426
|
}
|
|
427
|
+
const source = asset.source;
|
|
455
428
|
await route.fulfill({
|
|
456
|
-
body: typeof
|
|
429
|
+
body: typeof source === "string" ? source : Buffer.from(source),
|
|
457
430
|
contentType: asset.contentType,
|
|
458
431
|
status: 200
|
|
459
432
|
});
|
|
@@ -487,6 +460,7 @@ async function captureStandaloneTileflowScene(input) {
|
|
|
487
460
|
await page.setContent(renderHtml(input.scene.viewport), { waitUntil: "domcontentloaded" });
|
|
488
461
|
await page.addStyleTag({ path: maplibreCssPath });
|
|
489
462
|
await page.addScriptTag({ path: maplibreJsPath });
|
|
463
|
+
await page.addScriptTag({ content: tileflowBrowserModule, type: "module" });
|
|
490
464
|
await page.addScriptTag({ content: renderMapInPageScript });
|
|
491
465
|
phase = "map-load";
|
|
492
466
|
const loadResult = await page.evaluate(
|
|
@@ -495,6 +469,7 @@ async function captureStandaloneTileflowScene(input) {
|
|
|
495
469
|
return renderWindow.__tileflowCaptureLoad(renderInput);
|
|
496
470
|
},
|
|
497
471
|
{
|
|
472
|
+
fontFaces,
|
|
498
473
|
mapEventTimeoutMs: Math.max(1, timeoutMs - 1e3),
|
|
499
474
|
style: input.style
|
|
500
475
|
}
|
|
@@ -584,6 +559,58 @@ async function captureStandaloneTileflowScene(input) {
|
|
|
584
559
|
await context?.close().catch(() => void 0);
|
|
585
560
|
}
|
|
586
561
|
}
|
|
562
|
+
async function localTilesetCaptureResponse(rangeHeader, file) {
|
|
563
|
+
const match = rangeHeader?.match(/^bytes=(\d+)-(\d*)$/u);
|
|
564
|
+
if (!match) {
|
|
565
|
+
return {
|
|
566
|
+
body: Buffer.alloc(0),
|
|
567
|
+
headers: {
|
|
568
|
+
"Accept-Ranges": "bytes",
|
|
569
|
+
"Content-Range": `bytes */${file.byteLength}`,
|
|
570
|
+
ETag: file.etag
|
|
571
|
+
},
|
|
572
|
+
status: 416
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const start = Number(match[1]);
|
|
576
|
+
const requestedEnd = match[2] ? Number(match[2]) : file.byteLength - 1;
|
|
577
|
+
const end = Math.min(requestedEnd, file.byteLength - 1);
|
|
578
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || start >= file.byteLength || requestedEnd < start || end - start + 1 > 16 * 1024 * 1024) {
|
|
579
|
+
return {
|
|
580
|
+
body: Buffer.alloc(0),
|
|
581
|
+
headers: {
|
|
582
|
+
"Accept-Ranges": "bytes",
|
|
583
|
+
"Content-Range": `bytes */${file.byteLength}`,
|
|
584
|
+
ETag: file.etag
|
|
585
|
+
},
|
|
586
|
+
status: 416
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const length = end - start + 1;
|
|
590
|
+
const handle = await open(file.sourcePath, "r");
|
|
591
|
+
try {
|
|
592
|
+
const body = Buffer.alloc(length);
|
|
593
|
+
let read = 0;
|
|
594
|
+
while (read < length) {
|
|
595
|
+
const result = await handle.read(body, read, length - read, start + read);
|
|
596
|
+
if (result.bytesRead === 0) throw new Error("Local PMTiles snapshot ended during capture.");
|
|
597
|
+
read += result.bytesRead;
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
body,
|
|
601
|
+
contentType: "application/vnd.pmtiles",
|
|
602
|
+
headers: {
|
|
603
|
+
"Accept-Ranges": "bytes",
|
|
604
|
+
"Content-Length": String(length),
|
|
605
|
+
"Content-Range": `bytes ${start}-${end}/${file.byteLength}`,
|
|
606
|
+
ETag: file.etag
|
|
607
|
+
},
|
|
608
|
+
status: 206
|
|
609
|
+
};
|
|
610
|
+
} finally {
|
|
611
|
+
await handle.close();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
587
614
|
function assertPhaseSucceeded(result, phase, syntheticAssetFailure, resourceFailures, pageFailed) {
|
|
588
615
|
if (syntheticAssetFailure) {
|
|
589
616
|
throw new TileflowCaptureError(
|
|
@@ -599,7 +626,8 @@ function assertPhaseSucceeded(result, phase, syntheticAssetFailure, resourceFail
|
|
|
599
626
|
});
|
|
600
627
|
}
|
|
601
628
|
if (result.status === "failed" || pageFailed) {
|
|
602
|
-
|
|
629
|
+
const message = result.status === "failed" && result.message ? `${mapFailureMessage(phase)} ${safeMapError(result.message)}` : mapFailureMessage(phase);
|
|
630
|
+
throw new TileflowCaptureError("MAP_LOAD_FAILED", message, {
|
|
603
631
|
details: { phase }
|
|
604
632
|
});
|
|
605
633
|
}
|
|
@@ -701,6 +729,9 @@ function timeoutMessage(phase) {
|
|
|
701
729
|
function mapFailureMessage(phase) {
|
|
702
730
|
return phase === "map-load" ? "MapLibre failed while loading the compiled style." : "MapLibre failed while waiting for the committed camera to become idle.";
|
|
703
731
|
}
|
|
732
|
+
function safeMapError(value) {
|
|
733
|
+
return value.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g, "resource").replace(/[^A-Za-z0-9 _.,:'"@+()[\]/=-]/g, "").trim().slice(0, 200);
|
|
734
|
+
}
|
|
704
735
|
function safeAssetName(value) {
|
|
705
736
|
return value.replace(/[^A-Za-z0-9._/-]/g, "").slice(0, 200) || "unknown";
|
|
706
737
|
}
|
|
@@ -755,6 +786,12 @@ function renderHtml(viewport) {
|
|
|
755
786
|
var renderMapInPageScript = String.raw`
|
|
756
787
|
window.__tileflowCaptureLoad = async function(input) {
|
|
757
788
|
try {
|
|
789
|
+
await loadTileflowFontFaces(input.fontFaces || []);
|
|
790
|
+
window.__tileflowRegisterContourProtocol?.({addProtocol: window.maplibregl.addProtocol});
|
|
791
|
+
window.__tileflowRegisterPmtilesProtocol?.({addProtocol: window.maplibregl.addProtocol});
|
|
792
|
+
window.__tileflowCaptureLoopbackSourceIds = Object.entries(input.style.sources || {})
|
|
793
|
+
.filter(([, source]) => isLoopbackSourceUrl(source?.url))
|
|
794
|
+
.map(([sourceId]) => sourceId);
|
|
758
795
|
const map = new window.maplibregl.Map({
|
|
759
796
|
attributionControl: false,
|
|
760
797
|
container: "map",
|
|
@@ -764,11 +801,59 @@ window.__tileflowCaptureLoad = async function(input) {
|
|
|
764
801
|
});
|
|
765
802
|
window.__tileflowMap = map;
|
|
766
803
|
return await waitForMapEvent(map, "load", input.mapEventTimeoutMs);
|
|
767
|
-
} catch {
|
|
768
|
-
return {status: "failed", reason: "error"};
|
|
804
|
+
} catch (error) {
|
|
805
|
+
return {status: "failed", reason: "error", message: String(error?.message || error || "")};
|
|
769
806
|
}
|
|
770
807
|
};
|
|
771
808
|
|
|
809
|
+
async function loadTileflowFontFaces(definitions) {
|
|
810
|
+
const maximumFontBytes = 1024 * 1024;
|
|
811
|
+
await Promise.all(definitions.map(async (definition) => {
|
|
812
|
+
const response = await fetch(definition.source, {
|
|
813
|
+
cache: "force-cache",
|
|
814
|
+
credentials: "same-origin",
|
|
815
|
+
redirect: "error"
|
|
816
|
+
});
|
|
817
|
+
if (!response.ok) throw new Error("Tileflow font failed: " + response.status);
|
|
818
|
+
const declaredLength = response.headers.get("content-length");
|
|
819
|
+
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maximumFontBytes) {
|
|
820
|
+
await response.body?.cancel().catch(() => undefined);
|
|
821
|
+
throw new Error("Tileflow font exceeds the maximum response size.");
|
|
822
|
+
}
|
|
823
|
+
if (!response.body) throw new Error("Tileflow font response is empty.");
|
|
824
|
+
const reader = response.body.getReader();
|
|
825
|
+
const chunks = [];
|
|
826
|
+
let byteLength = 0;
|
|
827
|
+
try {
|
|
828
|
+
while (true) {
|
|
829
|
+
const {done, value} = await reader.read();
|
|
830
|
+
if (done) break;
|
|
831
|
+
if (!value) continue;
|
|
832
|
+
byteLength += value.byteLength;
|
|
833
|
+
if (byteLength > maximumFontBytes) {
|
|
834
|
+
await reader.cancel().catch(() => undefined);
|
|
835
|
+
throw new Error("Tileflow font exceeds the maximum response size.");
|
|
836
|
+
}
|
|
837
|
+
chunks.push(value);
|
|
838
|
+
}
|
|
839
|
+
} finally {
|
|
840
|
+
reader.releaseLock();
|
|
841
|
+
}
|
|
842
|
+
const bytes = new Uint8Array(byteLength);
|
|
843
|
+
let offset = 0;
|
|
844
|
+
for (const chunk of chunks) {
|
|
845
|
+
bytes.set(chunk, offset);
|
|
846
|
+
offset += chunk.byteLength;
|
|
847
|
+
}
|
|
848
|
+
const face = new FontFace(definition.family, bytes.buffer, {
|
|
849
|
+
...(definition.style ? {style: definition.style} : {}),
|
|
850
|
+
...(definition.weight ? {weight: definition.weight} : {})
|
|
851
|
+
});
|
|
852
|
+
await face.load();
|
|
853
|
+
document.fonts.add(face);
|
|
854
|
+
}));
|
|
855
|
+
}
|
|
856
|
+
|
|
772
857
|
window.__tileflowCaptureIdle = async function(input) {
|
|
773
858
|
const map = window.__tileflowMap;
|
|
774
859
|
if (!map) return {status: "failed", reason: "error"};
|
|
@@ -820,9 +905,29 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
|
|
|
820
905
|
cleanup();
|
|
821
906
|
resolve({status: "ok"});
|
|
822
907
|
};
|
|
823
|
-
const onError = () => {
|
|
908
|
+
const onError = (errorEvent) => {
|
|
909
|
+
const message = String(errorEvent?.error?.message || errorEvent?.message || "");
|
|
910
|
+
const missingSourceLayer = message.match(
|
|
911
|
+
/^Source layer "(.+)" does not exist on source "(.+)" as specified by style layer ".+"\.$/
|
|
912
|
+
);
|
|
913
|
+
if (missingSourceLayer) {
|
|
914
|
+
const [, sourceLayer, sourceId] = missingSourceLayer;
|
|
915
|
+
const liveSource = mapInstance.getSource(sourceId);
|
|
916
|
+
const advertisedSourceLayers = liveSource?.vectorLayerIds;
|
|
917
|
+
const loopbackSource = window.__tileflowCaptureLoopbackSourceIds?.includes(sourceId);
|
|
918
|
+
// A layer advertised by TileJSON may legitimately be absent from one
|
|
919
|
+
// MVT. Local PMTiles development archives may also carry stale TileJSON
|
|
920
|
+
// metadata while their tile payload already contains the layer. Keep
|
|
921
|
+
// remote contracts strict, but let local visual captures verify pixels.
|
|
922
|
+
if (
|
|
923
|
+
loopbackSource ||
|
|
924
|
+
(Array.isArray(advertisedSourceLayers) && advertisedSourceLayers.includes(sourceLayer))
|
|
925
|
+
) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
824
929
|
cleanup();
|
|
825
|
-
resolve({status: "failed", reason: "error"});
|
|
930
|
+
resolve({status: "failed", reason: "error", message});
|
|
826
931
|
};
|
|
827
932
|
const cleanup = () => {
|
|
828
933
|
window.clearTimeout(timeout);
|
|
@@ -831,10 +936,24 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
|
|
|
831
936
|
};
|
|
832
937
|
|
|
833
938
|
mapInstance.once(event, onSuccess);
|
|
834
|
-
mapInstance.
|
|
939
|
+
mapInstance.on("error", onError);
|
|
835
940
|
});
|
|
836
941
|
}
|
|
837
942
|
|
|
943
|
+
function isLoopbackSourceUrl(value) {
|
|
944
|
+
if (typeof value !== "string") return false;
|
|
945
|
+
try {
|
|
946
|
+
const hostname = new URL(value).hostname.toLowerCase();
|
|
947
|
+
return (
|
|
948
|
+
hostname === "localhost" ||
|
|
949
|
+
hostname === "[::1]" ||
|
|
950
|
+
/^127(?:\.\d{1,3}){3}$/.test(hostname)
|
|
951
|
+
);
|
|
952
|
+
} catch {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
838
957
|
function nextFrame() {
|
|
839
958
|
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
|
|
840
959
|
}
|
|
@@ -886,7 +1005,7 @@ async function captureApplicationTileflowScene(input) {
|
|
|
886
1005
|
try {
|
|
887
1006
|
context = await input.browser.newContext({
|
|
888
1007
|
acceptDownloads: false,
|
|
889
|
-
colorScheme:
|
|
1008
|
+
colorScheme: input.colorScheme,
|
|
890
1009
|
deviceScaleFactor: input.scene.viewport.dpr,
|
|
891
1010
|
ignoreHTTPSErrors: false,
|
|
892
1011
|
javaScriptEnabled: true,
|
|
@@ -985,6 +1104,25 @@ async function captureApplicationTileflowScene(input) {
|
|
|
985
1104
|
);
|
|
986
1105
|
}
|
|
987
1106
|
if (targetCount === 0) {
|
|
1107
|
+
try {
|
|
1108
|
+
await locator.first().waitFor({
|
|
1109
|
+
state: "attached",
|
|
1110
|
+
// The implicit map/theme locator starts matching only after the framework has resolved
|
|
1111
|
+
// its manifest and concrete theme. Let the operation-wide timeout remain authoritative;
|
|
1112
|
+
// a separate two-second attachment budget made otherwise healthy cold starts fail early.
|
|
1113
|
+
timeout: timeoutMs
|
|
1114
|
+
});
|
|
1115
|
+
targetCount = await locator.count();
|
|
1116
|
+
} catch {
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (targetCount === 0) {
|
|
1120
|
+
if (applicationFailure || applicationOriginEscape) {
|
|
1121
|
+
throw new TileflowCaptureError(
|
|
1122
|
+
"APPLICATION_ERROR",
|
|
1123
|
+
"The application reported an error before its capture target became ready."
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
988
1126
|
throw new TileflowCaptureError(
|
|
989
1127
|
"APPLICATION_TARGET_NOT_FOUND",
|
|
990
1128
|
"The application page did not contain the requested Tileflow capture target."
|
|
@@ -1003,6 +1141,11 @@ async function captureApplicationTileflowScene(input) {
|
|
|
1003
1141
|
timeoutMs,
|
|
1004
1142
|
() => applicationFailure || applicationOriginEscape
|
|
1005
1143
|
);
|
|
1144
|
+
await assertApplicationTargetTheme(
|
|
1145
|
+
locator,
|
|
1146
|
+
input.scene.theme,
|
|
1147
|
+
input.scene.target.selector !== void 0
|
|
1148
|
+
);
|
|
1006
1149
|
if (applicationFailure || applicationOriginEscape) {
|
|
1007
1150
|
throw new TileflowCaptureError(
|
|
1008
1151
|
"APPLICATION_ERROR",
|
|
@@ -1022,6 +1165,11 @@ async function captureApplicationTileflowScene(input) {
|
|
|
1022
1165
|
input.scene.target.frame === "viewport" ? await page.screenshot({ animations: "disabled", type: "png" }) : await locator.screenshot({ animations: "disabled", type: "png" })
|
|
1023
1166
|
);
|
|
1024
1167
|
await assertApplicationTargetRemainsReady(locator, input.scene.target.selector !== void 0);
|
|
1168
|
+
await assertApplicationTargetTheme(
|
|
1169
|
+
locator,
|
|
1170
|
+
input.scene.theme,
|
|
1171
|
+
input.scene.target.selector !== void 0
|
|
1172
|
+
);
|
|
1025
1173
|
if (applicationFailure || applicationOriginEscape) {
|
|
1026
1174
|
throw new TileflowCaptureError(
|
|
1027
1175
|
"APPLICATION_ERROR",
|
|
@@ -1080,6 +1228,15 @@ async function assertApplicationTargetRemainsReady(locator, allowUnmarkedSelecto
|
|
|
1080
1228
|
"The application capture target stopped being ready while its screenshot was produced."
|
|
1081
1229
|
);
|
|
1082
1230
|
}
|
|
1231
|
+
async function assertApplicationTargetTheme(locator, expectedTheme, allowUnmarkedSelector) {
|
|
1232
|
+
if (!expectedTheme) return;
|
|
1233
|
+
const actualTheme = await locator.getAttribute("data-tileflow-theme");
|
|
1234
|
+
if (actualTheme === expectedTheme || actualTheme === null && allowUnmarkedSelector) return;
|
|
1235
|
+
throw new TileflowCaptureError(
|
|
1236
|
+
"APPLICATION_ERROR",
|
|
1237
|
+
`The application capture target resolved theme "${actualTheme ?? "(missing)"}" instead of "${expectedTheme}".`
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1083
1240
|
function webSocketHttpOrigin(url) {
|
|
1084
1241
|
const comparable = new URL(url);
|
|
1085
1242
|
comparable.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
@@ -1108,7 +1265,7 @@ function createTargetLocator(page, scene) {
|
|
|
1108
1265
|
return page.locator(`[data-tileflow-capture-id="${scene.target.captureId}"]`);
|
|
1109
1266
|
}
|
|
1110
1267
|
if (scene.target.selector) return page.locator(scene.target.selector);
|
|
1111
|
-
return page.locator(`[data-tileflow-map="${scene.map}"]`);
|
|
1268
|
+
return page.locator(`[data-tileflow-map="${scene.map}"][data-tileflow-theme="${scene.theme}"]`);
|
|
1112
1269
|
} catch (error) {
|
|
1113
1270
|
throw new TileflowCaptureError(
|
|
1114
1271
|
"APPLICATION_TARGET_NOT_FOUND",
|
|
@@ -1199,180 +1356,222 @@ function compareCodeUnits2(left, right) {
|
|
|
1199
1356
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
1200
1357
|
}
|
|
1201
1358
|
|
|
1202
|
-
// src/
|
|
1359
|
+
// src/world.ts
|
|
1203
1360
|
import {
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2,
|
|
1207
|
-
tileflowCaptureSceneNameSchema
|
|
1361
|
+
isTileflowWorldReleaseId,
|
|
1362
|
+
tileflowWorldTileJsonUrl
|
|
1208
1363
|
} 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
|
-
|
|
1364
|
+
var tileflowWorldCurrentTileJsonUrl = tileflowWorldTileJsonUrl;
|
|
1365
|
+
var maximumTileJsonBytes = 1024 * 1024;
|
|
1366
|
+
var TileflowCaptureWorldSession = class {
|
|
1367
|
+
#fetch;
|
|
1368
|
+
#lookupUrl;
|
|
1369
|
+
#resolution;
|
|
1370
|
+
constructor(fetchTileJson = globalThis.fetch) {
|
|
1371
|
+
this.#fetch = fetchTileJson;
|
|
1372
|
+
}
|
|
1373
|
+
async prepare(style, signal) {
|
|
1374
|
+
const metadata = requireDataMetadata(style);
|
|
1375
|
+
if (metadata.kind !== "tileflow-world") {
|
|
1376
|
+
return { data: validateVectorDataInput(metadata), style };
|
|
1377
|
+
}
|
|
1378
|
+
const source = requirePrimaryVectorSource(style);
|
|
1379
|
+
const lookupUrl = worldTileJsonLookupUrl(source, metadata);
|
|
1380
|
+
const resolution = await this.#resolveOne(lookupUrl, signal);
|
|
1381
|
+
const semantics = validateSemantics(metadata.semantics);
|
|
1382
|
+
const data = {
|
|
1383
|
+
...resolution.identity,
|
|
1384
|
+
kind: "tileflow-world",
|
|
1385
|
+
schema: requireLiteral(metadata.schema, "openmaptiles", "schema"),
|
|
1386
|
+
schemaVersion: requirePositiveInteger(metadata.schemaVersion, "schemaVersion"),
|
|
1387
|
+
...semantics ? { semantics } : {},
|
|
1388
|
+
sourceId: requireLiteral(metadata.sourceId, "tileflow", "sourceId")
|
|
1389
|
+
};
|
|
1390
|
+
const { tiles: _mutableTiles, url: _mutableUrl, ...sourceWithoutSelector } = source;
|
|
1391
|
+
return {
|
|
1392
|
+
data,
|
|
1393
|
+
style: {
|
|
1394
|
+
...style,
|
|
1395
|
+
sources: {
|
|
1396
|
+
...style.sources,
|
|
1397
|
+
tileflow: { ...sourceWithoutSelector, tiles: [...resolution.tiles] }
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
#resolveOne(lookupUrl, signal) {
|
|
1403
|
+
if (this.#lookupUrl !== void 0 && this.#lookupUrl !== lookupUrl) {
|
|
1404
|
+
throw worldResolutionError(
|
|
1405
|
+
"One capture session cannot mix different Tileflow World selectors."
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
this.#lookupUrl ??= lookupUrl;
|
|
1409
|
+
this.#resolution ??= resolveTileflowCaptureWorldTileJson(lookupUrl, {
|
|
1410
|
+
fetchTileJson: this.#fetch,
|
|
1411
|
+
signal
|
|
1412
|
+
});
|
|
1413
|
+
return this.#resolution;
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
async function resolveTileflowCaptureWorldTileJson(tileJsonUrl, options = {}) {
|
|
1417
|
+
const normalizedUrl = validateTileJsonUrl(tileJsonUrl);
|
|
1418
|
+
const expectedRelease = exactWorldReferenceFromSelector(normalizedUrl);
|
|
1419
|
+
let response;
|
|
1420
|
+
try {
|
|
1421
|
+
response = await (options.fetchTileJson ?? globalThis.fetch)(normalizedUrl, {
|
|
1422
|
+
cache: "no-store",
|
|
1423
|
+
redirect: "error",
|
|
1424
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1425
|
+
});
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
if (options.signal?.aborted) {
|
|
1428
|
+
throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
|
|
1429
|
+
}
|
|
1430
|
+
throw worldResolutionError("Tileflow World TileJSON could not be loaded.", error);
|
|
1431
|
+
}
|
|
1432
|
+
if (!response.ok) {
|
|
1433
|
+
throw worldResolutionError(`Tileflow World TileJSON returned HTTP ${String(response.status)}.`);
|
|
1243
1434
|
}
|
|
1244
|
-
let value;
|
|
1245
1435
|
let text;
|
|
1436
|
+
let input;
|
|
1246
1437
|
try {
|
|
1247
|
-
text =
|
|
1248
|
-
|
|
1438
|
+
text = await response.text();
|
|
1439
|
+
if (new TextEncoder().encode(text).byteLength > maximumTileJsonBytes) {
|
|
1440
|
+
throw new TypeError("TileJSON exceeds the supported byte limit");
|
|
1441
|
+
}
|
|
1442
|
+
input = JSON.parse(text);
|
|
1249
1443
|
} 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"
|
|
1291
|
-
]);
|
|
1292
|
-
const cssWidth = requirePositiveFinite(image.cssWidth, "image.cssWidth");
|
|
1293
|
-
const cssHeight = requirePositiveFinite(image.cssHeight, "image.cssHeight");
|
|
1294
|
-
const physicalWidth = requirePositiveInteger(image.physicalWidth, "image.physicalWidth");
|
|
1295
|
-
const physicalHeight = requirePositiveInteger(image.physicalHeight, "image.physicalHeight");
|
|
1296
|
-
if (image.dpr !== 1 && image.dpr !== 2) {
|
|
1297
|
-
throw invalidReceipt("The baseline receipt has an invalid image DPR.");
|
|
1298
|
-
}
|
|
1299
|
-
if (cssWidth * image.dpr !== physicalWidth || cssHeight * image.dpr !== physicalHeight) {
|
|
1300
|
-
throw invalidReceipt("The baseline receipt image dimensions are inconsistent.");
|
|
1301
|
-
}
|
|
1302
|
-
if (physicalWidth > tileflowCaptureSceneLimits2.viewport.maximum * 2 || physicalHeight > tileflowCaptureSceneLimits2.viewport.maximum * 2 || physicalWidth * physicalHeight > tileflowCaptureSceneLimits2.maximumPhysicalPixels) {
|
|
1303
|
-
throw invalidReceipt("The baseline receipt image exceeds the supported pixel limit.");
|
|
1304
|
-
}
|
|
1305
|
-
const renderer = requireRecord(receipt.renderer, "renderer");
|
|
1306
|
-
requireExactKeys(renderer, [
|
|
1307
|
-
"tileflow",
|
|
1308
|
-
"maplibre",
|
|
1309
|
-
"playwright",
|
|
1310
|
-
"chromiumRevision",
|
|
1311
|
-
"chromiumVersion"
|
|
1444
|
+
throw worldResolutionError("Tileflow World TileJSON is not bounded valid JSON.", error);
|
|
1445
|
+
}
|
|
1446
|
+
const tileJson = requireRecord(input, "TileJSON");
|
|
1447
|
+
if (tileJson.tilejson !== "3.0.0") {
|
|
1448
|
+
throw worldResolutionError("Tileflow World requires TileJSON 3.0.0.");
|
|
1449
|
+
}
|
|
1450
|
+
if (!Array.isArray(tileJson.tiles) || tileJson.tiles.length !== 1) {
|
|
1451
|
+
throw worldResolutionError("Tileflow World TileJSON must contain one exact tile template.");
|
|
1452
|
+
}
|
|
1453
|
+
const tileflow = requireRecord(tileJson.tileflow, "TileJSON tileflow");
|
|
1454
|
+
const world = requireRecord(tileflow.world, "TileJSON tileflow.world");
|
|
1455
|
+
requireExactKeys(world, [
|
|
1456
|
+
"archiveSha256",
|
|
1457
|
+
"contractSha256",
|
|
1458
|
+
"dataContractSha256",
|
|
1459
|
+
"descriptorSha256",
|
|
1460
|
+
"product",
|
|
1461
|
+
"releaseId"
|
|
1312
1462
|
]);
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
"schema",
|
|
1323
|
-
"schemaVersion",
|
|
1324
|
-
"sourceId"
|
|
1325
|
-
]);
|
|
1326
|
-
if (data.kind !== "tileflow-world" && data.kind !== "vector-tiles") {
|
|
1327
|
-
throw invalidReceipt("The baseline receipt has an invalid data.kind.");
|
|
1328
|
-
}
|
|
1329
|
-
if (data.schema !== "openmaptiles" || data.sourceId !== "tileflow") {
|
|
1330
|
-
throw invalidReceipt("The baseline receipt has an unsupported data contract.");
|
|
1463
|
+
if (world.product !== "world-v1") {
|
|
1464
|
+
throw worldResolutionError("Tileflow World TileJSON has an unsupported product.");
|
|
1465
|
+
}
|
|
1466
|
+
const releaseId = requireWorldReleaseId(world.releaseId);
|
|
1467
|
+
const descriptorSha256 = requireHash(world.descriptorSha256, "descriptorSha256");
|
|
1468
|
+
if (expectedRelease && (expectedRelease.releaseId !== releaseId || expectedRelease.descriptorSha256 !== descriptorSha256)) {
|
|
1469
|
+
throw worldResolutionError(
|
|
1470
|
+
"Tileflow World TileJSON conflicts with the requested exact release."
|
|
1471
|
+
);
|
|
1331
1472
|
}
|
|
1473
|
+
const tileTemplate = validateExactTileTemplate(tileJson.tiles[0], releaseId, descriptorSha256);
|
|
1332
1474
|
return {
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1475
|
+
identity: {
|
|
1476
|
+
archiveSha256: requireHash(world.archiveSha256, "archiveSha256"),
|
|
1477
|
+
contractSha256: requireHash(world.contractSha256, "contractSha256"),
|
|
1478
|
+
dataContractSha256: requireHash(world.dataContractSha256, "dataContractSha256"),
|
|
1479
|
+
descriptorSha256,
|
|
1480
|
+
product: "world-v1",
|
|
1481
|
+
releaseId
|
|
1339
1482
|
},
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
sha256: requireHash(image.sha256, "image.sha256"),
|
|
1343
|
-
cssWidth,
|
|
1344
|
-
cssHeight,
|
|
1345
|
-
physicalWidth,
|
|
1346
|
-
physicalHeight,
|
|
1347
|
-
dpr: image.dpr
|
|
1348
|
-
},
|
|
1349
|
-
renderer: {
|
|
1350
|
-
tileflow: requireBoundedString(renderer.tileflow, "renderer.tileflow"),
|
|
1351
|
-
maplibre: requireBoundedString(renderer.maplibre, "renderer.maplibre"),
|
|
1352
|
-
playwright: requireBoundedString(renderer.playwright, "renderer.playwright"),
|
|
1353
|
-
chromiumRevision: requireBoundedString(
|
|
1354
|
-
renderer.chromiumRevision,
|
|
1355
|
-
"renderer.chromiumRevision"
|
|
1356
|
-
),
|
|
1357
|
-
chromiumVersion: requireBoundedString(renderer.chromiumVersion, "renderer.chromiumVersion")
|
|
1358
|
-
},
|
|
1359
|
-
platform: {
|
|
1360
|
-
os: requireBoundedString(platform.os, "platform.os"),
|
|
1361
|
-
architecture: requireBoundedString(platform.architecture, "platform.architecture")
|
|
1362
|
-
},
|
|
1363
|
-
data: {
|
|
1364
|
-
kind: data.kind,
|
|
1365
|
-
...data.revision === void 0 ? {} : { revision: requireSourceRevision(data.revision) },
|
|
1366
|
-
schema: data.schema,
|
|
1367
|
-
schemaVersion: requirePositiveInteger(data.schemaVersion, "data.schemaVersion"),
|
|
1368
|
-
sourceId: data.sourceId
|
|
1369
|
-
},
|
|
1370
|
-
networkDependent: receipt.networkDependent
|
|
1483
|
+
tileJsonUrl: normalizedUrl,
|
|
1484
|
+
tiles: [tileTemplate]
|
|
1371
1485
|
};
|
|
1372
1486
|
}
|
|
1487
|
+
function requireDataMetadata(style) {
|
|
1488
|
+
const data = style.metadata?.["tileflow:data"];
|
|
1489
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
1490
|
+
throw worldResolutionError("The compiled style has no valid Tileflow data identity.");
|
|
1491
|
+
}
|
|
1492
|
+
return data;
|
|
1493
|
+
}
|
|
1494
|
+
function validateVectorDataInput(data) {
|
|
1495
|
+
if (data.kind !== "vector-tiles") {
|
|
1496
|
+
throw worldResolutionError("The compiled style has an unsupported Tileflow data kind.");
|
|
1497
|
+
}
|
|
1498
|
+
return data;
|
|
1499
|
+
}
|
|
1500
|
+
function requirePrimaryVectorSource(style) {
|
|
1501
|
+
const source = style.sources.tileflow;
|
|
1502
|
+
if (!source || source.type !== "vector") {
|
|
1503
|
+
throw worldResolutionError("Tileflow World requires the primary vector source.");
|
|
1504
|
+
}
|
|
1505
|
+
return source;
|
|
1506
|
+
}
|
|
1507
|
+
function worldTileJsonLookupUrl(source, metadata) {
|
|
1508
|
+
if (typeof source.url === "string") return validateTileJsonUrl(source.url);
|
|
1509
|
+
if (typeof metadata.url === "string") return validateTileJsonUrl(metadata.url);
|
|
1510
|
+
throw worldResolutionError(
|
|
1511
|
+
"Tileflow World capture requires a current or exact TileJSON selector."
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
function validateTileJsonUrl(value) {
|
|
1515
|
+
if (value.length < 1 || value.length > 2048) {
|
|
1516
|
+
throw worldResolutionError("Tileflow World TileJSON URL has an invalid length.");
|
|
1517
|
+
}
|
|
1518
|
+
let url;
|
|
1519
|
+
try {
|
|
1520
|
+
url = new URL(value);
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
throw worldResolutionError("Tileflow World TileJSON URL is invalid.", error);
|
|
1523
|
+
}
|
|
1524
|
+
if (url.username || url.password || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname2(url.hostname))) {
|
|
1525
|
+
throw worldResolutionError("Tileflow World TileJSON URL is not an approved HTTP URL.");
|
|
1526
|
+
}
|
|
1527
|
+
return url.toString();
|
|
1528
|
+
}
|
|
1529
|
+
function exactWorldReferenceFromSelector(value) {
|
|
1530
|
+
const url = new URL(value);
|
|
1531
|
+
const releaseId = url.searchParams.get("worldReleaseId");
|
|
1532
|
+
const descriptorSha256 = url.searchParams.get("worldDescriptorSha256");
|
|
1533
|
+
if (releaseId === null !== (descriptorSha256 === null)) {
|
|
1534
|
+
throw worldResolutionError(
|
|
1535
|
+
"An exact Tileflow World selector requires releaseId and descriptorSha256 together."
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
return releaseId === null ? void 0 : {
|
|
1539
|
+
descriptorSha256: requireHash(descriptorSha256, "selector descriptorSha256"),
|
|
1540
|
+
releaseId: requireWorldReleaseId(releaseId)
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
function validateExactTileTemplate(value, releaseId, descriptorSha256) {
|
|
1544
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 4096) {
|
|
1545
|
+
throw worldResolutionError("Tileflow World has an invalid exact tile template.");
|
|
1546
|
+
}
|
|
1547
|
+
if (!value.includes("{z}") || !value.includes("{x}") || !value.includes("{y}")) {
|
|
1548
|
+
throw worldResolutionError("Tileflow World tile template is missing XYZ placeholders.");
|
|
1549
|
+
}
|
|
1550
|
+
let url;
|
|
1551
|
+
try {
|
|
1552
|
+
url = new URL(value.replace("{z}", "0").replace("{x}", "0").replace("{y}", "0"));
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
throw worldResolutionError("Tileflow World has an invalid exact tile template.", error);
|
|
1555
|
+
}
|
|
1556
|
+
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) {
|
|
1557
|
+
throw worldResolutionError(
|
|
1558
|
+
"Tileflow World TileJSON did not bind tiles to its exact release and descriptor."
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
return value;
|
|
1562
|
+
}
|
|
1563
|
+
function validateSemantics(value) {
|
|
1564
|
+
if (value === void 0) return void 0;
|
|
1565
|
+
const semantics = requireRecord(value, "data semantics");
|
|
1566
|
+
requireExactKeys(semantics, ["parkLayer"]);
|
|
1567
|
+
if (semantics.parkLayer !== "mixed" && semantics.parkLayer !== "protected-only") {
|
|
1568
|
+
throw worldResolutionError("The compiled style has invalid park semantics.");
|
|
1569
|
+
}
|
|
1570
|
+
return { parkLayer: semantics.parkLayer };
|
|
1571
|
+
}
|
|
1373
1572
|
function requireRecord(value, field) {
|
|
1374
1573
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1375
|
-
throw
|
|
1574
|
+
throw worldResolutionError(`${field} must be an object.`);
|
|
1376
1575
|
}
|
|
1377
1576
|
return value;
|
|
1378
1577
|
}
|
|
@@ -1380,54 +1579,39 @@ function requireExactKeys(value, expected) {
|
|
|
1380
1579
|
const actual = Object.keys(value).sort();
|
|
1381
1580
|
const wanted = [...expected].sort();
|
|
1382
1581
|
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
1383
|
-
throw
|
|
1582
|
+
throw worldResolutionError("Tileflow World identity contains missing or unsupported fields.");
|
|
1384
1583
|
}
|
|
1385
1584
|
}
|
|
1386
|
-
function requireIdentifier(value, field) {
|
|
1387
|
-
const result = tileflowCaptureIdSchema.safeParse(value);
|
|
1388
|
-
if (!result.success) throw invalidReceipt(`The baseline receipt has an invalid ${field}.`);
|
|
1389
|
-
return result.data;
|
|
1390
|
-
}
|
|
1391
|
-
function requireSceneName(value) {
|
|
1392
|
-
const result = tileflowCaptureSceneNameSchema.safeParse(value);
|
|
1393
|
-
if (!result.success) throw invalidReceipt("The baseline receipt has an invalid scene.name.");
|
|
1394
|
-
return result.data;
|
|
1395
|
-
}
|
|
1396
1585
|
function requireHash(value, field) {
|
|
1397
|
-
if (typeof value !== "string" || !/^[a-f0-9]{64}
|
|
1398
|
-
throw
|
|
1586
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
|
|
1587
|
+
throw worldResolutionError(`Tileflow World has an invalid ${field}.`);
|
|
1399
1588
|
}
|
|
1400
1589
|
return value;
|
|
1401
1590
|
}
|
|
1402
|
-
function
|
|
1403
|
-
if (
|
|
1404
|
-
throw
|
|
1591
|
+
function requireWorldReleaseId(value) {
|
|
1592
|
+
if (!isTileflowWorldReleaseId(value)) {
|
|
1593
|
+
throw worldResolutionError("Tileflow World has an invalid releaseId.");
|
|
1405
1594
|
}
|
|
1406
1595
|
return value;
|
|
1407
1596
|
}
|
|
1408
1597
|
function requirePositiveInteger(value, field) {
|
|
1409
1598
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
1410
|
-
throw
|
|
1599
|
+
throw worldResolutionError(`The compiled style has an invalid ${field}.`);
|
|
1411
1600
|
}
|
|
1412
1601
|
return value;
|
|
1413
1602
|
}
|
|
1414
|
-
function
|
|
1415
|
-
if (
|
|
1416
|
-
throw
|
|
1603
|
+
function requireLiteral(value, expected, field) {
|
|
1604
|
+
if (value !== expected) {
|
|
1605
|
+
throw worldResolutionError(`The compiled style has an invalid ${field}.`);
|
|
1417
1606
|
}
|
|
1418
|
-
return
|
|
1607
|
+
return expected;
|
|
1419
1608
|
}
|
|
1420
|
-
function
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
}
|
|
1424
|
-
return value;
|
|
1425
|
-
}
|
|
1426
|
-
function isSafeRuntimeIdentity(value) {
|
|
1427
|
-
return /^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(value);
|
|
1609
|
+
function isLoopbackHostname2(value) {
|
|
1610
|
+
const hostname = value.toLowerCase().replace(/^\[|\]$/gu, "");
|
|
1611
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
1428
1612
|
}
|
|
1429
|
-
function
|
|
1430
|
-
return new TileflowCaptureError("
|
|
1613
|
+
function worldResolutionError(message, cause) {
|
|
1614
|
+
return new TileflowCaptureError("WORLD_RESOLUTION_FAILED", message, { cause });
|
|
1431
1615
|
}
|
|
1432
1616
|
|
|
1433
1617
|
// src/capture.ts
|
|
@@ -1446,6 +1630,7 @@ function createTileflowCaptureSession(options = {}) {
|
|
|
1446
1630
|
var TileflowCaptureSessionImpl = class {
|
|
1447
1631
|
#options;
|
|
1448
1632
|
#browserManager;
|
|
1633
|
+
#worldSession;
|
|
1449
1634
|
#closed = false;
|
|
1450
1635
|
constructor(options = {}) {
|
|
1451
1636
|
this.#options = options;
|
|
@@ -1454,20 +1639,29 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1454
1639
|
onInstallProgress: options.onBrowserInstallProgress,
|
|
1455
1640
|
signal: options.signal
|
|
1456
1641
|
});
|
|
1642
|
+
this.#worldSession = new TileflowCaptureWorldSession();
|
|
1457
1643
|
}
|
|
1458
1644
|
async capture(scenes, signal) {
|
|
1459
1645
|
this.#assertOpen();
|
|
1460
1646
|
const artifacts = await this.#createArtifacts();
|
|
1461
|
-
|
|
1647
|
+
try {
|
|
1648
|
+
return await this.captureArtifacts(artifacts, scenes, signal ?? this.#options.signal);
|
|
1649
|
+
} finally {
|
|
1650
|
+
await disposeCaptureArtifacts(artifacts);
|
|
1651
|
+
}
|
|
1462
1652
|
}
|
|
1463
1653
|
async captureAll(signal) {
|
|
1464
1654
|
this.#assertOpen();
|
|
1465
1655
|
const artifacts = await this.#createArtifacts();
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1656
|
+
try {
|
|
1657
|
+
return await this.captureArtifacts(
|
|
1658
|
+
artifacts,
|
|
1659
|
+
Object.keys(artifacts.project.scenes ?? {}),
|
|
1660
|
+
signal ?? this.#options.signal
|
|
1661
|
+
);
|
|
1662
|
+
} finally {
|
|
1663
|
+
await disposeCaptureArtifacts(artifacts);
|
|
1664
|
+
}
|
|
1471
1665
|
}
|
|
1472
1666
|
async captureDefinitions(scenes, signal) {
|
|
1473
1667
|
this.#assertOpen();
|
|
@@ -1476,16 +1670,21 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1476
1670
|
...artifacts,
|
|
1477
1671
|
project: { ...artifacts.project, scenes }
|
|
1478
1672
|
};
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1673
|
+
try {
|
|
1674
|
+
return await this.captureArtifacts(
|
|
1675
|
+
withDefinitions,
|
|
1676
|
+
Object.keys(scenes),
|
|
1677
|
+
signal ?? this.#options.signal
|
|
1678
|
+
);
|
|
1679
|
+
} finally {
|
|
1680
|
+
await disposeCaptureArtifacts(artifacts);
|
|
1681
|
+
}
|
|
1484
1682
|
}
|
|
1485
1683
|
async captureArtifacts(artifacts, requestedScenes, signal) {
|
|
1486
1684
|
this.#assertOpen();
|
|
1487
1685
|
const sceneNames = selectTileflowCaptureSceneNames(artifacts, requestedScenes);
|
|
1488
|
-
const
|
|
1686
|
+
const validatedStyles = /* @__PURE__ */ new Set();
|
|
1687
|
+
const preparedStyles = /* @__PURE__ */ new Map();
|
|
1489
1688
|
for (const sceneName of sceneNames) {
|
|
1490
1689
|
const scene = this.#resolveScene(artifacts, sceneName);
|
|
1491
1690
|
if (scene.target.kind === "application" && !this.#options.appOrigin && !this.#options.appUrl) {
|
|
@@ -1494,20 +1693,23 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1494
1693
|
`Scene "${sceneName}" requires --app-origin or TILEFLOW_APP_ORIGIN.`
|
|
1495
1694
|
);
|
|
1496
1695
|
}
|
|
1497
|
-
const style =
|
|
1696
|
+
const style = artifacts.styles[scene.map]?.[scene.theme];
|
|
1498
1697
|
if (!style) {
|
|
1499
1698
|
throw new TileflowCaptureError(
|
|
1500
1699
|
"SCENE_NOT_FOUND",
|
|
1501
|
-
`Scene "${sceneName}" references unavailable map "${scene.map}".`
|
|
1700
|
+
`Scene "${sceneName}" references unavailable theme "${scene.theme}" on map "${scene.map}".`
|
|
1502
1701
|
);
|
|
1503
1702
|
}
|
|
1504
|
-
|
|
1703
|
+
const styleKey = `${scene.map}/${scene.theme}`;
|
|
1704
|
+
if (!validatedStyles.has(styleKey)) {
|
|
1705
|
+
const prepared = await this.#worldSession.prepare(style, signal);
|
|
1505
1706
|
try {
|
|
1506
|
-
assertValidTileflowStyle2(style,
|
|
1707
|
+
assertValidTileflowStyle2(prepared.style, styleKey);
|
|
1507
1708
|
} catch (error) {
|
|
1508
1709
|
throwCaptureStyleValidationError(error);
|
|
1509
1710
|
}
|
|
1510
|
-
|
|
1711
|
+
preparedStyles.set(styleKey, prepared);
|
|
1712
|
+
validatedStyles.add(styleKey);
|
|
1511
1713
|
}
|
|
1512
1714
|
}
|
|
1513
1715
|
throwIfAborted2(signal);
|
|
@@ -1517,23 +1719,30 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1517
1719
|
for (const sceneName of sceneNames) {
|
|
1518
1720
|
throwIfAborted2(signal);
|
|
1519
1721
|
const scene = this.#resolveScene(artifacts, sceneName);
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1722
|
+
const prepared = preparedStyles.get(`${scene.map}/${scene.theme}`);
|
|
1723
|
+
const style = prepared?.style;
|
|
1724
|
+
if (!style || !prepared) {
|
|
1522
1725
|
throw new TileflowCaptureError(
|
|
1523
1726
|
"SCENE_NOT_FOUND",
|
|
1524
1727
|
`Scene "${sceneName}" references unavailable map "${scene.map}".`
|
|
1525
1728
|
);
|
|
1526
1729
|
}
|
|
1730
|
+
const colorScheme = resolveThemeSelection(
|
|
1731
|
+
parseResolvedTileflowMap(artifacts.project.maps[scene.map]),
|
|
1732
|
+
scene.theme
|
|
1733
|
+
).theme.colorScheme;
|
|
1527
1734
|
const rendered = scene.target.kind === "application" ? await captureApplicationTileflowScene({
|
|
1528
1735
|
appOrigin: this.#options.appOrigin,
|
|
1529
1736
|
appUrl: this.#options.appUrl,
|
|
1530
1737
|
browser,
|
|
1738
|
+
colorScheme,
|
|
1531
1739
|
scene: { ...scene, target: scene.target },
|
|
1532
1740
|
signal,
|
|
1533
1741
|
timeoutMs: this.#options.timeoutMs
|
|
1534
1742
|
}) : await captureStandaloneTileflowScene({
|
|
1535
1743
|
assets: artifacts.assets,
|
|
1536
1744
|
browser,
|
|
1745
|
+
...artifacts.localTilesets ? { localTilesets: artifacts.localTilesets } : {},
|
|
1537
1746
|
scene,
|
|
1538
1747
|
signal,
|
|
1539
1748
|
style,
|
|
@@ -1541,15 +1750,16 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1541
1750
|
});
|
|
1542
1751
|
const [sha256, sceneSha256, styleSha256] = await Promise.all([
|
|
1543
1752
|
sha256Hex(rendered.png),
|
|
1544
|
-
sha256Hex(
|
|
1545
|
-
sha256Hex(
|
|
1753
|
+
sha256Hex(serializeCanonicalJson(scene)),
|
|
1754
|
+
sha256Hex(serializeCanonicalJson(style))
|
|
1546
1755
|
]);
|
|
1547
1756
|
const target = scene.target.kind;
|
|
1548
1757
|
const receipt = createTileflowCaptureReceipt({
|
|
1549
|
-
data:
|
|
1758
|
+
data: prepared.data,
|
|
1550
1759
|
dpr: scene.viewport.dpr,
|
|
1551
1760
|
height: rendered.height,
|
|
1552
1761
|
map: scene.map,
|
|
1762
|
+
theme: scene.theme,
|
|
1553
1763
|
networkDependent: rendered.networkDependent,
|
|
1554
1764
|
pngSha256: sha256,
|
|
1555
1765
|
renderer,
|
|
@@ -1562,6 +1772,7 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1562
1772
|
captures.push({
|
|
1563
1773
|
scene: sceneName,
|
|
1564
1774
|
map: scene.map,
|
|
1775
|
+
theme: scene.theme,
|
|
1565
1776
|
target,
|
|
1566
1777
|
png: rendered.png,
|
|
1567
1778
|
sha256,
|
|
@@ -1585,13 +1796,18 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1585
1796
|
this.#closed = true;
|
|
1586
1797
|
await this.#browserManager.close();
|
|
1587
1798
|
}
|
|
1588
|
-
#createArtifacts() {
|
|
1799
|
+
async #createArtifacts() {
|
|
1589
1800
|
const cwd = resolve(this.#options.cwd ?? process.cwd());
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1801
|
+
try {
|
|
1802
|
+
const artifacts = await createTileflowBuildArtifacts({
|
|
1803
|
+
assetBaseUrl: tileflowSyntheticAssetOrigin,
|
|
1804
|
+
config: this.#options.config ?? defaultTileflowConfigPath,
|
|
1805
|
+
cwd
|
|
1806
|
+
});
|
|
1807
|
+
return artifacts;
|
|
1808
|
+
} catch (error) {
|
|
1809
|
+
return throwCaptureStyleValidationError(error);
|
|
1810
|
+
}
|
|
1595
1811
|
}
|
|
1596
1812
|
#assertOpen() {
|
|
1597
1813
|
if (this.#closed) {
|
|
@@ -1629,8 +1845,13 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1629
1845
|
};
|
|
1630
1846
|
}
|
|
1631
1847
|
};
|
|
1848
|
+
async function disposeCaptureArtifacts(artifacts) {
|
|
1849
|
+
const dispose = artifacts.dispose;
|
|
1850
|
+
artifacts.dispose = void 0;
|
|
1851
|
+
await dispose?.();
|
|
1852
|
+
}
|
|
1632
1853
|
function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
|
|
1633
|
-
const availableScenes = Object.keys(artifacts.project.scenes ?? {}).filter((sceneName) =>
|
|
1854
|
+
const availableScenes = Object.keys(artifacts.project.scenes ?? {}).filter((sceneName) => tileflowCaptureSceneNameSchema.safeParse(sceneName).success).sort(compareCodeUnits3);
|
|
1634
1855
|
const sceneNames = [...new Set(requestedScenes)].sort(compareCodeUnits3);
|
|
1635
1856
|
if (sceneNames.length === 0) {
|
|
1636
1857
|
throw new TileflowCaptureError(
|
|
@@ -1639,7 +1860,7 @@ function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
|
|
|
1639
1860
|
);
|
|
1640
1861
|
}
|
|
1641
1862
|
for (const sceneName of sceneNames) {
|
|
1642
|
-
if (!
|
|
1863
|
+
if (!tileflowCaptureSceneNameSchema.safeParse(sceneName).success) {
|
|
1643
1864
|
throw new TileflowCaptureError(
|
|
1644
1865
|
"SCENE_NOT_FOUND",
|
|
1645
1866
|
"Expected a portable Tileflow capture scene name."
|
|
@@ -1660,7 +1881,26 @@ function normalizeScene(artifacts, sceneName) {
|
|
|
1660
1881
|
if (!scene) {
|
|
1661
1882
|
throw new TileflowCaptureError("SCENE_NOT_FOUND", `Unknown Tileflow scene "${sceneName}".`);
|
|
1662
1883
|
}
|
|
1663
|
-
|
|
1884
|
+
const normalized = normalizeTileflowCaptureScene(scene);
|
|
1885
|
+
const resolvedMap = artifacts.project.maps[normalized.map];
|
|
1886
|
+
if (!resolvedMap) {
|
|
1887
|
+
throw new TileflowCaptureError(
|
|
1888
|
+
"SCENE_NOT_FOUND",
|
|
1889
|
+
`Scene "${sceneName}" references unavailable map "${normalized.map}".`
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
const map = parseResolvedTileflowMap(resolvedMap);
|
|
1893
|
+
let theme;
|
|
1894
|
+
try {
|
|
1895
|
+
theme = resolveThemeSelection(map, normalized.theme).name;
|
|
1896
|
+
} catch (error) {
|
|
1897
|
+
throw new TileflowCaptureError(
|
|
1898
|
+
"SCENE_NOT_FOUND",
|
|
1899
|
+
`Scene "${sceneName}" has an invalid theme: ${error instanceof Error ? error.message : "unknown theme"}`,
|
|
1900
|
+
{ cause: error }
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
return { ...normalized, theme };
|
|
1664
1904
|
}
|
|
1665
1905
|
function throwIfAborted2(signal) {
|
|
1666
1906
|
if (signal?.aborted) {
|
|
@@ -1682,33 +1922,119 @@ function throwCaptureStyleValidationError(error) {
|
|
|
1682
1922
|
|
|
1683
1923
|
// src/visual.ts
|
|
1684
1924
|
import pixelmatch from "pixelmatch";
|
|
1925
|
+
import { types as nodeUtilTypes } from "util";
|
|
1685
1926
|
import { PNG } from "pngjs";
|
|
1686
1927
|
import {
|
|
1687
1928
|
compareCodeUnits as compareCodeUnits4,
|
|
1688
|
-
serializeCanonicalJson as
|
|
1929
|
+
serializeCanonicalJson as serializeCanonicalJson2,
|
|
1689
1930
|
sha256Hex as sha256Hex2,
|
|
1690
|
-
tileflowCaptureSceneLimits as
|
|
1931
|
+
tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2
|
|
1691
1932
|
} from "@tileflow/core";
|
|
1692
1933
|
var tileflowVisualComparisonSchemaVersion = 1;
|
|
1693
1934
|
var tileflowVisualAnalysisSchemaVersion = 1;
|
|
1694
1935
|
var tileflowVisualPerceptualThreshold = 0.1;
|
|
1936
|
+
var tileflowVisualEdgeThreshold = 0.05;
|
|
1695
1937
|
var tileflowVisualArtifactLimits = Object.freeze({
|
|
1696
1938
|
maximumPngBytes: 256 * 1024 * 1024,
|
|
1697
1939
|
maximumPaletteColors: 16
|
|
1698
1940
|
});
|
|
1941
|
+
async function analyzeTileflowCapturePairForReview(left, right, options) {
|
|
1942
|
+
if (options.region) {
|
|
1943
|
+
resolveTileflowVisualRegion(
|
|
1944
|
+
options.region,
|
|
1945
|
+
left.receipt.image.physicalWidth,
|
|
1946
|
+
left.receipt.image.physicalHeight
|
|
1947
|
+
);
|
|
1948
|
+
resolveTileflowVisualRegion(
|
|
1949
|
+
options.region,
|
|
1950
|
+
right.receipt.image.physicalWidth,
|
|
1951
|
+
right.receipt.image.physicalHeight
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
if (!options.comparePixels) {
|
|
1955
|
+
const leftIdentity = await inspectPngAgainstReceipt(left.png, left.receipt, "left");
|
|
1956
|
+
const rightIdentity = await inspectPngAgainstReceipt(right.png, right.receipt, "right");
|
|
1957
|
+
return {
|
|
1958
|
+
left: leftIdentity,
|
|
1959
|
+
right: rightIdentity,
|
|
1960
|
+
exact: null,
|
|
1961
|
+
perceptual: null,
|
|
1962
|
+
meanAbsoluteChannelDifference: null,
|
|
1963
|
+
appearance: null
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
const leftImage = await validatePngAgainstReceipt(left.png, left.receipt, "left");
|
|
1967
|
+
const rightImage = await validatePngAgainstReceipt(right.png, right.receipt, "right");
|
|
1968
|
+
if (leftImage.width !== rightImage.width || leftImage.height !== rightImage.height) {
|
|
1969
|
+
throw invalidBaseline("Comparable review PNGs did not decode to equal dimensions.");
|
|
1970
|
+
}
|
|
1971
|
+
const totalPixels = leftImage.width * leftImage.height;
|
|
1972
|
+
const region = resolveTileflowVisualRegion(options.region, leftImage.width, leftImage.height);
|
|
1973
|
+
const appearance = createTileflowVisualReviewAppearance(leftImage, rightImage, region);
|
|
1974
|
+
const exactChangedPixels = countExactChangedPixels(leftImage.data, rightImage.data);
|
|
1975
|
+
const diffData = options.includeDiff ? new Uint8Array(totalPixels * 4) : void 0;
|
|
1976
|
+
const perceptualChangedPixels = pixelmatch(
|
|
1977
|
+
leftImage.data,
|
|
1978
|
+
rightImage.data,
|
|
1979
|
+
diffData,
|
|
1980
|
+
leftImage.width,
|
|
1981
|
+
leftImage.height,
|
|
1982
|
+
{
|
|
1983
|
+
checkerboard: true,
|
|
1984
|
+
diffColor: [255, 0, 255],
|
|
1985
|
+
diffColorAlt: [0, 255, 255],
|
|
1986
|
+
diffMask: true,
|
|
1987
|
+
includeAA: true,
|
|
1988
|
+
threshold: tileflowVisualPerceptualThreshold
|
|
1989
|
+
}
|
|
1990
|
+
);
|
|
1991
|
+
return {
|
|
1992
|
+
left: reviewImageAnalysisIdentity(leftImage, left.receipt),
|
|
1993
|
+
right: reviewImageAnalysisIdentity(rightImage, right.receipt),
|
|
1994
|
+
exact: pixelMetric(exactChangedPixels, totalPixels),
|
|
1995
|
+
perceptual: {
|
|
1996
|
+
threshold: tileflowVisualPerceptualThreshold,
|
|
1997
|
+
...pixelMetric(perceptualChangedPixels, totalPixels)
|
|
1998
|
+
},
|
|
1999
|
+
meanAbsoluteChannelDifference: meanAbsoluteChannelDifference(leftImage.data, rightImage.data),
|
|
2000
|
+
appearance,
|
|
2001
|
+
...diffData ? { diffPng: encodeDiffPng(diffData, leftImage.width, leftImage.height) } : {}
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
async function inspectPngAgainstReceipt(png, receipt, role) {
|
|
2005
|
+
const image = await validatePngAgainstReceipt(png, receipt, role);
|
|
2006
|
+
return reviewImageAnalysisIdentity(image, receipt);
|
|
2007
|
+
}
|
|
2008
|
+
function reviewImageAnalysisIdentity(image, receipt) {
|
|
2009
|
+
return {
|
|
2010
|
+
sha256: receipt.image.sha256,
|
|
2011
|
+
physicalWidth: image.width,
|
|
2012
|
+
physicalHeight: image.height,
|
|
2013
|
+
palette: createQuantizedPalette(image.data)
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
1699
2016
|
function validateTileflowVisualReferencePng(png) {
|
|
1700
2017
|
const image = decodePng(png, "reference");
|
|
1701
2018
|
return { width: image.width, height: image.height };
|
|
1702
2019
|
}
|
|
1703
|
-
async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
2020
|
+
async function analyzeTileflowCaptureReference(actualCapture, referencePng, options = {}) {
|
|
2021
|
+
const requestedRegion = snapshotTileflowVisualReferenceAnalysisOptions(options);
|
|
1704
2022
|
const receipt = validateTileflowCaptureReceipt(actualCapture.receipt);
|
|
2023
|
+
if (receipt.schemaVersion !== 4) {
|
|
2024
|
+
throw invalidBaseline("Visual reference analysis requires a schema-v4 capture receipt.");
|
|
2025
|
+
}
|
|
1705
2026
|
const actualImage = await validatePngAgainstReceipt(actualCapture.png, receipt, "actual");
|
|
1706
2027
|
const referenceImage = decodePng(referencePng, "reference");
|
|
1707
2028
|
const dimensionsMatch = referenceImage.width === actualImage.width && referenceImage.height === actualImage.height;
|
|
2029
|
+
if (requestedRegion) {
|
|
2030
|
+
resolveTileflowVisualRegion(requestedRegion, referenceImage.width, referenceImage.height);
|
|
2031
|
+
resolveTileflowVisualRegion(requestedRegion, actualImage.width, actualImage.height);
|
|
2032
|
+
}
|
|
1708
2033
|
const common = {
|
|
1709
2034
|
schemaVersion: tileflowVisualAnalysisSchemaVersion,
|
|
1710
2035
|
scene: receipt.scene.name,
|
|
1711
2036
|
map: receipt.scene.map,
|
|
2037
|
+
theme: receipt.scene.theme,
|
|
1712
2038
|
target: receipt.scene.target,
|
|
1713
2039
|
dimensionsMatch,
|
|
1714
2040
|
reference: {
|
|
@@ -1730,6 +2056,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
|
1730
2056
|
exact: null,
|
|
1731
2057
|
perceptual: null,
|
|
1732
2058
|
meanAbsoluteChannelDifference: null,
|
|
2059
|
+
appearance: null,
|
|
1733
2060
|
warnings: [
|
|
1734
2061
|
"The reference and actual PNG dimensions differ; pixel metrics and diff were not computed.",
|
|
1735
2062
|
...networkWarnings(void 0, receipt)
|
|
@@ -1737,6 +2064,11 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
|
1737
2064
|
};
|
|
1738
2065
|
}
|
|
1739
2066
|
const totalPixels = actualImage.width * actualImage.height;
|
|
2067
|
+
const region = resolveTileflowVisualRegion(
|
|
2068
|
+
requestedRegion,
|
|
2069
|
+
actualImage.width,
|
|
2070
|
+
actualImage.height
|
|
2071
|
+
);
|
|
1740
2072
|
const exactChangedPixels = countExactChangedPixels(referenceImage.data, actualImage.data);
|
|
1741
2073
|
const diffData = new Uint8Array(totalPixels * 4);
|
|
1742
2074
|
const perceptualChangedPixels = pixelmatch(
|
|
@@ -1765,6 +2097,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng) {
|
|
|
1765
2097
|
referenceImage.data,
|
|
1766
2098
|
actualImage.data
|
|
1767
2099
|
),
|
|
2100
|
+
appearance: createTileflowVisualReferenceAppearance(referenceImage, actualImage, region),
|
|
1768
2101
|
diffPng: encodeDiffPng(diffData, actualImage.width, actualImage.height),
|
|
1769
2102
|
warnings: networkWarnings(void 0, receipt)
|
|
1770
2103
|
};
|
|
@@ -1852,8 +2185,8 @@ async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
|
|
|
1852
2185
|
baselineReceipt.image.physicalHeight,
|
|
1853
2186
|
actualReceipt.image.physicalHeight
|
|
1854
2187
|
);
|
|
1855
|
-
const boundedWidth = diffWidth * diffHeight <=
|
|
1856
|
-
const boundedHeight = diffWidth * diffHeight <=
|
|
2188
|
+
const boundedWidth = diffWidth * diffHeight <= tileflowCaptureSceneLimits2.maximumPhysicalPixels ? diffWidth : actualReceipt.image.physicalWidth;
|
|
2189
|
+
const boundedHeight = diffWidth * diffHeight <= tileflowCaptureSceneLimits2.maximumPhysicalPixels ? diffHeight : actualReceipt.image.physicalHeight;
|
|
1857
2190
|
return {
|
|
1858
2191
|
...common,
|
|
1859
2192
|
status: "changed",
|
|
@@ -1918,7 +2251,7 @@ function createTileflowVisualComparisonDocument(comparison) {
|
|
|
1918
2251
|
return document;
|
|
1919
2252
|
}
|
|
1920
2253
|
function serializeTileflowVisualComparison(comparison) {
|
|
1921
|
-
return `${
|
|
2254
|
+
return `${serializeCanonicalJson2(createTileflowVisualComparisonDocument(comparison))}
|
|
1922
2255
|
`;
|
|
1923
2256
|
}
|
|
1924
2257
|
function imageIdentity(receipt) {
|
|
@@ -1964,7 +2297,7 @@ function preflightPng(png, role) {
|
|
|
1964
2297
|
`The ${role} PNG is interlaced and cannot be decoded within fixed bounds.`
|
|
1965
2298
|
);
|
|
1966
2299
|
}
|
|
1967
|
-
if (dimensions.width >
|
|
2300
|
+
if (dimensions.width > tileflowCaptureSceneLimits2.viewport.maximum * 2 || dimensions.height > tileflowCaptureSceneLimits2.viewport.maximum * 2 || dimensions.width * dimensions.height > tileflowCaptureSceneLimits2.maximumPhysicalPixels) {
|
|
1968
2301
|
throw invalidBaseline(`The ${role} PNG exceeds the visual pixel limit.`);
|
|
1969
2302
|
}
|
|
1970
2303
|
return dimensions;
|
|
@@ -2047,11 +2380,260 @@ function meanAbsoluteChannelDifference(left, right) {
|
|
|
2047
2380
|
}
|
|
2048
2381
|
return Math.round(sum / left.byteLength * 1e6) / 1e6;
|
|
2049
2382
|
}
|
|
2383
|
+
var appearanceHistogramSteps = 4096;
|
|
2384
|
+
var maximumOklabChroma = 0.5;
|
|
2385
|
+
function createTileflowVisualReferenceAppearance(reference, actual, region) {
|
|
2386
|
+
const referenceProfile = createAppearanceProfile(reference, region);
|
|
2387
|
+
const actualProfile = createAppearanceProfile(actual, region);
|
|
2388
|
+
return {
|
|
2389
|
+
region,
|
|
2390
|
+
reference: referenceProfile,
|
|
2391
|
+
actual: actualProfile,
|
|
2392
|
+
actualMinusReference: subtractAppearanceProfiles(actualProfile, referenceProfile)
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
function createTileflowVisualReviewAppearance(left, right, region) {
|
|
2396
|
+
const leftProfile = createAppearanceProfile(left, region);
|
|
2397
|
+
const rightProfile = createAppearanceProfile(right, region);
|
|
2398
|
+
return {
|
|
2399
|
+
region,
|
|
2400
|
+
left: leftProfile,
|
|
2401
|
+
right: rightProfile,
|
|
2402
|
+
rightMinusLeft: subtractAppearanceProfiles(rightProfile, leftProfile)
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
2405
|
+
function createAppearanceProfile(image, region) {
|
|
2406
|
+
const linearLuminanceHistogram = createAppearanceHistogram(1);
|
|
2407
|
+
const oklabLightnessHistogram = createAppearanceHistogram(1);
|
|
2408
|
+
const oklabChromaHistogram = createAppearanceHistogram(maximumOklabChroma);
|
|
2409
|
+
const previousRow = new Float64Array(region.width);
|
|
2410
|
+
const totalPixels = region.width * region.height;
|
|
2411
|
+
let linearLuminanceSum = 0;
|
|
2412
|
+
let oklabLightnessSum = 0;
|
|
2413
|
+
let oklabChromaSum = 0;
|
|
2414
|
+
let neighborCount = 0;
|
|
2415
|
+
let edgeCount = 0;
|
|
2416
|
+
let localContrastSum = 0;
|
|
2417
|
+
for (let localY = 0; localY < region.height; localY += 1) {
|
|
2418
|
+
const y = region.y + localY;
|
|
2419
|
+
let leftLuminance = 0;
|
|
2420
|
+
for (let localX = 0; localX < region.width; localX += 1) {
|
|
2421
|
+
const x = region.x + localX;
|
|
2422
|
+
const offset = (y * image.width + x) * 4;
|
|
2423
|
+
const alpha = image.data[offset + 3] / 255;
|
|
2424
|
+
const red = srgbToLinear(compositeSrgbOnWhite(image.data[offset], alpha));
|
|
2425
|
+
const green = srgbToLinear(compositeSrgbOnWhite(image.data[offset + 1], alpha));
|
|
2426
|
+
const blue = srgbToLinear(compositeSrgbOnWhite(image.data[offset + 2], alpha));
|
|
2427
|
+
const linearLuminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
2428
|
+
const [oklabLightness, oklabA, oklabB] = linearSrgbToOklab(red, green, blue);
|
|
2429
|
+
const oklabChroma = Math.hypot(oklabA, oklabB);
|
|
2430
|
+
linearLuminanceSum += linearLuminance;
|
|
2431
|
+
oklabLightnessSum += oklabLightness;
|
|
2432
|
+
oklabChromaSum += oklabChroma;
|
|
2433
|
+
addAppearanceHistogramValue(linearLuminanceHistogram, linearLuminance);
|
|
2434
|
+
addAppearanceHistogramValue(oklabLightnessHistogram, oklabLightness);
|
|
2435
|
+
addAppearanceHistogramValue(oklabChromaHistogram, oklabChroma);
|
|
2436
|
+
if (localX > 0) {
|
|
2437
|
+
const difference = Math.abs(linearLuminance - leftLuminance);
|
|
2438
|
+
localContrastSum += difference;
|
|
2439
|
+
neighborCount += 1;
|
|
2440
|
+
if (difference >= tileflowVisualEdgeThreshold) edgeCount += 1;
|
|
2441
|
+
}
|
|
2442
|
+
if (localY > 0) {
|
|
2443
|
+
const difference = Math.abs(linearLuminance - previousRow[localX]);
|
|
2444
|
+
localContrastSum += difference;
|
|
2445
|
+
neighborCount += 1;
|
|
2446
|
+
if (difference >= tileflowVisualEdgeThreshold) edgeCount += 1;
|
|
2447
|
+
}
|
|
2448
|
+
leftLuminance = linearLuminance;
|
|
2449
|
+
previousRow[localX] = linearLuminance;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
return {
|
|
2453
|
+
linearLuminance: appearanceStatistics(
|
|
2454
|
+
linearLuminanceSum,
|
|
2455
|
+
totalPixels,
|
|
2456
|
+
linearLuminanceHistogram
|
|
2457
|
+
),
|
|
2458
|
+
oklabLightness: appearanceStatistics(oklabLightnessSum, totalPixels, oklabLightnessHistogram),
|
|
2459
|
+
oklabChroma: appearanceStatistics(oklabChromaSum, totalPixels, oklabChromaHistogram),
|
|
2460
|
+
edgeDensity: roundAppearanceMetric(neighborCount === 0 ? 0 : edgeCount / neighborCount),
|
|
2461
|
+
localContrast: roundAppearanceMetric(
|
|
2462
|
+
neighborCount === 0 ? 0 : localContrastSum / neighborCount
|
|
2463
|
+
)
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
function createAppearanceHistogram(maximum) {
|
|
2467
|
+
return { counts: new Uint32Array(appearanceHistogramSteps + 1), maximum };
|
|
2468
|
+
}
|
|
2469
|
+
function addAppearanceHistogramValue(histogram, value) {
|
|
2470
|
+
const normalized = Math.max(0, Math.min(1, value / histogram.maximum));
|
|
2471
|
+
histogram.counts[Math.round(normalized * appearanceHistogramSteps)] += 1;
|
|
2472
|
+
}
|
|
2473
|
+
function appearanceStatistics(sum, total, histogram) {
|
|
2474
|
+
return {
|
|
2475
|
+
mean: roundAppearanceMetric(sum / total),
|
|
2476
|
+
percentiles: {
|
|
2477
|
+
p10: appearancePercentile(histogram, total, 0.1),
|
|
2478
|
+
p50: appearancePercentile(histogram, total, 0.5),
|
|
2479
|
+
p90: appearancePercentile(histogram, total, 0.9)
|
|
2480
|
+
}
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
function appearancePercentile(histogram, total, percentile) {
|
|
2484
|
+
const position = (total - 1) * percentile;
|
|
2485
|
+
const lowerRank = Math.floor(position);
|
|
2486
|
+
const upperRank = Math.ceil(position);
|
|
2487
|
+
const lower = appearanceHistogramValueAtRank(histogram, lowerRank);
|
|
2488
|
+
const upper = appearanceHistogramValueAtRank(histogram, upperRank);
|
|
2489
|
+
return roundAppearanceMetric(lower + (upper - lower) * (position - lowerRank));
|
|
2490
|
+
}
|
|
2491
|
+
function appearanceHistogramValueAtRank(histogram, rank) {
|
|
2492
|
+
let cumulative = 0;
|
|
2493
|
+
for (let index = 0; index < histogram.counts.length; index += 1) {
|
|
2494
|
+
cumulative += histogram.counts[index];
|
|
2495
|
+
if (cumulative > rank) {
|
|
2496
|
+
return index / appearanceHistogramSteps * histogram.maximum;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
return histogram.maximum;
|
|
2500
|
+
}
|
|
2501
|
+
function subtractAppearanceProfiles(minuend, subtrahend) {
|
|
2502
|
+
return {
|
|
2503
|
+
linearLuminance: subtractAppearanceStatistics(
|
|
2504
|
+
minuend.linearLuminance,
|
|
2505
|
+
subtrahend.linearLuminance
|
|
2506
|
+
),
|
|
2507
|
+
oklabLightness: subtractAppearanceStatistics(minuend.oklabLightness, subtrahend.oklabLightness),
|
|
2508
|
+
oklabChroma: subtractAppearanceStatistics(minuend.oklabChroma, subtrahend.oklabChroma),
|
|
2509
|
+
edgeDensity: subtractAppearanceMetric(minuend.edgeDensity, subtrahend.edgeDensity),
|
|
2510
|
+
localContrast: subtractAppearanceMetric(minuend.localContrast, subtrahend.localContrast)
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
function subtractAppearanceStatistics(minuend, subtrahend) {
|
|
2514
|
+
return {
|
|
2515
|
+
mean: subtractAppearanceMetric(minuend.mean, subtrahend.mean),
|
|
2516
|
+
percentiles: {
|
|
2517
|
+
p10: subtractAppearanceMetric(minuend.percentiles.p10, subtrahend.percentiles.p10),
|
|
2518
|
+
p50: subtractAppearanceMetric(minuend.percentiles.p50, subtrahend.percentiles.p50),
|
|
2519
|
+
p90: subtractAppearanceMetric(minuend.percentiles.p90, subtrahend.percentiles.p90)
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
function subtractAppearanceMetric(minuend, subtrahend) {
|
|
2524
|
+
return roundAppearanceMetric(minuend - subtrahend);
|
|
2525
|
+
}
|
|
2526
|
+
function roundAppearanceMetric(value) {
|
|
2527
|
+
const rounded = Math.round(value * 1e6) / 1e6;
|
|
2528
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
2529
|
+
}
|
|
2530
|
+
function compositeSrgbOnWhite(channel, alpha) {
|
|
2531
|
+
return channel / 255 * alpha + (1 - alpha);
|
|
2532
|
+
}
|
|
2533
|
+
function srgbToLinear(channel) {
|
|
2534
|
+
return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
|
2535
|
+
}
|
|
2536
|
+
function linearSrgbToOklab(red, green, blue) {
|
|
2537
|
+
const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
|
|
2538
|
+
const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
|
|
2539
|
+
const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
|
|
2540
|
+
return [
|
|
2541
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
2542
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
2543
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
2544
|
+
];
|
|
2545
|
+
}
|
|
2546
|
+
function snapshotTileflowVisualReferenceAnalysisOptions(options) {
|
|
2547
|
+
const record = requirePlainVisualRecord(options, "Visual reference analysis options");
|
|
2548
|
+
const keys = Object.keys(record);
|
|
2549
|
+
if (keys.some((key) => key !== "region")) {
|
|
2550
|
+
throw invalidBaseline("Visual reference analysis options contain unsupported fields.");
|
|
2551
|
+
}
|
|
2552
|
+
return record.region === void 0 ? void 0 : snapshotTileflowVisualRegion(record.region, "Visual reference analysis region");
|
|
2553
|
+
}
|
|
2554
|
+
function snapshotTileflowVisualRegion(value, label) {
|
|
2555
|
+
const record = requirePlainVisualRecord(value, label);
|
|
2556
|
+
const keys = Object.keys(record);
|
|
2557
|
+
if (keys.length !== 4 || keys.some((key) => key !== "x" && key !== "y" && key !== "width" && key !== "height")) {
|
|
2558
|
+
throw invalidBaseline(`${label} must contain exactly x, y, width, and height.`);
|
|
2559
|
+
}
|
|
2560
|
+
return {
|
|
2561
|
+
x: record.x,
|
|
2562
|
+
y: record.y,
|
|
2563
|
+
width: record.width,
|
|
2564
|
+
height: record.height
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
function requirePlainVisualRecord(value, label) {
|
|
2568
|
+
if (value === null || typeof value !== "object" || nodeUtilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) {
|
|
2569
|
+
throw invalidBaseline(`${label} must be a plain data object.`);
|
|
2570
|
+
}
|
|
2571
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
2572
|
+
const entries = [];
|
|
2573
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
2574
|
+
if (typeof key !== "string") {
|
|
2575
|
+
throw invalidBaseline(`${label} must not contain symbol fields.`);
|
|
2576
|
+
}
|
|
2577
|
+
const descriptor = descriptors[key];
|
|
2578
|
+
if (!("value" in descriptor) || !descriptor.enumerable) {
|
|
2579
|
+
throw invalidBaseline(`${label} must not contain accessors or hidden fields.`);
|
|
2580
|
+
}
|
|
2581
|
+
entries.push([key, descriptor.value]);
|
|
2582
|
+
}
|
|
2583
|
+
return Object.fromEntries(entries);
|
|
2584
|
+
}
|
|
2585
|
+
function resolveTileflowVisualRegion(requested, physicalWidth, physicalHeight) {
|
|
2586
|
+
const region = requested ?? { x: 0, y: 0, width: physicalWidth, height: physicalHeight };
|
|
2587
|
+
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) {
|
|
2588
|
+
throw invalidBaseline(
|
|
2589
|
+
"The visual region must be a positive integer rectangle within the physical PNG bounds."
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
return { ...region };
|
|
2593
|
+
}
|
|
2050
2594
|
function pixelMetric(changedPixels, totalPixels) {
|
|
2051
2595
|
return { changedPixels, totalPixels, ratio: changedPixels / totalPixels };
|
|
2052
2596
|
}
|
|
2053
2597
|
function sameSceneIdentity(baseline, actual) {
|
|
2054
|
-
return baseline.scene.name === actual.scene.name && baseline.scene.map === actual.scene.map && baseline.scene.target === actual.scene.target && baseline.scene.sha256 === actual.scene.sha256 &&
|
|
2598
|
+
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);
|
|
2599
|
+
}
|
|
2600
|
+
function sameDataIdentity(baseline, actual) {
|
|
2601
|
+
if (baseline.kind !== actual.kind || baseline.schema !== actual.schema || baseline.schemaVersion !== actual.schemaVersion || baseline.sourceId !== actual.sourceId) {
|
|
2602
|
+
return false;
|
|
2603
|
+
}
|
|
2604
|
+
const baselineExactWorld = exactWorldIdentity(baseline);
|
|
2605
|
+
const actualExactWorld = exactWorldIdentity(actual);
|
|
2606
|
+
if (baselineExactWorld || actualExactWorld) {
|
|
2607
|
+
if (!baselineExactWorld || !actualExactWorld) return false;
|
|
2608
|
+
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);
|
|
2609
|
+
}
|
|
2610
|
+
const baselineLegacy = baseline;
|
|
2611
|
+
const actualLegacy = actual;
|
|
2612
|
+
if (baselineLegacy.generation !== actualLegacy.generation || baselineLegacy.revision !== actualLegacy.revision || !sameOptionalSemantics(baselineLegacy.semantics, actualLegacy.semantics)) {
|
|
2613
|
+
return false;
|
|
2614
|
+
}
|
|
2615
|
+
if (baselineLegacy.source && (!actualLegacy.source || baselineLegacy.source.kind !== actualLegacy.source.kind || baselineLegacy.source.sha256 !== actualLegacy.source.sha256)) {
|
|
2616
|
+
return false;
|
|
2617
|
+
}
|
|
2618
|
+
if (baselineLegacy.capabilities && (!actualLegacy.capabilities || !isRecordSubset(baselineLegacy.capabilities, actualLegacy.capabilities))) {
|
|
2619
|
+
return false;
|
|
2620
|
+
}
|
|
2621
|
+
if (baselineLegacy.bindings) {
|
|
2622
|
+
if (!actualLegacy.bindings) return false;
|
|
2623
|
+
if (!isRecordSubset(baselineLegacy.bindings.fields, actualLegacy.bindings.fields) || !isRecordSubset(baselineLegacy.bindings.layers, actualLegacy.bindings.layers)) {
|
|
2624
|
+
return false;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
return true;
|
|
2628
|
+
}
|
|
2629
|
+
function exactWorldIdentity(value) {
|
|
2630
|
+
return value.kind === "tileflow-world" && "product" in value ? value : void 0;
|
|
2631
|
+
}
|
|
2632
|
+
function sameOptionalSemantics(baseline, actual) {
|
|
2633
|
+
return baseline === void 0 || baseline.parkLayer === actual?.parkLayer;
|
|
2634
|
+
}
|
|
2635
|
+
function isRecordSubset(baseline, actual) {
|
|
2636
|
+
return Object.entries(baseline).every(([key, value]) => actual[key] === value);
|
|
2055
2637
|
}
|
|
2056
2638
|
function sameRuntimeIdentity(baseline, actual) {
|
|
2057
2639
|
return baseline.renderer.tileflow === actual.renderer.tileflow && baseline.renderer.maplibre === actual.renderer.maplibre && baseline.renderer.playwright === actual.renderer.playwright && baseline.renderer.chromiumRevision === actual.renderer.chromiumRevision && baseline.renderer.chromiumVersion === actual.renderer.chromiumVersion && baseline.platform.os === actual.platform.os && baseline.platform.architecture === actual.platform.architecture;
|
|
@@ -2069,22 +2651,455 @@ function networkWarnings(baseline, actual) {
|
|
|
2069
2651
|
function invalidBaseline(message, cause) {
|
|
2070
2652
|
return new TileflowCaptureError("BASELINE_INVALID", message, { cause });
|
|
2071
2653
|
}
|
|
2654
|
+
|
|
2655
|
+
// src/review.ts
|
|
2656
|
+
import { types as nodeUtilTypes2 } from "util";
|
|
2657
|
+
import {
|
|
2658
|
+
normalizeTileflowCaptureScene as normalizeTileflowCaptureScene2,
|
|
2659
|
+
serializeCanonicalJson as serializeCanonicalJson3,
|
|
2660
|
+
sha256Hex as sha256Hex3
|
|
2661
|
+
} from "@tileflow/core";
|
|
2662
|
+
var tileflowVisualReviewSchemaVersion = 1;
|
|
2663
|
+
var TileflowVisualReviewError = class extends Error {
|
|
2664
|
+
code = "VISUAL_REVIEW_INVALID";
|
|
2665
|
+
constructor(message, options) {
|
|
2666
|
+
super(message, options);
|
|
2667
|
+
this.name = "TileflowVisualReviewError";
|
|
2668
|
+
}
|
|
2669
|
+
};
|
|
2670
|
+
async function compareTileflowCapturesForReview(leftInput, rightInput, options = {}) {
|
|
2671
|
+
const normalizedOptions = validateReviewOptions(options);
|
|
2672
|
+
const [leftSnapshot, rightSnapshot] = snapshotReviewInputs(leftInput, rightInput);
|
|
2673
|
+
const [left, right] = await Promise.all([
|
|
2674
|
+
validateReviewCapture(leftSnapshot, "left"),
|
|
2675
|
+
validateReviewCapture(rightSnapshot, "right")
|
|
2676
|
+
]);
|
|
2677
|
+
const frameMatch = sameCanonicalValue(left.comparableFrame, right.comparableFrame);
|
|
2678
|
+
const dimensionsMatch = left.receipt.image.physicalWidth === right.receipt.image.physicalWidth && left.receipt.image.physicalHeight === right.receipt.image.physicalHeight;
|
|
2679
|
+
const rendererMatch = sameCanonicalValue(
|
|
2680
|
+
runtimeIdentity2(left.receipt),
|
|
2681
|
+
runtimeIdentity2(right.receipt)
|
|
2682
|
+
);
|
|
2683
|
+
const dataMatch = sameCanonicalValue(left.receipt.data, right.receipt.data);
|
|
2684
|
+
const status = reviewStatus({ dataMatch, dimensionsMatch, frameMatch, rendererMatch });
|
|
2685
|
+
const comparable = status === "comparable";
|
|
2686
|
+
const warnings = reviewWarnings(status, left.receipt, right.receipt);
|
|
2687
|
+
let analysis;
|
|
2688
|
+
try {
|
|
2689
|
+
analysis = await analyzeTileflowCapturePairForReview(
|
|
2690
|
+
{ png: left.png, receipt: left.receipt },
|
|
2691
|
+
{ png: right.png, receipt: right.receipt },
|
|
2692
|
+
{
|
|
2693
|
+
comparePixels: comparable,
|
|
2694
|
+
includeDiff: comparable && normalizedOptions.includeDiff,
|
|
2695
|
+
...normalizedOptions.region ? { region: normalizedOptions.region } : {}
|
|
2696
|
+
}
|
|
2697
|
+
);
|
|
2698
|
+
} catch (error) {
|
|
2699
|
+
const detail = error instanceof Error ? ` ${error.message}` : "";
|
|
2700
|
+
throw invalidReview(`The review PNGs could not be validated.${detail}`, error);
|
|
2701
|
+
}
|
|
2702
|
+
return {
|
|
2703
|
+
schemaVersion: tileflowVisualReviewSchemaVersion,
|
|
2704
|
+
kind: "style-review",
|
|
2705
|
+
status,
|
|
2706
|
+
left: reviewSide(left, analysis.left.palette),
|
|
2707
|
+
right: reviewSide(right, analysis.right.palette),
|
|
2708
|
+
frameMatch,
|
|
2709
|
+
dimensionsMatch,
|
|
2710
|
+
rendererMatch,
|
|
2711
|
+
dataMatch,
|
|
2712
|
+
exact: comparable ? analysis.exact : null,
|
|
2713
|
+
perceptual: comparable ? analysis.perceptual : null,
|
|
2714
|
+
meanAbsoluteChannelDifference: comparable ? analysis.meanAbsoluteChannelDifference : null,
|
|
2715
|
+
appearance: comparable ? analysis.appearance : null,
|
|
2716
|
+
...comparable && analysis.diffPng ? { diffPng: analysis.diffPng } : {},
|
|
2717
|
+
warnings
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
function createTileflowVisualReviewDocument(comparison) {
|
|
2721
|
+
const { diffPng: _diffPng, ...document } = comparison;
|
|
2722
|
+
return document;
|
|
2723
|
+
}
|
|
2724
|
+
var tileflowVisualReviewLimits = Object.freeze({
|
|
2725
|
+
maximumAggregatePngBytes: tileflowVisualArtifactLimits.maximumPngBytes
|
|
2726
|
+
});
|
|
2727
|
+
var reviewCaptureKeys = [
|
|
2728
|
+
"scene",
|
|
2729
|
+
"map",
|
|
2730
|
+
"theme",
|
|
2731
|
+
"target",
|
|
2732
|
+
"png",
|
|
2733
|
+
"sha256",
|
|
2734
|
+
"sceneSha256",
|
|
2735
|
+
"styleSha256",
|
|
2736
|
+
"width",
|
|
2737
|
+
"height",
|
|
2738
|
+
"dpr",
|
|
2739
|
+
"networkDependent",
|
|
2740
|
+
"renderer",
|
|
2741
|
+
"receipt",
|
|
2742
|
+
"warnings"
|
|
2743
|
+
];
|
|
2744
|
+
var maximumReviewDataNodes = 4096;
|
|
2745
|
+
var maximumReviewDataDepth = 32;
|
|
2746
|
+
function snapshotReviewInputs(leftInput, rightInput) {
|
|
2747
|
+
const left = inspectReviewInput(leftInput, "left");
|
|
2748
|
+
const right = inspectReviewInput(rightInput, "right");
|
|
2749
|
+
validateTileflowVisualReviewPngByteLengths(left.pngByteLength, right.pngByteLength);
|
|
2750
|
+
return [snapshotInspectedReviewInput(left, "left"), snapshotInspectedReviewInput(right, "right")];
|
|
2751
|
+
}
|
|
2752
|
+
function inspectReviewInput(input, role) {
|
|
2753
|
+
const wrapper = readPlainDataRecord(input, `${role} review input`, ["capture", "definition"]);
|
|
2754
|
+
const capture = readPlainDataRecord(wrapper.capture, `${role} capture`, reviewCaptureKeys);
|
|
2755
|
+
const definition = clonePlainDataValue(
|
|
2756
|
+
wrapper.definition,
|
|
2757
|
+
`${role} definition`
|
|
2758
|
+
);
|
|
2759
|
+
const renderer = clonePlainDataValue(
|
|
2760
|
+
capture.renderer,
|
|
2761
|
+
`${role} capture.renderer`
|
|
2762
|
+
);
|
|
2763
|
+
const receipt = clonePlainDataValue(
|
|
2764
|
+
capture.receipt,
|
|
2765
|
+
`${role} capture.receipt`
|
|
2766
|
+
);
|
|
2767
|
+
const warnings = clonePlainDataValue(capture.warnings, `${role} capture.warnings`);
|
|
2768
|
+
const png = requireReviewPng(capture.png, role);
|
|
2769
|
+
return {
|
|
2770
|
+
capture: {
|
|
2771
|
+
scene: capture.scene,
|
|
2772
|
+
map: capture.map,
|
|
2773
|
+
theme: capture.theme,
|
|
2774
|
+
target: capture.target,
|
|
2775
|
+
png: png.source,
|
|
2776
|
+
sha256: capture.sha256,
|
|
2777
|
+
sceneSha256: capture.sceneSha256,
|
|
2778
|
+
styleSha256: capture.styleSha256,
|
|
2779
|
+
width: capture.width,
|
|
2780
|
+
height: capture.height,
|
|
2781
|
+
dpr: capture.dpr,
|
|
2782
|
+
networkDependent: capture.networkDependent,
|
|
2783
|
+
renderer,
|
|
2784
|
+
receipt,
|
|
2785
|
+
warnings
|
|
2786
|
+
},
|
|
2787
|
+
pngByteLength: png.byteLength,
|
|
2788
|
+
definition
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2791
|
+
function snapshotInspectedReviewInput(input, role) {
|
|
2792
|
+
let png;
|
|
2793
|
+
try {
|
|
2794
|
+
png = new Uint8Array(input.pngByteLength);
|
|
2795
|
+
png.set(input.capture.png);
|
|
2796
|
+
} catch (error) {
|
|
2797
|
+
throw invalidReview(`The ${role} PNG could not be snapshotted safely.`, error);
|
|
2798
|
+
}
|
|
2799
|
+
if (png.byteLength !== input.pngByteLength) {
|
|
2800
|
+
throw invalidReview(`The ${role} PNG changed while it was being snapshotted.`);
|
|
2801
|
+
}
|
|
2802
|
+
return { capture: { ...input.capture, png }, definition: input.definition };
|
|
2803
|
+
}
|
|
2804
|
+
function validateTileflowVisualReviewPngByteLengths(leftByteLength, rightByteLength) {
|
|
2805
|
+
for (const [role, byteLength] of [
|
|
2806
|
+
["left", leftByteLength],
|
|
2807
|
+
["right", rightByteLength]
|
|
2808
|
+
]) {
|
|
2809
|
+
if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
|
|
2810
|
+
throw invalidReview(`The ${role} PNG has an invalid byte length.`);
|
|
2811
|
+
}
|
|
2812
|
+
if (byteLength > tileflowVisualArtifactLimits.maximumPngBytes) {
|
|
2813
|
+
throw invalidReview(`The ${role} PNG exceeds the visual artifact byte limit.`);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
if (leftByteLength + rightByteLength > tileflowVisualReviewLimits.maximumAggregatePngBytes) {
|
|
2817
|
+
throw invalidReview("The review PNGs exceed the aggregate visual artifact byte limit.");
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
function requireReviewPng(value, role) {
|
|
2821
|
+
if (value !== null && typeof value === "object" && nodeUtilTypes2.isProxy(value)) {
|
|
2822
|
+
throw invalidReview(`The ${role} PNG must not use an executable proxy.`);
|
|
2823
|
+
}
|
|
2824
|
+
const isBuffer = Buffer.isBuffer(value);
|
|
2825
|
+
if (!(value instanceof Uint8Array) || !isBuffer && Object.getPrototypeOf(value) !== Uint8Array.prototype) {
|
|
2826
|
+
throw invalidReview(`The ${role} PNG must be a plain Uint8Array or Buffer.`);
|
|
2827
|
+
}
|
|
2828
|
+
if (typeof SharedArrayBuffer !== "undefined" && value.buffer instanceof SharedArrayBuffer) {
|
|
2829
|
+
throw invalidReview(`The ${role} PNG must not use shared mutable memory.`);
|
|
2830
|
+
}
|
|
2831
|
+
let byteLength;
|
|
2832
|
+
try {
|
|
2833
|
+
byteLength = value.byteLength;
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
throw invalidReview(`The ${role} PNG has an invalid byte buffer.`, error);
|
|
2836
|
+
}
|
|
2837
|
+
return { source: value, byteLength };
|
|
2838
|
+
}
|
|
2839
|
+
function validateReviewOptions(options) {
|
|
2840
|
+
const record = readPlainDataRecord(options, "review options");
|
|
2841
|
+
const keys = Object.keys(record);
|
|
2842
|
+
if (keys.some((key) => key !== "includeDiff" && key !== "region")) {
|
|
2843
|
+
throw invalidReview("The review options contain unsupported fields.");
|
|
2844
|
+
}
|
|
2845
|
+
if (record.includeDiff !== void 0 && typeof record.includeDiff !== "boolean") {
|
|
2846
|
+
throw invalidReview("The review includeDiff option must be boolean.");
|
|
2847
|
+
}
|
|
2848
|
+
if (record.region === void 0) {
|
|
2849
|
+
return { includeDiff: record.includeDiff !== false };
|
|
2850
|
+
}
|
|
2851
|
+
const region = readPlainDataRecord(record.region, "review region", ["x", "y", "width", "height"]);
|
|
2852
|
+
return {
|
|
2853
|
+
includeDiff: record.includeDiff !== false,
|
|
2854
|
+
region: {
|
|
2855
|
+
x: region.x,
|
|
2856
|
+
y: region.y,
|
|
2857
|
+
width: region.width,
|
|
2858
|
+
height: region.height
|
|
2859
|
+
}
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
function clonePlainDataValue(value, path, state = {
|
|
2863
|
+
ancestors: /* @__PURE__ */ new WeakSet(),
|
|
2864
|
+
nodes: 0
|
|
2865
|
+
}, depth = 0) {
|
|
2866
|
+
if (value === null || value === void 0 || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
2867
|
+
return value;
|
|
2868
|
+
}
|
|
2869
|
+
if (typeof value !== "object") {
|
|
2870
|
+
throw invalidReview(`The ${path} value must contain only plain data.`);
|
|
2871
|
+
}
|
|
2872
|
+
if (nodeUtilTypes2.isProxy(value)) {
|
|
2873
|
+
throw invalidReview(`The ${path} value must not use an executable proxy.`);
|
|
2874
|
+
}
|
|
2875
|
+
if (depth > maximumReviewDataDepth || ++state.nodes > maximumReviewDataNodes) {
|
|
2876
|
+
throw invalidReview(`The ${path} value exceeds the bounded plain-data shape.`);
|
|
2877
|
+
}
|
|
2878
|
+
if (state.ancestors.has(value)) {
|
|
2879
|
+
throw invalidReview(`The ${path} value must not contain a cycle.`);
|
|
2880
|
+
}
|
|
2881
|
+
state.ancestors.add(value);
|
|
2882
|
+
try {
|
|
2883
|
+
if (Array.isArray(value)) {
|
|
2884
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || value.length > maximumReviewDataNodes) {
|
|
2885
|
+
throw invalidReview(`The ${path} value must use a plain bounded array.`);
|
|
2886
|
+
}
|
|
2887
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
2888
|
+
const indices = Object.keys(descriptors).filter((key) => key !== "length");
|
|
2889
|
+
if (indices.length !== value.length) {
|
|
2890
|
+
throw invalidReview(`The ${path} value must use a dense plain array.`);
|
|
2891
|
+
}
|
|
2892
|
+
const clone = new Array(value.length);
|
|
2893
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
2894
|
+
if (key === "length") continue;
|
|
2895
|
+
if (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(key)) {
|
|
2896
|
+
throw invalidReview(`The ${path} value contains an unsupported array field.`);
|
|
2897
|
+
}
|
|
2898
|
+
const index = Number(key);
|
|
2899
|
+
if (!Number.isSafeInteger(index) || index >= value.length) {
|
|
2900
|
+
throw invalidReview(`The ${path} value contains an out-of-bounds array field.`);
|
|
2901
|
+
}
|
|
2902
|
+
const descriptor = descriptors[key];
|
|
2903
|
+
if (!("value" in descriptor) || !descriptor.enumerable) {
|
|
2904
|
+
throw invalidReview(`The ${path} value contains an executable array field.`);
|
|
2905
|
+
}
|
|
2906
|
+
clone[index] = clonePlainDataValue(descriptor.value, `${path}[${key}]`, state, depth + 1);
|
|
2907
|
+
}
|
|
2908
|
+
return clone;
|
|
2909
|
+
}
|
|
2910
|
+
const record = readPlainDataRecord(value, path);
|
|
2911
|
+
state.nodes += Object.keys(record).length;
|
|
2912
|
+
if (state.nodes > maximumReviewDataNodes) {
|
|
2913
|
+
throw invalidReview(`The ${path} value exceeds the bounded plain-data shape.`);
|
|
2914
|
+
}
|
|
2915
|
+
return Object.fromEntries(
|
|
2916
|
+
Object.entries(record).map(([key, child]) => [
|
|
2917
|
+
key,
|
|
2918
|
+
clonePlainDataValue(child, `${path}.${key}`, state, depth + 1)
|
|
2919
|
+
])
|
|
2920
|
+
);
|
|
2921
|
+
} finally {
|
|
2922
|
+
state.ancestors.delete(value);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
function readPlainDataRecord(value, path, expectedKeys) {
|
|
2926
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2927
|
+
throw invalidReview(`The ${path} must be a plain data object.`);
|
|
2928
|
+
}
|
|
2929
|
+
if (nodeUtilTypes2.isProxy(value)) {
|
|
2930
|
+
throw invalidReview(`The ${path} must not use an executable proxy.`);
|
|
2931
|
+
}
|
|
2932
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2933
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
2934
|
+
throw invalidReview(`The ${path} must not use a custom prototype.`);
|
|
2935
|
+
}
|
|
2936
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
2937
|
+
const entries = [];
|
|
2938
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
2939
|
+
if (typeof key !== "string") {
|
|
2940
|
+
throw invalidReview(`The ${path} contains an unsupported symbol field.`);
|
|
2941
|
+
}
|
|
2942
|
+
const descriptor = descriptors[key];
|
|
2943
|
+
if (!("value" in descriptor) || !descriptor.enumerable) {
|
|
2944
|
+
throw invalidReview(`The ${path} contains an accessor or non-enumerable field.`);
|
|
2945
|
+
}
|
|
2946
|
+
entries.push([key, descriptor.value]);
|
|
2947
|
+
}
|
|
2948
|
+
if (expectedKeys) {
|
|
2949
|
+
const actual = entries.map(([key]) => key).sort(compareCodeUnits5);
|
|
2950
|
+
const wanted = [...expectedKeys].sort(compareCodeUnits5);
|
|
2951
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
2952
|
+
throw invalidReview(`The ${path} contains missing or unsupported fields.`);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
return Object.fromEntries(entries);
|
|
2956
|
+
}
|
|
2957
|
+
async function validateReviewCapture(input, role) {
|
|
2958
|
+
let normalized;
|
|
2959
|
+
try {
|
|
2960
|
+
normalized = normalizeTileflowCaptureScene2(input.definition);
|
|
2961
|
+
} catch (error) {
|
|
2962
|
+
throw invalidReview(`The ${role} review definition is invalid.`, error);
|
|
2963
|
+
}
|
|
2964
|
+
const definition = normalized;
|
|
2965
|
+
if (!sameCanonicalValue(input.definition, definition)) {
|
|
2966
|
+
throw invalidReview(`The ${role} review definition must be the exact normalized shape.`);
|
|
2967
|
+
}
|
|
2968
|
+
let parsedReceipt;
|
|
2969
|
+
try {
|
|
2970
|
+
parsedReceipt = validateTileflowCaptureReceipt(input.capture.receipt);
|
|
2971
|
+
} catch (error) {
|
|
2972
|
+
throw invalidReview(`The ${role} capture receipt is invalid.`, error);
|
|
2973
|
+
}
|
|
2974
|
+
const receipt = parsedReceipt;
|
|
2975
|
+
if (receipt.schemaVersion !== 4) {
|
|
2976
|
+
throw invalidReview(`The ${role} review capture requires a schema-v4 receipt.`);
|
|
2977
|
+
}
|
|
2978
|
+
if (definition.map !== receipt.scene.map || definition.theme !== receipt.scene.theme || definition.target.kind !== receipt.scene.target) {
|
|
2979
|
+
throw invalidReview(`The ${role} review identity does not match its capture receipt.`);
|
|
2980
|
+
}
|
|
2981
|
+
const capturesViewport = definition.target.kind === "map" || definition.target.frame === "viewport";
|
|
2982
|
+
if (definition.viewport.dpr !== receipt.image.dpr || capturesViewport && (definition.viewport.width !== receipt.image.cssWidth || definition.viewport.height !== receipt.image.cssHeight)) {
|
|
2983
|
+
throw invalidReview(`The ${role} review frame does not match its captured dimensions.`);
|
|
2984
|
+
}
|
|
2985
|
+
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)) {
|
|
2986
|
+
throw invalidReview(`The ${role} capture metadata does not match its receipt.`);
|
|
2987
|
+
}
|
|
2988
|
+
const definitionSha256 = await sha256Hex3(serializeCanonicalJson3(definition));
|
|
2989
|
+
if (definitionSha256 !== receipt.scene.sha256) {
|
|
2990
|
+
throw invalidReview(`The ${role} review definition does not match its capture receipt.`);
|
|
2991
|
+
}
|
|
2992
|
+
return {
|
|
2993
|
+
definition,
|
|
2994
|
+
comparableFrame: reviewFrameIdentity(definition),
|
|
2995
|
+
png: input.capture.png,
|
|
2996
|
+
receipt
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
function reviewSide(input, palette) {
|
|
3000
|
+
return {
|
|
3001
|
+
scene: input.receipt.scene,
|
|
3002
|
+
style: input.receipt.style,
|
|
3003
|
+
frame: input.comparableFrame,
|
|
3004
|
+
image: imageIdentity2(input.receipt),
|
|
3005
|
+
runtime: runtimeIdentity2(input.receipt),
|
|
3006
|
+
data: input.receipt.data,
|
|
3007
|
+
verification: input.receipt.verification,
|
|
3008
|
+
palette
|
|
3009
|
+
};
|
|
3010
|
+
}
|
|
3011
|
+
function reviewFrameIdentity(definition) {
|
|
3012
|
+
return {
|
|
3013
|
+
camera: definition.camera,
|
|
3014
|
+
viewport: definition.viewport,
|
|
3015
|
+
target: definition.target.kind === "map" ? { kind: "map" } : { kind: "application", frame: definition.target.frame }
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
function imageIdentity2(receipt) {
|
|
3019
|
+
return {
|
|
3020
|
+
sha256: receipt.image.sha256,
|
|
3021
|
+
cssWidth: receipt.image.cssWidth,
|
|
3022
|
+
cssHeight: receipt.image.cssHeight,
|
|
3023
|
+
physicalWidth: receipt.image.physicalWidth,
|
|
3024
|
+
physicalHeight: receipt.image.physicalHeight,
|
|
3025
|
+
dpr: receipt.image.dpr,
|
|
3026
|
+
networkDependent: receipt.networkDependent
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
function runtimeIdentity2(receipt) {
|
|
3030
|
+
return { renderer: receipt.renderer, platform: receipt.platform };
|
|
3031
|
+
}
|
|
3032
|
+
function reviewStatus(input) {
|
|
3033
|
+
if (!input.frameMatch) return "frame-mismatch";
|
|
3034
|
+
if (!input.dimensionsMatch) return "dimensions-mismatch";
|
|
3035
|
+
if (!input.rendererMatch) return "runtime-mismatch";
|
|
3036
|
+
if (!input.dataMatch) return "data-mismatch";
|
|
3037
|
+
return "comparable";
|
|
3038
|
+
}
|
|
3039
|
+
function reviewWarnings(status, left, right) {
|
|
3040
|
+
const warnings = [];
|
|
3041
|
+
if (left.networkDependent) {
|
|
3042
|
+
warnings.push("The left capture used remote resources and may not be globally byte-stable.");
|
|
3043
|
+
}
|
|
3044
|
+
if (right.networkDependent) {
|
|
3045
|
+
warnings.push("The right capture used remote resources and may not be globally byte-stable.");
|
|
3046
|
+
}
|
|
3047
|
+
if (left.verification.style !== "rendered" || right.verification.style !== "rendered") {
|
|
3048
|
+
warnings.push(
|
|
3049
|
+
"At least one application capture has expected-but-unverified style and data identity."
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
if (status === "frame-mismatch") {
|
|
3053
|
+
warnings.push("The review frames differ; pixel metrics and contextual diff were not computed.");
|
|
3054
|
+
} else if (status === "dimensions-mismatch") {
|
|
3055
|
+
warnings.push(
|
|
3056
|
+
"The rendered dimensions differ; pixel metrics and contextual diff were not computed."
|
|
3057
|
+
);
|
|
3058
|
+
} else if (status === "runtime-mismatch") {
|
|
3059
|
+
warnings.push(
|
|
3060
|
+
"The renderer runtimes differ; pixel metrics and contextual diff were not computed."
|
|
3061
|
+
);
|
|
3062
|
+
} else if (status === "data-mismatch") {
|
|
3063
|
+
warnings.push(
|
|
3064
|
+
"The exact data identities differ; pixel metrics and contextual diff were not computed."
|
|
3065
|
+
);
|
|
3066
|
+
} else {
|
|
3067
|
+
warnings.push(
|
|
3068
|
+
"Pixel metrics and the contextual diff describe two deliberate renders; they do not approve either image or create a baseline."
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
3071
|
+
return [...new Set(warnings)].sort(compareCodeUnits5);
|
|
3072
|
+
}
|
|
3073
|
+
function sameCanonicalValue(left, right) {
|
|
3074
|
+
return serializeCanonicalJson3(left) === serializeCanonicalJson3(right);
|
|
3075
|
+
}
|
|
3076
|
+
function compareCodeUnits5(left, right) {
|
|
3077
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
3078
|
+
}
|
|
3079
|
+
function invalidReview(message, cause) {
|
|
3080
|
+
return new TileflowVisualReviewError(message, cause === void 0 ? void 0 : { cause });
|
|
3081
|
+
}
|
|
2072
3082
|
export {
|
|
2073
3083
|
TileflowCaptureBrowserManager,
|
|
2074
3084
|
TileflowCaptureError,
|
|
3085
|
+
TileflowCaptureWorldSession,
|
|
3086
|
+
TileflowVisualReviewError,
|
|
2075
3087
|
analyzeTileflowCaptureReference,
|
|
2076
3088
|
captureErrorMessage,
|
|
2077
3089
|
captureTileflowScenes,
|
|
2078
3090
|
compareTileflowCaptureToBaseline,
|
|
3091
|
+
compareTileflowCapturesForReview,
|
|
2079
3092
|
createTileflowCaptureBrowserEnvironment,
|
|
2080
3093
|
createTileflowCaptureReceipt,
|
|
2081
3094
|
createTileflowCaptureRendererIdentity,
|
|
2082
3095
|
createTileflowCaptureSession,
|
|
2083
3096
|
createTileflowVisualComparisonDocument,
|
|
2084
3097
|
createTileflowVisualReferenceAnalysisDocument,
|
|
3098
|
+
createTileflowVisualReviewDocument,
|
|
2085
3099
|
launchTileflowCaptureBrowser,
|
|
2086
3100
|
parseTileflowCaptureReceipt,
|
|
2087
3101
|
resolveTileflowApplicationUrl,
|
|
3102
|
+
resolveTileflowCaptureWorldTileJson,
|
|
2088
3103
|
serializeTileflowCaptureReceipt,
|
|
2089
3104
|
serializeTileflowVisualComparison,
|
|
2090
3105
|
setupTileflowCaptureBrowser,
|
|
@@ -2095,7 +3110,11 @@ export {
|
|
|
2095
3110
|
tileflowSyntheticAssetOrigin,
|
|
2096
3111
|
tileflowVisualArtifactLimits,
|
|
2097
3112
|
tileflowVisualComparisonSchemaVersion,
|
|
3113
|
+
tileflowVisualEdgeThreshold,
|
|
2098
3114
|
tileflowVisualPerceptualThreshold,
|
|
3115
|
+
tileflowVisualReviewLimits,
|
|
3116
|
+
tileflowVisualReviewSchemaVersion,
|
|
3117
|
+
tileflowWorldCurrentTileJsonUrl,
|
|
2099
3118
|
validateTileflowCaptureReceipt,
|
|
2100
3119
|
validateTileflowVisualReferencePng
|
|
2101
3120
|
};
|