@tileflow/capture 0.1.0-alpha.38 → 0.1.0-alpha.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/index.d.ts +43 -2
- package/dist/index.js +353 -27
- package/package.json +14 -11
package/README.md
CHANGED
|
@@ -256,6 +256,16 @@ retained `definition`; when present, that definition must match the explicit rev
|
|
|
256
256
|
`analyzeTileflowCaptureReference` analyzes
|
|
257
257
|
an external PNG without claiming receipt compatibility. Neither operation approves a baseline.
|
|
258
258
|
|
|
259
|
+
## Complete images and warnings
|
|
260
|
+
|
|
261
|
+
A map whose style declares a 3D scene is captured with it mounted, and each image waits until the
|
|
262
|
+
scene has nothing pending, within `timeoutMs` (30 seconds by default). An image whose scene had not
|
|
263
|
+
settled, a dataset the capture is not entitled to read (HTTP 401 or 403, named in the warning) and a
|
|
264
|
+
missing 3D model leave a drawable map: the capture succeeds and `capture.warnings` says what is
|
|
265
|
+
missing. Set `graphics: 'gpu'` on the session to render with the machine's graphics hardware, much
|
|
266
|
+
faster for dense 3D views but with hardware-dependent pixels; keep the default `software` for
|
|
267
|
+
baselines. `session.contactSheet(images)` lays captured PNGs out in one labelled grid.
|
|
268
|
+
|
|
259
269
|
## Errors, receipts, and reproducibility
|
|
260
270
|
|
|
261
271
|
`TileflowCaptureError` reports a stable code, phase, and bounded details. Phases distinguish style
|
package/dist/index.d.ts
CHANGED
|
@@ -5,14 +5,21 @@ import { NormalizedTileflowCaptureShot, NormalizedTileflowCaptureScene, Tileflow
|
|
|
5
5
|
import { TileflowBuildAsset, TileflowIconResolutionOptions, TileflowBuildArtifacts } from '@tileflow/dev/artifacts';
|
|
6
6
|
|
|
7
7
|
type TileflowBrowserInstallProgress = 'installing' | 'installed';
|
|
8
|
+
/**
|
|
9
|
+
* `software` (the default) renders WebGL on the CPU, so images match across machines. `gpu` uses
|
|
10
|
+
* this machine's graphics hardware: many times faster for 3D scenes, but its pixels depend on the
|
|
11
|
+
* hardware and driver, so it suits exploration rather than committed baselines.
|
|
12
|
+
*/
|
|
13
|
+
type TileflowCaptureGraphics = 'software' | 'gpu';
|
|
8
14
|
type TileflowBrowserLaunchOptions = {
|
|
9
15
|
allowInstall?: boolean;
|
|
16
|
+
graphics?: TileflowCaptureGraphics;
|
|
10
17
|
onInstallProgress?: (progress: TileflowBrowserInstallProgress) => void;
|
|
11
18
|
signal?: AbortSignal;
|
|
12
19
|
};
|
|
13
20
|
type TileflowCaptureBrowserDependencies = {
|
|
14
21
|
install(signal?: AbortSignal): Promise<void>;
|
|
15
|
-
launch(): Promise<Browser>;
|
|
22
|
+
launch(graphics?: TileflowCaptureGraphics): Promise<Browser>;
|
|
16
23
|
};
|
|
17
24
|
declare function createTileflowCaptureBrowserEnvironment(source?: NodeJS.ProcessEnv): Record<string, string>;
|
|
18
25
|
declare function launchTileflowCaptureBrowser(options?: TileflowBrowserLaunchOptions): Promise<Browser>;
|
|
@@ -24,6 +31,34 @@ declare class TileflowCaptureBrowserManager {
|
|
|
24
31
|
close(): Promise<void>;
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
/** One captured image on a contact sheet, with the label printed under it. */
|
|
35
|
+
type TileflowContactSheetImage = {
|
|
36
|
+
label: string;
|
|
37
|
+
png: Uint8Array;
|
|
38
|
+
};
|
|
39
|
+
type TileflowContactSheetOptions = {
|
|
40
|
+
/** Images per row; defaults to the square root of the count, at most 4. */
|
|
41
|
+
columns?: number;
|
|
42
|
+
/** Width of each image on the sheet in CSS pixels; defaults to 480. */
|
|
43
|
+
cellWidth?: number;
|
|
44
|
+
};
|
|
45
|
+
declare const tileflowContactSheetLimits: Readonly<{
|
|
46
|
+
cellWidth: {
|
|
47
|
+
maximum: number;
|
|
48
|
+
minimum: number;
|
|
49
|
+
};
|
|
50
|
+
columns: {
|
|
51
|
+
maximum: number;
|
|
52
|
+
minimum: number;
|
|
53
|
+
};
|
|
54
|
+
images: 64;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Lays captured images out in one labelled grid PNG, for choosing between candidates at a glance.
|
|
58
|
+
* The sheet renders offline in the given browser: it loads nothing but the images it is given.
|
|
59
|
+
*/
|
|
60
|
+
declare function renderTileflowContactSheet(browser: Browser, images: readonly TileflowContactSheetImage[], options?: TileflowContactSheetOptions): Promise<Uint8Array>;
|
|
61
|
+
|
|
27
62
|
declare const tileflowSyntheticAssetOrigin = "https://tileflow.local.invalid";
|
|
28
63
|
type TileflowCaptureAsset = TileflowBuildAsset & {
|
|
29
64
|
url?: string;
|
|
@@ -83,6 +118,8 @@ type TileflowCaptureOptions = {
|
|
|
83
118
|
icons?: TileflowIconResolutionOptions;
|
|
84
119
|
onBrowserInstallProgress?: (progress: TileflowBrowserInstallProgress) => void;
|
|
85
120
|
frame?: 'map' | 'viewport';
|
|
121
|
+
/** How the browser renders WebGL; see `TileflowCaptureGraphics`. Defaults to `software`. */
|
|
122
|
+
graphics?: TileflowCaptureGraphics;
|
|
86
123
|
scenes: string[];
|
|
87
124
|
signal?: AbortSignal;
|
|
88
125
|
selector?: string;
|
|
@@ -139,6 +176,10 @@ type TileflowCaptureSession = {
|
|
|
139
176
|
/** Renders committed shots in name order from one compiled artifact snapshot. */
|
|
140
177
|
captureShots(shots: string[], options?: TileflowShotCaptureOptions): Promise<TileflowShotCaptureResult>;
|
|
141
178
|
captureAllShots(options?: TileflowShotCaptureOptions): Promise<TileflowShotCaptureResult>;
|
|
179
|
+
/** Lays captured images out in one labelled grid PNG, with this session's browser. */
|
|
180
|
+
contactSheet(images: readonly TileflowContactSheetImage[], options?: TileflowContactSheetOptions & {
|
|
181
|
+
signal?: AbortSignal;
|
|
182
|
+
}): Promise<Uint8Array>;
|
|
142
183
|
close(): Promise<void>;
|
|
143
184
|
};
|
|
144
185
|
declare function captureTileflowScenes(options: TileflowCaptureOptions): Promise<TileflowCaptureResult>;
|
|
@@ -428,4 +469,4 @@ declare const tileflowVisualReviewLimits: Readonly<{
|
|
|
428
469
|
maximumAggregatePngBytes: number;
|
|
429
470
|
}>;
|
|
430
471
|
|
|
431
|
-
export { type CaptureTileflowShotOptions, type CreateTileflowCaptureSessionOptions, type PreparedTileflowCaptureStyle, type TileflowBrowserInstallProgress, type TileflowBrowserLaunchOptions, type TileflowCapture, TileflowCaptureBrowserManager, TileflowCaptureDataInput, type TileflowCaptureDiagnostic, TileflowCaptureError, type TileflowCaptureErrorCode, type TileflowCaptureErrorDetails, type TileflowCaptureOptions, type TileflowCapturePhase, TileflowCaptureReceipt, TileflowCaptureRendererIdentity, type TileflowCaptureResolvedWorldV1, type TileflowCaptureResourceDiagnostic, type TileflowCaptureResourceKind, type TileflowCaptureResult, type TileflowCaptureSession, TileflowCaptureWorldIdentity, TileflowCaptureWorldSession, type TileflowShotCapture, type TileflowShotCaptureOptions, type TileflowShotCaptureResult, type TileflowShotFormat, type TileflowShotFrame, TileflowShotReceiptV1, type TileflowShotVideoCodec, type TileflowVisualAppearanceDelta, type TileflowVisualAppearanceProfile, type TileflowVisualAppearanceStatistics, type TileflowVisualBaseline, type TileflowVisualComparison, type TileflowVisualComparisonDocument, type TileflowVisualComparisonStatus, type TileflowVisualImageIdentity, type TileflowVisualPaletteColor, type TileflowVisualPixelMetric, type TileflowVisualReferenceAnalysis, type TileflowVisualReferenceAnalysisDocument, type TileflowVisualReferenceAnalysisOptions, type TileflowVisualReferenceAppearance, type TileflowVisualRegion, type TileflowVisualReviewAppearance, type TileflowVisualReviewCapture, type TileflowVisualReviewComparison, type TileflowVisualReviewDefinition, type TileflowVisualReviewDocument, TileflowVisualReviewError, type TileflowVisualReviewFrameIdentity, type TileflowVisualReviewOptions, type TileflowVisualReviewSide, type TileflowVisualReviewStatus, type TileflowVisualRuntimeIdentity, type TileflowVisualSceneIdentity, analyzeTileflowCaptureReference, captureErrorMessage, captureTileflowScenes, captureTileflowShot, compareTileflowCaptureToBaseline, compareTileflowCapturesForReview, createTileflowCaptureBrowserEnvironment, createTileflowCaptureSession, createTileflowVisualComparisonDocument, createTileflowVisualReferenceAnalysisDocument, createTileflowVisualReviewDocument, launchTileflowCaptureBrowser, resolveTileflowApplicationUrl, resolveTileflowCaptureWorldTileJson, serializeTileflowVisualComparison, setupTileflowCaptureBrowser, tileflowCaptureResultSchemaVersion, tileflowShotFramesSha256, tileflowSyntheticAssetOrigin, tileflowVisualArtifactLimits, tileflowVisualComparisonSchemaVersion, tileflowVisualEdgeThreshold, tileflowVisualPerceptualThreshold, tileflowVisualReviewLimits, tileflowVisualReviewSchemaVersion, tileflowWorldCurrentTileJsonUrl, validateTileflowVisualReferencePng };
|
|
472
|
+
export { type CaptureTileflowShotOptions, type CreateTileflowCaptureSessionOptions, type PreparedTileflowCaptureStyle, type TileflowBrowserInstallProgress, type TileflowBrowserLaunchOptions, type TileflowCapture, TileflowCaptureBrowserManager, TileflowCaptureDataInput, type TileflowCaptureDiagnostic, TileflowCaptureError, type TileflowCaptureErrorCode, type TileflowCaptureErrorDetails, type TileflowCaptureGraphics, type TileflowCaptureOptions, type TileflowCapturePhase, TileflowCaptureReceipt, TileflowCaptureRendererIdentity, type TileflowCaptureResolvedWorldV1, type TileflowCaptureResourceDiagnostic, type TileflowCaptureResourceKind, type TileflowCaptureResult, type TileflowCaptureSession, TileflowCaptureWorldIdentity, TileflowCaptureWorldSession, type TileflowContactSheetImage, type TileflowContactSheetOptions, type TileflowShotCapture, type TileflowShotCaptureOptions, type TileflowShotCaptureResult, type TileflowShotFormat, type TileflowShotFrame, TileflowShotReceiptV1, type TileflowShotVideoCodec, type TileflowVisualAppearanceDelta, type TileflowVisualAppearanceProfile, type TileflowVisualAppearanceStatistics, type TileflowVisualBaseline, type TileflowVisualComparison, type TileflowVisualComparisonDocument, type TileflowVisualComparisonStatus, type TileflowVisualImageIdentity, type TileflowVisualPaletteColor, type TileflowVisualPixelMetric, type TileflowVisualReferenceAnalysis, type TileflowVisualReferenceAnalysisDocument, type TileflowVisualReferenceAnalysisOptions, type TileflowVisualReferenceAppearance, type TileflowVisualRegion, type TileflowVisualReviewAppearance, type TileflowVisualReviewCapture, type TileflowVisualReviewComparison, type TileflowVisualReviewDefinition, type TileflowVisualReviewDocument, TileflowVisualReviewError, type TileflowVisualReviewFrameIdentity, type TileflowVisualReviewOptions, type TileflowVisualReviewSide, type TileflowVisualReviewStatus, type TileflowVisualRuntimeIdentity, type TileflowVisualSceneIdentity, analyzeTileflowCaptureReference, captureErrorMessage, captureTileflowScenes, captureTileflowShot, compareTileflowCaptureToBaseline, compareTileflowCapturesForReview, createTileflowCaptureBrowserEnvironment, createTileflowCaptureSession, createTileflowVisualComparisonDocument, createTileflowVisualReferenceAnalysisDocument, createTileflowVisualReviewDocument, launchTileflowCaptureBrowser, renderTileflowContactSheet, resolveTileflowApplicationUrl, resolveTileflowCaptureWorldTileJson, serializeTileflowVisualComparison, setupTileflowCaptureBrowser, tileflowCaptureResultSchemaVersion, tileflowContactSheetLimits, tileflowShotFramesSha256, tileflowSyntheticAssetOrigin, tileflowVisualArtifactLimits, tileflowVisualComparisonSchemaVersion, tileflowVisualEdgeThreshold, tileflowVisualPerceptualThreshold, tileflowVisualReviewLimits, tileflowVisualReviewSchemaVersion, tileflowWorldCurrentTileJsonUrl, validateTileflowVisualReferencePng };
|
package/dist/index.js
CHANGED
|
@@ -116,7 +116,7 @@ async function launchTileflowCaptureBrowser(options = {}) {
|
|
|
116
116
|
async function launchTileflowCaptureBrowserWithDependencies(options, dependencies) {
|
|
117
117
|
throwIfAborted(options.signal);
|
|
118
118
|
try {
|
|
119
|
-
return await dependencies.launch();
|
|
119
|
+
return await dependencies.launch(options.graphics);
|
|
120
120
|
} catch (error) {
|
|
121
121
|
if (!isMissingBrowserError(error)) {
|
|
122
122
|
throw new TileflowCaptureError(
|
|
@@ -137,7 +137,7 @@ async function launchTileflowCaptureBrowserWithDependencies(options, dependencie
|
|
|
137
137
|
throwIfAborted(options.signal);
|
|
138
138
|
options.onInstallProgress?.("installed");
|
|
139
139
|
try {
|
|
140
|
-
return await dependencies.launch();
|
|
140
|
+
return await dependencies.launch(options.graphics);
|
|
141
141
|
} catch (retryError) {
|
|
142
142
|
throw new TileflowCaptureError(
|
|
143
143
|
"BROWSER_START_FAILED",
|
|
@@ -224,11 +224,21 @@ var windowsChromiumDisabledFeatures = [
|
|
|
224
224
|
// Avoid intermittent Windows loopback failures: https://issues.chromium.org/issues/572515601.
|
|
225
225
|
"TcpPortRandomizationWin"
|
|
226
226
|
];
|
|
227
|
-
function
|
|
227
|
+
function tileflowGpuBrowserArguments(platform = process.platform) {
|
|
228
|
+
const backend = platform === "darwin" ? "metal" : platform === "win32" ? "d3d11" : "vulkan";
|
|
229
|
+
return [
|
|
230
|
+
"--enable-gpu",
|
|
231
|
+
"--ignore-gpu-blocklist",
|
|
232
|
+
`--use-angle=${backend}`,
|
|
233
|
+
...platform === "linux" ? ["--enable-features=Vulkan"] : []
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
function launchPinnedBrowser(graphics = "software") {
|
|
228
237
|
return chromium.launch({
|
|
229
238
|
args: [
|
|
230
239
|
"--disable-dev-shm-usage",
|
|
231
|
-
...process.platform === "win32" ? [`--disable-features=${windowsChromiumDisabledFeatures.join(",")}`] : []
|
|
240
|
+
...process.platform === "win32" ? [`--disable-features=${windowsChromiumDisabledFeatures.join(",")}`] : [],
|
|
241
|
+
...graphics === "gpu" ? tileflowGpuBrowserArguments() : []
|
|
232
242
|
],
|
|
233
243
|
chromiumSandbox: true,
|
|
234
244
|
env: createTileflowCaptureBrowserEnvironment(),
|
|
@@ -347,6 +357,12 @@ import { readFileSync as readFileSync2 } from "fs";
|
|
|
347
357
|
import { open } from "fs/promises";
|
|
348
358
|
import { createRequire as createRequire2 } from "module";
|
|
349
359
|
import { dirname as dirname2, join as join2 } from "path";
|
|
360
|
+
import {
|
|
361
|
+
tileflowBuildingsMetadataKey,
|
|
362
|
+
tileflowStreetLightsMetadataKey,
|
|
363
|
+
tileflowVegetationDisplayMetadataKey,
|
|
364
|
+
tileflowVegetationMetadataKey
|
|
365
|
+
} from "@tileflow/core";
|
|
350
366
|
import { getTileflowStyleFontFaces } from "@tileflow/core/runtime";
|
|
351
367
|
import {
|
|
352
368
|
createTileflowLineBackgroundGlyphResponder,
|
|
@@ -374,10 +390,12 @@ globalThis.__tileflowRegisterContourProtocol = registerTileflowContourProtocol;
|
|
|
374
390
|
globalThis.__tileflowRegisterPathGroundProtocol = registerTileflowPathGroundProtocol;
|
|
375
391
|
globalThis.__tileflowRegisterPmtilesProtocol = registerTileflowPmtilesProtocol;
|
|
376
392
|
globalThis.__tileflowAttachAtmosphere = attachTileflowAtmosphere;
|
|
393
|
+
globalThis.__tileflowAttachBranding = attachTileflowBranding;
|
|
377
394
|
globalThis.__tileflowAttachNotices = attachTileflowNotices;
|
|
378
395
|
globalThis.__tileflowAttachNauticalLights = attachTileflowNauticalLights;
|
|
379
396
|
globalThis.__tileflowMaxPitch = tileflowMaxPitch;
|
|
380
397
|
globalThis.__tileflowAttachLabelPlacement = attachTileflowLabelPlacement;
|
|
398
|
+
globalThis.__tileflowCreateMap3D = createTileflowMap3D;
|
|
381
399
|
globalThis.__tileflowCreateThemeController = createTileflowThemeController;`;
|
|
382
400
|
async function captureStandaloneTileflowScene(input) {
|
|
383
401
|
if (input.scene.target.kind !== "map") {
|
|
@@ -429,6 +447,8 @@ async function withStandaloneTileflowPage(input, run) {
|
|
|
429
447
|
let context;
|
|
430
448
|
let phase = "browser-start";
|
|
431
449
|
let timeout;
|
|
450
|
+
let budgetStartedAt = Date.now();
|
|
451
|
+
const pageWarnings = /* @__PURE__ */ new Set();
|
|
432
452
|
const onAbort = () => {
|
|
433
453
|
termination = "aborted";
|
|
434
454
|
void context?.close().catch(() => void 0);
|
|
@@ -439,6 +459,7 @@ async function withStandaloneTileflowPage(input, run) {
|
|
|
439
459
|
};
|
|
440
460
|
const restartTimeout = () => {
|
|
441
461
|
stopTimeout();
|
|
462
|
+
budgetStartedAt = Date.now();
|
|
442
463
|
timeout = setTimeout(() => {
|
|
443
464
|
termination = "timeout";
|
|
444
465
|
void context?.close().catch(() => void 0);
|
|
@@ -483,6 +504,11 @@ async function withStandaloneTileflowPage(input, run) {
|
|
|
483
504
|
const resourceFailures = [];
|
|
484
505
|
let pageFailed = false;
|
|
485
506
|
const recordResourceFailure = (url, status) => {
|
|
507
|
+
const degraded = describeDegradedResource(url, status);
|
|
508
|
+
if (degraded) {
|
|
509
|
+
if (pageWarnings.size < 32) pageWarnings.add(degraded);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
486
512
|
const diagnostic = classifyResource(url, status);
|
|
487
513
|
if (!diagnostic || resourceFailures.length >= 16) return;
|
|
488
514
|
const key = JSON.stringify(diagnostic);
|
|
@@ -590,7 +616,10 @@ async function withStandaloneTileflowPage(input, run) {
|
|
|
590
616
|
recordResourceFailure(response.url(), response.status());
|
|
591
617
|
}
|
|
592
618
|
});
|
|
593
|
-
page.on("requestfailed", (request) =>
|
|
619
|
+
page.on("requestfailed", (request) => {
|
|
620
|
+
if (request.failure()?.errorText === "net::ERR_ABORTED") return;
|
|
621
|
+
recordResourceFailure(request.url());
|
|
622
|
+
});
|
|
594
623
|
page.setDefaultTimeout(timeoutMs);
|
|
595
624
|
await page.goto(mapLibreRuntimeUrls.document, { waitUntil: "domcontentloaded" });
|
|
596
625
|
await page.addStyleTag({ url: mapLibreRuntimeUrls.css });
|
|
@@ -605,16 +634,31 @@ globalThis.maplibregl = maplibregl;`,
|
|
|
605
634
|
);
|
|
606
635
|
await page.addScriptTag({ content: tileflowBrowserModule, type: "module" });
|
|
607
636
|
await page.addScriptTag({ content: renderMapInPageScript });
|
|
637
|
+
if (input.graphics === "gpu") {
|
|
638
|
+
const renderer = await page.evaluate(() => {
|
|
639
|
+
const context2 = document.createElement("canvas").getContext("webgl2");
|
|
640
|
+
const info = context2?.getExtension("WEBGL_debug_renderer_info");
|
|
641
|
+
return info ? String(context2.getParameter(info.UNMASKED_RENDERER_WEBGL)) : "";
|
|
642
|
+
});
|
|
643
|
+
pageWarnings.add(
|
|
644
|
+
/swiftshader|llvmpipe|software/iu.test(renderer) || renderer === "" ? "GPU rendering is unavailable on this machine; the capture used software rendering." : "Rendered with this machine's GPU: pixels depend on its hardware and driver, so compare it only with other GPU captures from the same machine."
|
|
645
|
+
);
|
|
646
|
+
}
|
|
608
647
|
const step = async (stepPhase, name, value) => {
|
|
609
648
|
phase = stepPhase;
|
|
649
|
+
const reserveMs = Math.max(1e4, Math.round(timeoutMs * 0.35));
|
|
650
|
+
const sceneBudgetMs = Math.max(0, timeoutMs - (Date.now() - budgetStartedAt) - reserveMs);
|
|
610
651
|
const result = await page.evaluate(
|
|
611
652
|
({ name: functionName, value: stepInput }) => {
|
|
612
653
|
const renderWindow = window;
|
|
613
654
|
return renderWindow[functionName](stepInput);
|
|
614
655
|
},
|
|
615
|
-
{ name, value }
|
|
656
|
+
{ name, value: { ...value, sceneBudgetMs } }
|
|
616
657
|
);
|
|
617
658
|
assertPhaseSucceeded(result, stepPhase, syntheticAssetFailure, resourceFailures, pageFailed);
|
|
659
|
+
if (result.status === "ok") {
|
|
660
|
+
for (const warning of result.warnings ?? []) pageWarnings.add(safeSceneWarning(warning));
|
|
661
|
+
}
|
|
618
662
|
};
|
|
619
663
|
return await run({
|
|
620
664
|
page,
|
|
@@ -662,7 +706,10 @@ globalThis.maplibregl = maplibregl;`,
|
|
|
662
706
|
result() {
|
|
663
707
|
return {
|
|
664
708
|
networkDependent: remoteOrigins.size > 0,
|
|
665
|
-
warnings: [
|
|
709
|
+
warnings: [
|
|
710
|
+
...[...remoteOrigins].sort(compareCodeUnits).map((origin) => `Capture requested remote resources from ${origin}.`),
|
|
711
|
+
...[...pageWarnings].sort(compareCodeUnits)
|
|
712
|
+
]
|
|
666
713
|
};
|
|
667
714
|
}
|
|
668
715
|
});
|
|
@@ -671,10 +718,11 @@ globalThis.maplibregl = maplibregl;`,
|
|
|
671
718
|
throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
|
|
672
719
|
}
|
|
673
720
|
if (termination === "timeout") {
|
|
674
|
-
throw new TileflowCaptureError(
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
721
|
+
throw new TileflowCaptureError(
|
|
722
|
+
"CAPTURE_TIMEOUT",
|
|
723
|
+
`${timeoutMessage(phase)}${slowSceneHint(input.style, input.graphics, phase)}`,
|
|
724
|
+
{ cause: error, details: { phase } }
|
|
725
|
+
);
|
|
678
726
|
}
|
|
679
727
|
if (error instanceof CallerFailure) {
|
|
680
728
|
throw error.error;
|
|
@@ -925,6 +973,41 @@ function classifyResource(value, status) {
|
|
|
925
973
|
...status === void 0 ? {} : { status }
|
|
926
974
|
};
|
|
927
975
|
}
|
|
976
|
+
function describeDegradedResource(value, status) {
|
|
977
|
+
if (status === void 0) return void 0;
|
|
978
|
+
let url;
|
|
979
|
+
try {
|
|
980
|
+
url = new URL(value);
|
|
981
|
+
} catch {
|
|
982
|
+
return void 0;
|
|
983
|
+
}
|
|
984
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
985
|
+
const kind = classifyResource(value, status)?.kind;
|
|
986
|
+
if (kind === "glyph" || kind === "sprite-json" || kind === "sprite-image") return void 0;
|
|
987
|
+
const dataset = tileflowDatasetOf(url);
|
|
988
|
+
if (status === 401 || status === 403) {
|
|
989
|
+
const subject = dataset ? `Tileflow dataset "${dataset}"` : `Data from ${url.origin}`;
|
|
990
|
+
return `${subject} returned HTTP ${status}: this capture is not entitled to read it, so its layers are missing from the image.`;
|
|
991
|
+
}
|
|
992
|
+
if (status === 404 && isModelResource(url)) {
|
|
993
|
+
return `A 3D model from ${dataset ? `Tileflow dataset "${dataset}"` : url.origin} returned HTTP 404; the buildings under it were captured instead.`;
|
|
994
|
+
}
|
|
995
|
+
return void 0;
|
|
996
|
+
}
|
|
997
|
+
function tileflowDatasetOf(url) {
|
|
998
|
+
const match = /^\/(?:v1\/)?tiles\/([^/]+)\//u.exec(url.pathname);
|
|
999
|
+
if (!match) return void 0;
|
|
1000
|
+
try {
|
|
1001
|
+
const name = decodeURIComponent(match[1]);
|
|
1002
|
+
return /^[a-z0-9][a-z0-9,_-]{0,63}$/u.test(name) ? name : void 0;
|
|
1003
|
+
} catch {
|
|
1004
|
+
return void 0;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function isModelResource(url) {
|
|
1008
|
+
const path = url.pathname.toLowerCase();
|
|
1009
|
+
return path.endsWith(".glb") || /\/(?:landmarks|models)(?:\/|$)/u.test(path);
|
|
1010
|
+
}
|
|
928
1011
|
function fontStackContext(path) {
|
|
929
1012
|
const segments = path.split("/").filter(Boolean);
|
|
930
1013
|
const index = segments.findIndex((segment) => /^(?:fonts?|glyphs?)$/i.test(segment));
|
|
@@ -951,6 +1034,20 @@ function resourceLabel(kind) {
|
|
|
951
1034
|
"vector-tile": "Vector tile"
|
|
952
1035
|
}[kind];
|
|
953
1036
|
}
|
|
1037
|
+
var sceneMetadataKeys = [
|
|
1038
|
+
tileflowBuildingsMetadataKey,
|
|
1039
|
+
tileflowVegetationMetadataKey,
|
|
1040
|
+
tileflowVegetationDisplayMetadataKey,
|
|
1041
|
+
tileflowStreetLightsMetadataKey
|
|
1042
|
+
];
|
|
1043
|
+
function slowSceneHint(style, graphics, phase) {
|
|
1044
|
+
if (graphics === "gpu" || phase !== "map-idle" && phase !== "screenshot") return "";
|
|
1045
|
+
const metadata = style.metadata;
|
|
1046
|
+
if (!sceneMetadataKeys.some((key) => metadata?.[key] !== void 0 && metadata[key] !== null)) {
|
|
1047
|
+
return "";
|
|
1048
|
+
}
|
|
1049
|
+
return " Software rendering draws a dense 3D scene slowly: raise --timeout, or pass --gpu for exploratory captures.";
|
|
1050
|
+
}
|
|
954
1051
|
function timeoutMessage(phase) {
|
|
955
1052
|
if (phase === "map-load") return "MapLibre did not finish loading before the capture timeout.";
|
|
956
1053
|
if (phase === "theme-blend") return "The theme blend was not ready before the capture timeout.";
|
|
@@ -966,6 +1063,9 @@ function mapFailureMessage(phase) {
|
|
|
966
1063
|
function safeMapError(value) {
|
|
967
1064
|
return value.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g, "resource").replace(/[^A-Za-z0-9 _.,:'"@+()[\]/=-]/g, "").trim().slice(0, 200);
|
|
968
1065
|
}
|
|
1066
|
+
function safeSceneWarning(value) {
|
|
1067
|
+
return value.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g, "resource").replace(/[^A-Za-z0-9 _.,:;'"@+()[\]/=-]/g, "").trim().slice(0, 300);
|
|
1068
|
+
}
|
|
969
1069
|
function safeAssetName(value) {
|
|
970
1070
|
return value.replace(/[^A-Za-z0-9._/-]/g, "").slice(0, 200) || "unknown";
|
|
971
1071
|
}
|
|
@@ -1043,10 +1143,22 @@ window.__tileflowCaptureLoad = async function(input) {
|
|
|
1043
1143
|
window.__tileflowCaptureStyle = input.style;
|
|
1044
1144
|
window.__tileflowAttachAtmosphere(map);
|
|
1045
1145
|
// Notices such as "Not for navigation" are part of the map, so captured images keep them.
|
|
1146
|
+
window.__tileflowAttachBranding(map);
|
|
1046
1147
|
window.__tileflowAttachNotices(map);
|
|
1047
1148
|
// Capture prefers reduced motion, so Nautical lights stay steady and every image is stable.
|
|
1048
1149
|
window.__tileflowAttachNauticalLights(map);
|
|
1049
1150
|
window.__tileflowAttachLabelPlacement(map);
|
|
1151
|
+
// The 3D scene the style declares (buildings, landmarks and Team models, trees, street
|
|
1152
|
+
// lights), mounted as an application and the preview mount it, so captures show the same map.
|
|
1153
|
+
window.__tileflowSceneErrors = [];
|
|
1154
|
+
window.__tileflowScene = window.__tileflowCreateMap3D?.({
|
|
1155
|
+
map,
|
|
1156
|
+
onError: (error) => {
|
|
1157
|
+
if (window.__tileflowSceneErrors.length < 4) {
|
|
1158
|
+
window.__tileflowSceneErrors.push(String(error?.message || error || "unknown error"));
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1050
1162
|
return await waitForMapEvent(map, "load", input.mapEventTimeoutMs);
|
|
1051
1163
|
} catch (error) {
|
|
1052
1164
|
return {status: "failed", reason: "error", message: String(error?.message || error || "")};
|
|
@@ -1104,14 +1216,11 @@ async function loadTileflowFontFaces(definitions) {
|
|
|
1104
1216
|
window.__tileflowCaptureIdle = async function(input) {
|
|
1105
1217
|
const map = window.__tileflowMap;
|
|
1106
1218
|
if (!map) return {status: "failed", reason: "error"};
|
|
1219
|
+
releaseFrames(map);
|
|
1107
1220
|
|
|
1108
1221
|
try {
|
|
1109
1222
|
applyCamera(map, input.camera);
|
|
1110
|
-
|
|
1111
|
-
if (result.status === "failed") return result;
|
|
1112
|
-
await nextFrame();
|
|
1113
|
-
await nextFrame();
|
|
1114
|
-
return {status: "ok"};
|
|
1223
|
+
return await settleMap(map, input);
|
|
1115
1224
|
} catch {
|
|
1116
1225
|
return {status: "failed", reason: "error"};
|
|
1117
1226
|
}
|
|
@@ -1121,6 +1230,7 @@ window.__tileflowCaptureIdle = async function(input) {
|
|
|
1121
1230
|
window.__tileflowCapturePrepareBlend = async function(input) {
|
|
1122
1231
|
const map = window.__tileflowMap;
|
|
1123
1232
|
if (!map) return {status: "failed", reason: "error"};
|
|
1233
|
+
releaseFrames(map);
|
|
1124
1234
|
|
|
1125
1235
|
try {
|
|
1126
1236
|
const controller = window.__tileflowCreateThemeController({
|
|
@@ -1145,6 +1255,7 @@ window.__tileflowCapturePrepareBlend = async function(input) {
|
|
|
1145
1255
|
window.__tileflowCaptureFrame = async function(input) {
|
|
1146
1256
|
const map = window.__tileflowMap;
|
|
1147
1257
|
if (!map) return {status: "failed", reason: "error"};
|
|
1258
|
+
releaseFrames(map);
|
|
1148
1259
|
|
|
1149
1260
|
try {
|
|
1150
1261
|
const blend = window.__tileflowShotBlend;
|
|
@@ -1159,17 +1270,110 @@ window.__tileflowCaptureFrame = async function(input) {
|
|
|
1159
1270
|
}
|
|
1160
1271
|
applyCamera(map, input.camera);
|
|
1161
1272
|
map.triggerRepaint();
|
|
1273
|
+
return await settleMap(map, input);
|
|
1274
|
+
} catch {
|
|
1275
|
+
return {status: "failed", reason: "error"};
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
// MapLibre's idle covers the style's own sources only, and a 3D scene still preparing keeps asking
|
|
1280
|
+
// for frames, so on a slow renderer the map may not become idle. With a 3D scene the capture waits until
|
|
1281
|
+
// the map's tiles have loaded and the scene has nothing pending, across a few consecutive checks.
|
|
1282
|
+
async function settleMap(map, input) {
|
|
1283
|
+
const scene = window.__tileflowScene;
|
|
1284
|
+
if (!scene) {
|
|
1162
1285
|
const result = await waitForMapEvent(map, "idle", input.mapEventTimeoutMs);
|
|
1163
1286
|
if (result.status === "failed") return result;
|
|
1164
1287
|
await nextFrame();
|
|
1165
1288
|
await nextFrame();
|
|
1166
|
-
return {status: "ok"};
|
|
1167
|
-
} catch {
|
|
1168
|
-
return {status: "failed", reason: "error"};
|
|
1289
|
+
return {status: "ok", warnings: []};
|
|
1169
1290
|
}
|
|
1170
|
-
|
|
1291
|
+
const warnings = [];
|
|
1292
|
+
const budget = Math.max(0, Math.min(input.mapEventTimeoutMs, input.sceneBudgetMs ?? 0));
|
|
1293
|
+
const deadline = performance.now() + budget;
|
|
1294
|
+
const failure = watchMapErrors(map);
|
|
1295
|
+
let quiet = 0;
|
|
1296
|
+
let pending = scene.pending();
|
|
1297
|
+
try {
|
|
1298
|
+
// The scene asks for the frames it needs; forcing more would only slow a software renderer.
|
|
1299
|
+
while (quiet < 3 && performance.now() < deadline) {
|
|
1300
|
+
await delay(100);
|
|
1301
|
+
await nextFrame();
|
|
1302
|
+
if (failure.result) return failure.result;
|
|
1303
|
+
pending = scene.pending();
|
|
1304
|
+
const busy = pending.buildingTiles + pending.models + pending.treeTiles + pending.transitions;
|
|
1305
|
+
quiet = busy === 0 && map.loaded() ? quiet + 1 : 0;
|
|
1306
|
+
}
|
|
1307
|
+
} finally {
|
|
1308
|
+
failure.stop();
|
|
1309
|
+
}
|
|
1310
|
+
if (quiet < 3) {
|
|
1311
|
+
const parts = [];
|
|
1312
|
+
if (!map.loaded()) parts.push("map tiles");
|
|
1313
|
+
if (pending.buildingTiles) parts.push(pending.buildingTiles + " building tiles");
|
|
1314
|
+
if (pending.models) parts.push(pending.models + " models");
|
|
1315
|
+
if (pending.treeTiles) parts.push(pending.treeTiles + " tree tiles");
|
|
1316
|
+
if (pending.transitions) parts.push(pending.transitions + " transitions");
|
|
1317
|
+
// Map tiles still loading are a MapLibre failure to settle, as without a 3D scene.
|
|
1318
|
+
if (!map.loaded() && parts.length === 1) return {status: "failed", reason: "timeout"};
|
|
1319
|
+
warnings.push(
|
|
1320
|
+
"The 3D scene was still loading when the capture budget ran out (" +
|
|
1321
|
+
parts.join(", ") +
|
|
1322
|
+
" pending after " + Math.round(budget / 1000) + " s); the image may be incomplete. Raise --timeout, or pass --gpu for exploratory captures."
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
for (const message of window.__tileflowSceneErrors || []) {
|
|
1326
|
+
warnings.push("The 3D scene failed to draw (" + message + "); the flat map was captured instead.");
|
|
1327
|
+
}
|
|
1328
|
+
if (quiet >= 3) {
|
|
1329
|
+
map.triggerRepaint();
|
|
1330
|
+
await nextFrame();
|
|
1331
|
+
await nextFrame();
|
|
1332
|
+
}
|
|
1333
|
+
holdFrames(map);
|
|
1334
|
+
return {status: "ok", warnings};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Keeps the last frame on screen while the PNG is taken: on a software renderer a 3D scene that
|
|
1338
|
+
// is still preparing would otherwise queue frames that take seconds each and stall the screenshot.
|
|
1339
|
+
// The next frame step releases it.
|
|
1340
|
+
function holdFrames(map) {
|
|
1341
|
+
if (map.__tileflowHeldRepaint) return;
|
|
1342
|
+
map.__tileflowHeldRepaint = map.triggerRepaint;
|
|
1343
|
+
map.triggerRepaint = () => map;
|
|
1344
|
+
}
|
|
1171
1345
|
|
|
1346
|
+
function releaseFrames(map) {
|
|
1347
|
+
if (!map.__tileflowHeldRepaint) return;
|
|
1348
|
+
map.triggerRepaint = map.__tileflowHeldRepaint;
|
|
1349
|
+
map.__tileflowHeldRepaint = undefined;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Reports the first map error while the scene settles, with the idle wait's tolerance for source
|
|
1353
|
+
// layers a TileJSON advertises but one tile lacks.
|
|
1354
|
+
function watchMapErrors(map) {
|
|
1355
|
+
const state = {result: undefined, stop: () => undefined};
|
|
1356
|
+
const pending = waitForMapEvent(map, "tileflow:never", 2147483647);
|
|
1357
|
+
pending.then((result) => {
|
|
1358
|
+
if (result.status === "failed" && result.reason === "error") state.result = result;
|
|
1359
|
+
});
|
|
1360
|
+
state.stop = () => pending.cancel?.();
|
|
1361
|
+
return state;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function delay(milliseconds) {
|
|
1365
|
+
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// A map's pitch cap can depend on zoom (a 3D scene lowers it over the overview), so a camera
|
|
1369
|
+
// that changes both is applied again once the new zoom has lifted the cap.
|
|
1172
1370
|
function applyCamera(mapInstance, camera) {
|
|
1371
|
+
moveCamera(mapInstance, camera);
|
|
1372
|
+
const pitch = camera.pitch ?? 0;
|
|
1373
|
+
if (Math.abs(mapInstance.getPitch() - pitch) > 1e-6) moveCamera(mapInstance, camera);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function moveCamera(mapInstance, camera) {
|
|
1173
1377
|
if (camera.type === "bounds") {
|
|
1174
1378
|
mapInstance.fitBounds(
|
|
1175
1379
|
[
|
|
@@ -1195,7 +1399,8 @@ function applyCamera(mapInstance, camera) {
|
|
|
1195
1399
|
}
|
|
1196
1400
|
|
|
1197
1401
|
function waitForMapEvent(mapInstance, event, timeoutMs) {
|
|
1198
|
-
|
|
1402
|
+
let cancel;
|
|
1403
|
+
const promise = new Promise((resolve) => {
|
|
1199
1404
|
const timeout = window.setTimeout(() => {
|
|
1200
1405
|
cleanup();
|
|
1201
1406
|
resolve({status: "failed", reason: "timeout"});
|
|
@@ -1205,6 +1410,15 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
|
|
|
1205
1410
|
resolve({status: "ok"});
|
|
1206
1411
|
};
|
|
1207
1412
|
const onError = (errorEvent) => {
|
|
1413
|
+
// Data the key cannot read leaves the rest of the map drawable; the capture reports it as a
|
|
1414
|
+
// warning naming the dataset instead of failing.
|
|
1415
|
+
const status = errorEvent?.error?.status;
|
|
1416
|
+
if (status === 401 || status === 403) {
|
|
1417
|
+
// MapLibre never finishes loading a style whose source it could not read; the source and
|
|
1418
|
+
// its layers come off, so the rest of the map loads and draws.
|
|
1419
|
+
if (errorEvent?.sourceId) removeRefusedSource(mapInstance, errorEvent.sourceId);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1208
1422
|
const message = String(errorEvent?.error?.message || errorEvent?.message || "");
|
|
1209
1423
|
const missingSourceLayer = message.match(
|
|
1210
1424
|
/^Source layer "(.+)" does not exist on source "(.+)" as specified by style layer ".+"\.$/
|
|
@@ -1234,9 +1448,27 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
|
|
|
1234
1448
|
mapInstance.off("error", onError);
|
|
1235
1449
|
};
|
|
1236
1450
|
|
|
1451
|
+
cancel = () => {
|
|
1452
|
+
cleanup();
|
|
1453
|
+
resolve({status: "cancelled"});
|
|
1454
|
+
};
|
|
1237
1455
|
mapInstance.once(event, onSuccess);
|
|
1238
1456
|
mapInstance.on("error", onError);
|
|
1239
1457
|
});
|
|
1458
|
+
promise.cancel = cancel;
|
|
1459
|
+
return promise;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function removeRefusedSource(mapInstance, sourceId) {
|
|
1463
|
+
try {
|
|
1464
|
+
if (!mapInstance.getSource(sourceId)) return;
|
|
1465
|
+
for (const layer of mapInstance.getStyle()?.layers || []) {
|
|
1466
|
+
if (layer.source === sourceId) mapInstance.removeLayer(layer.id);
|
|
1467
|
+
}
|
|
1468
|
+
mapInstance.removeSource(sourceId);
|
|
1469
|
+
} catch {
|
|
1470
|
+
// The warning still names the dataset; MapLibre reports anything else it cannot load.
|
|
1471
|
+
}
|
|
1240
1472
|
}
|
|
1241
1473
|
|
|
1242
1474
|
function isLoopbackSourceUrl(value) {
|
|
@@ -1656,6 +1888,90 @@ function compareCodeUnits2(left, right) {
|
|
|
1656
1888
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
1657
1889
|
}
|
|
1658
1890
|
|
|
1891
|
+
// src/contact-sheet.ts
|
|
1892
|
+
var tileflowContactSheetLimits = Object.freeze({
|
|
1893
|
+
cellWidth: { maximum: 1024, minimum: 120 },
|
|
1894
|
+
columns: { maximum: 8, minimum: 1 },
|
|
1895
|
+
images: 64
|
|
1896
|
+
});
|
|
1897
|
+
async function renderTileflowContactSheet(browser, images, options = {}) {
|
|
1898
|
+
const limits = tileflowContactSheetLimits;
|
|
1899
|
+
if (images.length === 0 || images.length > limits.images) {
|
|
1900
|
+
throw new TileflowCaptureError(
|
|
1901
|
+
"SCREENSHOT_FAILED",
|
|
1902
|
+
`A contact sheet holds from 1 to ${limits.images} images.`
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
const columns = Math.round(options.columns ?? Math.min(4, Math.ceil(Math.sqrt(images.length))));
|
|
1906
|
+
const cellWidth = Math.round(options.cellWidth ?? 480);
|
|
1907
|
+
if (!(columns >= limits.columns.minimum && columns <= limits.columns.maximum) || !(cellWidth >= limits.cellWidth.minimum && cellWidth <= limits.cellWidth.maximum)) {
|
|
1908
|
+
throw new TileflowCaptureError(
|
|
1909
|
+
"SCREENSHOT_FAILED",
|
|
1910
|
+
`Contact sheet columns must be ${limits.columns.minimum}-${limits.columns.maximum} and cell width ${limits.cellWidth.minimum}-${limits.cellWidth.maximum}.`
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
const cells = images.map(({ label, png }) => {
|
|
1914
|
+
const { height: height2, width: width2 } = readPngDimensions(png);
|
|
1915
|
+
return {
|
|
1916
|
+
height: Math.round(cellWidth * height2 / width2),
|
|
1917
|
+
label,
|
|
1918
|
+
src: `data:image/png;base64,${Buffer.from(png).toString("base64")}`
|
|
1919
|
+
};
|
|
1920
|
+
});
|
|
1921
|
+
const gap = 12;
|
|
1922
|
+
const labelHeight = 22;
|
|
1923
|
+
const rows = Math.ceil(cells.length / columns);
|
|
1924
|
+
const rowHeights = Array.from(
|
|
1925
|
+
{ length: rows },
|
|
1926
|
+
(_, row) => Math.max(...cells.slice(row * columns, row * columns + columns).map((cell) => cell.height))
|
|
1927
|
+
);
|
|
1928
|
+
const width = gap + columns * (cellWidth + gap);
|
|
1929
|
+
const height = gap + rowHeights.reduce((sum, rowHeight) => sum + rowHeight + labelHeight + gap, 0);
|
|
1930
|
+
const context = await browser.newContext({
|
|
1931
|
+
deviceScaleFactor: 1,
|
|
1932
|
+
javaScriptEnabled: false,
|
|
1933
|
+
serviceWorkers: "block",
|
|
1934
|
+
viewport: { height: Math.min(height, 16384), width }
|
|
1935
|
+
});
|
|
1936
|
+
try {
|
|
1937
|
+
await context.route("**/*", (route) => route.abort("blockedbyclient"));
|
|
1938
|
+
const page = await context.newPage();
|
|
1939
|
+
await page.setContent(contactSheetHtml(cells, { cellWidth, columns, gap, labelHeight }), {
|
|
1940
|
+
waitUntil: "load"
|
|
1941
|
+
});
|
|
1942
|
+
return new Uint8Array(await page.screenshot({ fullPage: true, type: "png" }));
|
|
1943
|
+
} catch (error) {
|
|
1944
|
+
throw new TileflowCaptureError(
|
|
1945
|
+
"SCREENSHOT_FAILED",
|
|
1946
|
+
"Tileflow could not render the contact sheet.",
|
|
1947
|
+
{
|
|
1948
|
+
cause: error,
|
|
1949
|
+
details: { phase: "screenshot" }
|
|
1950
|
+
}
|
|
1951
|
+
);
|
|
1952
|
+
} finally {
|
|
1953
|
+
await context.close().catch(() => void 0);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
function contactSheetHtml(cells, layout) {
|
|
1957
|
+
const figures = cells.map(
|
|
1958
|
+
(cell) => `<figure><img src="${cell.src}" width="${layout.cellWidth}" height="${cell.height}" alt=""><figcaption>${escapeHtml(cell.label)}</figcaption></figure>`
|
|
1959
|
+
).join("");
|
|
1960
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
1961
|
+
html,body{margin:0;background:#f4f2ee;}
|
|
1962
|
+
main{display:grid;grid-template-columns:repeat(${layout.columns},${layout.cellWidth}px);gap:${layout.gap}px;padding:${layout.gap}px;align-items:start;}
|
|
1963
|
+
figure{margin:0;}
|
|
1964
|
+
img{display:block;border-radius:4px;}
|
|
1965
|
+
figcaption{height:${layout.labelHeight}px;line-height:${layout.labelHeight}px;font:500 13px/22px ui-monospace,Menlo,Consolas,monospace;color:#2b2824;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
1966
|
+
</style></head><body><main>${figures}</main></body></html>`;
|
|
1967
|
+
}
|
|
1968
|
+
function escapeHtml(value) {
|
|
1969
|
+
return value.replace(
|
|
1970
|
+
/[&<>"']/gu,
|
|
1971
|
+
(character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1659
1975
|
// src/shot.ts
|
|
1660
1976
|
import {
|
|
1661
1977
|
serializeCanonicalJson,
|
|
@@ -2608,6 +2924,7 @@ var TileflowCaptureSessionImpl = class {
|
|
|
2608
2924
|
this.#options = options;
|
|
2609
2925
|
this.#browserManager = new TileflowCaptureBrowserManager({
|
|
2610
2926
|
allowInstall: options.allowBrowserInstall,
|
|
2927
|
+
...options.graphics ? { graphics: options.graphics } : {},
|
|
2611
2928
|
onInstallProgress: options.onBrowserInstallProgress,
|
|
2612
2929
|
signal: options.signal
|
|
2613
2930
|
});
|
|
@@ -2719,6 +3036,7 @@ var TileflowCaptureSessionImpl = class {
|
|
|
2719
3036
|
glyphCacheDirectory: resolve(this.#options.cwd ?? process.cwd()),
|
|
2720
3037
|
lineBackgroundGlyphs: artifacts.lineBackgroundGlyphs
|
|
2721
3038
|
} : {},
|
|
3039
|
+
...this.#options.graphics ? { graphics: this.#options.graphics } : {},
|
|
2722
3040
|
scene,
|
|
2723
3041
|
signal,
|
|
2724
3042
|
style,
|
|
@@ -2858,6 +3176,12 @@ var TileflowCaptureSessionImpl = class {
|
|
|
2858
3176
|
}
|
|
2859
3177
|
return { schemaVersion: tileflowCaptureResultSchemaVersion, shots };
|
|
2860
3178
|
}
|
|
3179
|
+
async contactSheet(images, options = {}) {
|
|
3180
|
+
this.#assertOpen();
|
|
3181
|
+
const { signal, ...layout } = options;
|
|
3182
|
+
const browser = await this.#browserManager.getBrowser(signal ?? this.#options.signal);
|
|
3183
|
+
return await renderTileflowContactSheet(browser, images, layout);
|
|
3184
|
+
}
|
|
2861
3185
|
async close() {
|
|
2862
3186
|
if (this.#closed) {
|
|
2863
3187
|
return;
|
|
@@ -3217,8 +3541,8 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng, opti
|
|
|
3217
3541
|
};
|
|
3218
3542
|
}
|
|
3219
3543
|
function createTileflowVisualReferenceAnalysisDocument(analysis) {
|
|
3220
|
-
const { diffPng: _diffPng, ...
|
|
3221
|
-
return
|
|
3544
|
+
const { diffPng: _diffPng, ...document2 } = analysis;
|
|
3545
|
+
return document2;
|
|
3222
3546
|
}
|
|
3223
3547
|
async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
|
|
3224
3548
|
const actualReceipt = validateTileflowCaptureReceipt(actualCapture.receipt);
|
|
@@ -3361,8 +3685,8 @@ async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
|
|
|
3361
3685
|
};
|
|
3362
3686
|
}
|
|
3363
3687
|
function createTileflowVisualComparisonDocument(comparison) {
|
|
3364
|
-
const { diffPng: _diffPng, ...
|
|
3365
|
-
return
|
|
3688
|
+
const { diffPng: _diffPng, ...document2 } = comparison;
|
|
3689
|
+
return document2;
|
|
3366
3690
|
}
|
|
3367
3691
|
function serializeTileflowVisualComparison(comparison) {
|
|
3368
3692
|
return `${serializeCanonicalJson3(createTileflowVisualComparisonDocument(comparison))}
|
|
@@ -3794,8 +4118,8 @@ async function compareTileflowCapturesForReview(leftInput, rightInput, options =
|
|
|
3794
4118
|
};
|
|
3795
4119
|
}
|
|
3796
4120
|
function createTileflowVisualReviewDocument(comparison) {
|
|
3797
|
-
const { diffPng: _diffPng, ...
|
|
3798
|
-
return
|
|
4121
|
+
const { diffPng: _diffPng, ...document2 } = comparison;
|
|
4122
|
+
return document2;
|
|
3799
4123
|
}
|
|
3800
4124
|
var tileflowVisualReviewLimits = Object.freeze({
|
|
3801
4125
|
maximumAggregatePngBytes: tileflowVisualArtifactLimits.maximumPngBytes
|
|
@@ -4185,6 +4509,7 @@ export {
|
|
|
4185
4509
|
launchTileflowCaptureBrowser,
|
|
4186
4510
|
parseTileflowCaptureReceipt,
|
|
4187
4511
|
parseTileflowShotReceipt,
|
|
4512
|
+
renderTileflowContactSheet,
|
|
4188
4513
|
resolveTileflowApplicationUrl,
|
|
4189
4514
|
resolveTileflowCaptureWorldTileJson,
|
|
4190
4515
|
serializeTileflowCaptureReceipt,
|
|
@@ -4195,6 +4520,7 @@ export {
|
|
|
4195
4520
|
tileflowCaptureReceiptSchemaVersion,
|
|
4196
4521
|
tileflowCaptureResultSchemaVersion,
|
|
4197
4522
|
tileflowCaptureRuntime,
|
|
4523
|
+
tileflowContactSheetLimits,
|
|
4198
4524
|
tileflowShotFramesSha256,
|
|
4199
4525
|
tileflowShotReceiptLimits,
|
|
4200
4526
|
tileflowShotReceiptSchemaVersion,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tileflow/capture",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.40",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Deterministic local visual capture and comparison for Tileflow maps.",
|
|
6
6
|
"engines": {
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@tileflow/core": ">=0.1.0-alpha.
|
|
51
|
-
"@tileflow/dev": ">=0.1.0-alpha.
|
|
50
|
+
"@tileflow/core": ">=0.1.0-alpha.50 <0.1.0-beta.0",
|
|
51
|
+
"@tileflow/dev": ">=0.1.0-alpha.45 <0.1.0-beta.0",
|
|
52
52
|
"maplibre-gl": "6.16.0",
|
|
53
53
|
"pixelmatch": "7.2.0",
|
|
54
54
|
"playwright": "1.62.1",
|
|
@@ -56,23 +56,26 @@
|
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
59
|
-
"@types/node": "^26.6.
|
|
59
|
+
"@types/node": "^26.6.6",
|
|
60
60
|
"@types/pngjs": "6.0.5",
|
|
61
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
61
|
+
"@vitejs/plugin-vue": "^6.0.10",
|
|
62
62
|
"maplibre-gl-min": "npm:maplibre-gl@6.4.1",
|
|
63
63
|
"next": "16.4.2",
|
|
64
64
|
"react": "^19.3.0",
|
|
65
65
|
"react-dom": "^19.3.0",
|
|
66
66
|
"svelte": "^5.57.2",
|
|
67
|
+
"terra-draw": "^1.39.0",
|
|
68
|
+
"terra-draw-maplibre-gl-adapter": "^1.5.0",
|
|
67
69
|
"vite": "^8.3.4",
|
|
68
70
|
"vue": "^3.5.43",
|
|
69
71
|
"webpack": "5.108.3",
|
|
70
|
-
"@tileflow/
|
|
71
|
-
"@tileflow/next": "0.1.0-alpha.
|
|
72
|
-
"@tileflow/react": "0.1.0-alpha.
|
|
73
|
-
"@tileflow/svelte": "0.1.0-alpha.
|
|
74
|
-
"@tileflow/
|
|
75
|
-
"@tileflow/
|
|
72
|
+
"@tileflow/dom": "0.1.0-alpha.0",
|
|
73
|
+
"@tileflow/next": "0.1.0-alpha.26",
|
|
74
|
+
"@tileflow/react": "0.1.0-alpha.40",
|
|
75
|
+
"@tileflow/svelte": "0.1.0-alpha.38",
|
|
76
|
+
"@tileflow/maps": "0.1.0-alpha.22",
|
|
77
|
+
"@tileflow/vite": "0.1.0-alpha.27",
|
|
78
|
+
"@tileflow/vue": "0.1.0-alpha.38",
|
|
76
79
|
"@tileflow/webpack": "0.1.0-alpha.25"
|
|
77
80
|
},
|
|
78
81
|
"scripts": {
|