@tileflow/capture 0.1.0-alpha.39 → 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 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 launchPinnedBrowser() {
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,
@@ -379,6 +395,7 @@ globalThis.__tileflowAttachNotices = attachTileflowNotices;
379
395
  globalThis.__tileflowAttachNauticalLights = attachTileflowNauticalLights;
380
396
  globalThis.__tileflowMaxPitch = tileflowMaxPitch;
381
397
  globalThis.__tileflowAttachLabelPlacement = attachTileflowLabelPlacement;
398
+ globalThis.__tileflowCreateMap3D = createTileflowMap3D;
382
399
  globalThis.__tileflowCreateThemeController = createTileflowThemeController;`;
383
400
  async function captureStandaloneTileflowScene(input) {
384
401
  if (input.scene.target.kind !== "map") {
@@ -430,6 +447,8 @@ async function withStandaloneTileflowPage(input, run) {
430
447
  let context;
431
448
  let phase = "browser-start";
432
449
  let timeout;
450
+ let budgetStartedAt = Date.now();
451
+ const pageWarnings = /* @__PURE__ */ new Set();
433
452
  const onAbort = () => {
434
453
  termination = "aborted";
435
454
  void context?.close().catch(() => void 0);
@@ -440,6 +459,7 @@ async function withStandaloneTileflowPage(input, run) {
440
459
  };
441
460
  const restartTimeout = () => {
442
461
  stopTimeout();
462
+ budgetStartedAt = Date.now();
443
463
  timeout = setTimeout(() => {
444
464
  termination = "timeout";
445
465
  void context?.close().catch(() => void 0);
@@ -484,6 +504,11 @@ async function withStandaloneTileflowPage(input, run) {
484
504
  const resourceFailures = [];
485
505
  let pageFailed = false;
486
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
+ }
487
512
  const diagnostic = classifyResource(url, status);
488
513
  if (!diagnostic || resourceFailures.length >= 16) return;
489
514
  const key = JSON.stringify(diagnostic);
@@ -591,7 +616,10 @@ async function withStandaloneTileflowPage(input, run) {
591
616
  recordResourceFailure(response.url(), response.status());
592
617
  }
593
618
  });
594
- page.on("requestfailed", (request) => recordResourceFailure(request.url()));
619
+ page.on("requestfailed", (request) => {
620
+ if (request.failure()?.errorText === "net::ERR_ABORTED") return;
621
+ recordResourceFailure(request.url());
622
+ });
595
623
  page.setDefaultTimeout(timeoutMs);
596
624
  await page.goto(mapLibreRuntimeUrls.document, { waitUntil: "domcontentloaded" });
597
625
  await page.addStyleTag({ url: mapLibreRuntimeUrls.css });
@@ -606,16 +634,31 @@ globalThis.maplibregl = maplibregl;`,
606
634
  );
607
635
  await page.addScriptTag({ content: tileflowBrowserModule, type: "module" });
608
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
+ }
609
647
  const step = async (stepPhase, name, value) => {
610
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);
611
651
  const result = await page.evaluate(
612
652
  ({ name: functionName, value: stepInput }) => {
613
653
  const renderWindow = window;
614
654
  return renderWindow[functionName](stepInput);
615
655
  },
616
- { name, value }
656
+ { name, value: { ...value, sceneBudgetMs } }
617
657
  );
618
658
  assertPhaseSucceeded(result, stepPhase, syntheticAssetFailure, resourceFailures, pageFailed);
659
+ if (result.status === "ok") {
660
+ for (const warning of result.warnings ?? []) pageWarnings.add(safeSceneWarning(warning));
661
+ }
619
662
  };
620
663
  return await run({
621
664
  page,
@@ -663,7 +706,10 @@ globalThis.maplibregl = maplibregl;`,
663
706
  result() {
664
707
  return {
665
708
  networkDependent: remoteOrigins.size > 0,
666
- warnings: [...remoteOrigins].sort(compareCodeUnits).map((origin) => `Capture requested remote resources from ${origin}.`)
709
+ warnings: [
710
+ ...[...remoteOrigins].sort(compareCodeUnits).map((origin) => `Capture requested remote resources from ${origin}.`),
711
+ ...[...pageWarnings].sort(compareCodeUnits)
712
+ ]
667
713
  };
668
714
  }
669
715
  });
@@ -672,10 +718,11 @@ globalThis.maplibregl = maplibregl;`,
672
718
  throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
673
719
  }
674
720
  if (termination === "timeout") {
675
- throw new TileflowCaptureError("CAPTURE_TIMEOUT", timeoutMessage(phase), {
676
- cause: error,
677
- details: { phase }
678
- });
721
+ throw new TileflowCaptureError(
722
+ "CAPTURE_TIMEOUT",
723
+ `${timeoutMessage(phase)}${slowSceneHint(input.style, input.graphics, phase)}`,
724
+ { cause: error, details: { phase } }
725
+ );
679
726
  }
680
727
  if (error instanceof CallerFailure) {
681
728
  throw error.error;
@@ -926,6 +973,41 @@ function classifyResource(value, status) {
926
973
  ...status === void 0 ? {} : { status }
927
974
  };
928
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
+ }
929
1011
  function fontStackContext(path) {
930
1012
  const segments = path.split("/").filter(Boolean);
931
1013
  const index = segments.findIndex((segment) => /^(?:fonts?|glyphs?)$/i.test(segment));
@@ -952,6 +1034,20 @@ function resourceLabel(kind) {
952
1034
  "vector-tile": "Vector tile"
953
1035
  }[kind];
954
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
+ }
955
1051
  function timeoutMessage(phase) {
956
1052
  if (phase === "map-load") return "MapLibre did not finish loading before the capture timeout.";
957
1053
  if (phase === "theme-blend") return "The theme blend was not ready before the capture timeout.";
@@ -967,6 +1063,9 @@ function mapFailureMessage(phase) {
967
1063
  function safeMapError(value) {
968
1064
  return value.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g, "resource").replace(/[^A-Za-z0-9 _.,:'"@+()[\]/=-]/g, "").trim().slice(0, 200);
969
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
+ }
970
1069
  function safeAssetName(value) {
971
1070
  return value.replace(/[^A-Za-z0-9._/-]/g, "").slice(0, 200) || "unknown";
972
1071
  }
@@ -1049,6 +1148,17 @@ window.__tileflowCaptureLoad = async function(input) {
1049
1148
  // Capture prefers reduced motion, so Nautical lights stay steady and every image is stable.
1050
1149
  window.__tileflowAttachNauticalLights(map);
1051
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
+ });
1052
1162
  return await waitForMapEvent(map, "load", input.mapEventTimeoutMs);
1053
1163
  } catch (error) {
1054
1164
  return {status: "failed", reason: "error", message: String(error?.message || error || "")};
@@ -1106,14 +1216,11 @@ async function loadTileflowFontFaces(definitions) {
1106
1216
  window.__tileflowCaptureIdle = async function(input) {
1107
1217
  const map = window.__tileflowMap;
1108
1218
  if (!map) return {status: "failed", reason: "error"};
1219
+ releaseFrames(map);
1109
1220
 
1110
1221
  try {
1111
1222
  applyCamera(map, input.camera);
1112
- const result = await waitForMapEvent(map, "idle", input.mapEventTimeoutMs);
1113
- if (result.status === "failed") return result;
1114
- await nextFrame();
1115
- await nextFrame();
1116
- return {status: "ok"};
1223
+ return await settleMap(map, input);
1117
1224
  } catch {
1118
1225
  return {status: "failed", reason: "error"};
1119
1226
  }
@@ -1123,6 +1230,7 @@ window.__tileflowCaptureIdle = async function(input) {
1123
1230
  window.__tileflowCapturePrepareBlend = async function(input) {
1124
1231
  const map = window.__tileflowMap;
1125
1232
  if (!map) return {status: "failed", reason: "error"};
1233
+ releaseFrames(map);
1126
1234
 
1127
1235
  try {
1128
1236
  const controller = window.__tileflowCreateThemeController({
@@ -1147,6 +1255,7 @@ window.__tileflowCapturePrepareBlend = async function(input) {
1147
1255
  window.__tileflowCaptureFrame = async function(input) {
1148
1256
  const map = window.__tileflowMap;
1149
1257
  if (!map) return {status: "failed", reason: "error"};
1258
+ releaseFrames(map);
1150
1259
 
1151
1260
  try {
1152
1261
  const blend = window.__tileflowShotBlend;
@@ -1161,17 +1270,110 @@ window.__tileflowCaptureFrame = async function(input) {
1161
1270
  }
1162
1271
  applyCamera(map, input.camera);
1163
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) {
1164
1285
  const result = await waitForMapEvent(map, "idle", input.mapEventTimeoutMs);
1165
1286
  if (result.status === "failed") return result;
1166
1287
  await nextFrame();
1167
1288
  await nextFrame();
1168
- return {status: "ok"};
1169
- } catch {
1170
- return {status: "failed", reason: "error"};
1289
+ return {status: "ok", warnings: []};
1171
1290
  }
1172
- };
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
+ }
1173
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.
1174
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) {
1175
1377
  if (camera.type === "bounds") {
1176
1378
  mapInstance.fitBounds(
1177
1379
  [
@@ -1197,7 +1399,8 @@ function applyCamera(mapInstance, camera) {
1197
1399
  }
1198
1400
 
1199
1401
  function waitForMapEvent(mapInstance, event, timeoutMs) {
1200
- return new Promise((resolve) => {
1402
+ let cancel;
1403
+ const promise = new Promise((resolve) => {
1201
1404
  const timeout = window.setTimeout(() => {
1202
1405
  cleanup();
1203
1406
  resolve({status: "failed", reason: "timeout"});
@@ -1207,6 +1410,15 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
1207
1410
  resolve({status: "ok"});
1208
1411
  };
1209
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
+ }
1210
1422
  const message = String(errorEvent?.error?.message || errorEvent?.message || "");
1211
1423
  const missingSourceLayer = message.match(
1212
1424
  /^Source layer "(.+)" does not exist on source "(.+)" as specified by style layer ".+"\.$/
@@ -1236,9 +1448,27 @@ function waitForMapEvent(mapInstance, event, timeoutMs) {
1236
1448
  mapInstance.off("error", onError);
1237
1449
  };
1238
1450
 
1451
+ cancel = () => {
1452
+ cleanup();
1453
+ resolve({status: "cancelled"});
1454
+ };
1239
1455
  mapInstance.once(event, onSuccess);
1240
1456
  mapInstance.on("error", onError);
1241
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
+ }
1242
1472
  }
1243
1473
 
1244
1474
  function isLoopbackSourceUrl(value) {
@@ -1658,6 +1888,90 @@ function compareCodeUnits2(left, right) {
1658
1888
  return left < right ? -1 : left > right ? 1 : 0;
1659
1889
  }
1660
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]
1972
+ );
1973
+ }
1974
+
1661
1975
  // src/shot.ts
1662
1976
  import {
1663
1977
  serializeCanonicalJson,
@@ -2610,6 +2924,7 @@ var TileflowCaptureSessionImpl = class {
2610
2924
  this.#options = options;
2611
2925
  this.#browserManager = new TileflowCaptureBrowserManager({
2612
2926
  allowInstall: options.allowBrowserInstall,
2927
+ ...options.graphics ? { graphics: options.graphics } : {},
2613
2928
  onInstallProgress: options.onBrowserInstallProgress,
2614
2929
  signal: options.signal
2615
2930
  });
@@ -2721,6 +3036,7 @@ var TileflowCaptureSessionImpl = class {
2721
3036
  glyphCacheDirectory: resolve(this.#options.cwd ?? process.cwd()),
2722
3037
  lineBackgroundGlyphs: artifacts.lineBackgroundGlyphs
2723
3038
  } : {},
3039
+ ...this.#options.graphics ? { graphics: this.#options.graphics } : {},
2724
3040
  scene,
2725
3041
  signal,
2726
3042
  style,
@@ -2860,6 +3176,12 @@ var TileflowCaptureSessionImpl = class {
2860
3176
  }
2861
3177
  return { schemaVersion: tileflowCaptureResultSchemaVersion, shots };
2862
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
+ }
2863
3185
  async close() {
2864
3186
  if (this.#closed) {
2865
3187
  return;
@@ -3219,8 +3541,8 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng, opti
3219
3541
  };
3220
3542
  }
3221
3543
  function createTileflowVisualReferenceAnalysisDocument(analysis) {
3222
- const { diffPng: _diffPng, ...document } = analysis;
3223
- return document;
3544
+ const { diffPng: _diffPng, ...document2 } = analysis;
3545
+ return document2;
3224
3546
  }
3225
3547
  async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
3226
3548
  const actualReceipt = validateTileflowCaptureReceipt(actualCapture.receipt);
@@ -3363,8 +3685,8 @@ async function compareTileflowCaptureToBaseline(actualCapture, baseline) {
3363
3685
  };
3364
3686
  }
3365
3687
  function createTileflowVisualComparisonDocument(comparison) {
3366
- const { diffPng: _diffPng, ...document } = comparison;
3367
- return document;
3688
+ const { diffPng: _diffPng, ...document2 } = comparison;
3689
+ return document2;
3368
3690
  }
3369
3691
  function serializeTileflowVisualComparison(comparison) {
3370
3692
  return `${serializeCanonicalJson3(createTileflowVisualComparisonDocument(comparison))}
@@ -3796,8 +4118,8 @@ async function compareTileflowCapturesForReview(leftInput, rightInput, options =
3796
4118
  };
3797
4119
  }
3798
4120
  function createTileflowVisualReviewDocument(comparison) {
3799
- const { diffPng: _diffPng, ...document } = comparison;
3800
- return document;
4121
+ const { diffPng: _diffPng, ...document2 } = comparison;
4122
+ return document2;
3801
4123
  }
3802
4124
  var tileflowVisualReviewLimits = Object.freeze({
3803
4125
  maximumAggregatePngBytes: tileflowVisualArtifactLimits.maximumPngBytes
@@ -4187,6 +4509,7 @@ export {
4187
4509
  launchTileflowCaptureBrowser,
4188
4510
  parseTileflowCaptureReceipt,
4189
4511
  parseTileflowShotReceipt,
4512
+ renderTileflowContactSheet,
4190
4513
  resolveTileflowApplicationUrl,
4191
4514
  resolveTileflowCaptureWorldTileJson,
4192
4515
  serializeTileflowCaptureReceipt,
@@ -4197,6 +4520,7 @@ export {
4197
4520
  tileflowCaptureReceiptSchemaVersion,
4198
4521
  tileflowCaptureResultSchemaVersion,
4199
4522
  tileflowCaptureRuntime,
4523
+ tileflowContactSheetLimits,
4200
4524
  tileflowShotFramesSha256,
4201
4525
  tileflowShotReceiptLimits,
4202
4526
  tileflowShotReceiptSchemaVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tileflow/capture",
3
- "version": "0.1.0-alpha.39",
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.49 <0.1.0-beta.0",
51
- "@tileflow/dev": ">=0.1.0-alpha.44 <0.1.0-beta.0",
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",
@@ -69,12 +69,13 @@
69
69
  "vite": "^8.3.4",
70
70
  "vue": "^3.5.43",
71
71
  "webpack": "5.108.3",
72
- "@tileflow/maps": "0.1.0-alpha.21",
73
- "@tileflow/react": "0.1.0-alpha.39",
74
- "@tileflow/next": "0.1.0-alpha.25",
75
- "@tileflow/svelte": "0.1.0-alpha.37",
76
- "@tileflow/vue": "0.1.0-alpha.37",
77
- "@tileflow/vite": "0.1.0-alpha.26",
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",
78
79
  "@tileflow/webpack": "0.1.0-alpha.25"
79
80
  },
80
81
  "scripts": {