@tileflow/capture 0.1.0-alpha.29 → 0.1.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,12 +2,18 @@ import {
2
2
  TileflowCaptureError,
3
3
  captureErrorMessage,
4
4
  createTileflowCaptureReceipt,
5
+ createTileflowShotReceipt,
5
6
  parseTileflowCaptureReceipt,
7
+ parseTileflowShotReceipt,
6
8
  serializeTileflowCaptureReceipt,
9
+ serializeTileflowShotReceipt,
7
10
  tileflowCaptureReceiptLimits,
8
11
  tileflowCaptureReceiptSchemaVersion,
9
- validateTileflowCaptureReceipt
10
- } from "./chunk-VNDAYGXY.js";
12
+ tileflowShotReceiptLimits,
13
+ tileflowShotReceiptSchemaVersion,
14
+ validateTileflowCaptureReceipt,
15
+ validateTileflowShotReceipt
16
+ } from "./chunk-BI5PWIH4.js";
11
17
 
12
18
  // src/browser.ts
13
19
  import { spawn } from "child_process";
@@ -296,11 +302,13 @@ import { resolve } from "path";
296
302
  import {
297
303
  compareCodeUnits as compareCodeUnits3,
298
304
  normalizeTileflowCaptureScene,
305
+ normalizeTileflowCaptureShot,
299
306
  parseResolvedTileflowMap,
300
307
  resolveThemeSelection,
301
- serializeCanonicalJson,
302
- sha256Hex,
303
- tileflowCaptureSceneNameSchema
308
+ serializeCanonicalJson as serializeCanonicalJson2,
309
+ sha256Hex as sha256Hex2,
310
+ tileflowCaptureSceneNameSchema,
311
+ tileflowCaptureShotNameSchema
304
312
  } from "@tileflow/core";
305
313
  import {
306
314
  createTileflowBuildArtifacts,
@@ -341,7 +349,8 @@ var tileflowBrowserModule = `${readFileSync2(
341
349
  )}
342
350
  globalThis.__tileflowRegisterContourProtocol = registerTileflowContourProtocol;
343
351
  globalThis.__tileflowRegisterPmtilesProtocol = registerTileflowPmtilesProtocol;
344
- globalThis.__tileflowAttachAtmosphere = attachTileflowAtmosphere;`;
352
+ globalThis.__tileflowAttachAtmosphere = attachTileflowAtmosphere;
353
+ globalThis.__tileflowCreateThemeController = createTileflowThemeController;`;
345
354
  async function captureStandaloneTileflowScene(input) {
346
355
  if (input.scene.target.kind !== "map") {
347
356
  throw new TileflowCaptureError(
@@ -349,8 +358,26 @@ async function captureStandaloneTileflowScene(input) {
349
358
  "Application scenes require an explicit loopback application origin."
350
359
  );
351
360
  }
361
+ assertValidCaptureStyle(input.style, input.scene.map);
362
+ const { scene, ...pageInput } = input;
363
+ return withStandaloneTileflowPage({ ...pageInput, viewport: scene.viewport }, async (page) => {
364
+ await page.load(getTileflowStyleFontFaces(input.style));
365
+ await page.step("map-idle", "__tileflowCaptureIdle", {
366
+ camera: scene.camera,
367
+ mapEventTimeoutMs: page.mapEventTimeoutMs
368
+ });
369
+ const png = await page.screenshot();
370
+ return {
371
+ height: scene.viewport.height,
372
+ png,
373
+ width: scene.viewport.width,
374
+ ...page.result()
375
+ };
376
+ });
377
+ }
378
+ function assertValidCaptureStyle(style, label) {
352
379
  try {
353
- assertValidTileflowStyle(input.style, input.scene.map);
380
+ assertValidTileflowStyle(style, label);
354
381
  } catch (error) {
355
382
  if (error instanceof TileflowStyleValidationError) {
356
383
  throw new TileflowCaptureError("STYLE_INVALID", error.message, {
@@ -363,7 +390,10 @@ async function captureStandaloneTileflowScene(input) {
363
390
  }
364
391
  throw error;
365
392
  }
393
+ }
394
+ async function withStandaloneTileflowPage(input, run) {
366
395
  const timeoutMs = input.timeoutMs ?? 3e4;
396
+ const mapEventTimeoutMs = Math.max(1, timeoutMs - 1e3);
367
397
  const mapLibreRuntimeUrls = createMapLibreRuntimeUrls(
368
398
  resolveMapLibreRuntimeDocumentOrigin(input.style)
369
399
  );
@@ -375,6 +405,18 @@ async function captureStandaloneTileflowScene(input) {
375
405
  termination = "aborted";
376
406
  void context?.close().catch(() => void 0);
377
407
  };
408
+ const stopTimeout = () => {
409
+ if (timeout) clearTimeout(timeout);
410
+ timeout = void 0;
411
+ };
412
+ const restartTimeout = () => {
413
+ stopTimeout();
414
+ timeout = setTimeout(() => {
415
+ termination = "timeout";
416
+ void context?.close().catch(() => void 0);
417
+ }, timeoutMs);
418
+ timeout.unref?.();
419
+ };
378
420
  if (input.signal?.aborted) {
379
421
  throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
380
422
  }
@@ -382,7 +424,7 @@ async function captureStandaloneTileflowScene(input) {
382
424
  context = await input.browser.newContext({
383
425
  acceptDownloads: false,
384
426
  colorScheme: "light",
385
- deviceScaleFactor: input.scene.viewport.dpr,
427
+ deviceScaleFactor: input.viewport.dpr,
386
428
  ignoreHTTPSErrors: false,
387
429
  javaScriptEnabled: true,
388
430
  locale: "en-US",
@@ -390,8 +432,8 @@ async function captureStandaloneTileflowScene(input) {
390
432
  serviceWorkers: "block",
391
433
  timezoneId: "UTC",
392
434
  viewport: {
393
- height: input.scene.viewport.height,
394
- width: input.scene.viewport.width
435
+ height: input.viewport.height,
436
+ width: input.viewport.width
395
437
  }
396
438
  });
397
439
  input.signal?.addEventListener("abort", onAbort, { once: true });
@@ -399,11 +441,7 @@ async function captureStandaloneTileflowScene(input) {
399
441
  onAbort();
400
442
  throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
401
443
  }
402
- timeout = setTimeout(() => {
403
- termination = "timeout";
404
- void context?.close().catch(() => void 0);
405
- }, timeoutMs);
406
- timeout.unref?.();
444
+ restartTimeout();
407
445
  const assets = new Map(input.assets.map((asset) => [asset.fileName, asset]));
408
446
  const frozenResources = new Map(
409
447
  input.assets.filter((asset) => asset.url !== void 0).map((asset) => [asset.url, asset])
@@ -411,7 +449,6 @@ async function captureStandaloneTileflowScene(input) {
411
449
  const localTilesets = new Map(
412
450
  (input.localTilesets ?? []).map((tileset) => [tileset.fileName, tileset])
413
451
  );
414
- const fontFaces = getTileflowStyleFontFaces(input.style);
415
452
  const remoteOrigins = /* @__PURE__ */ new Set();
416
453
  let lineBackgroundGlyphs;
417
454
  let syntheticAssetFailure;
@@ -438,7 +475,7 @@ async function captureStandaloneTileflowScene(input) {
438
475
  return;
439
476
  }
440
477
  if (url.origin === mapLibreRuntimeUrls.origin) {
441
- const runtime = getMapLibreRuntimeResponse(url.pathname, input.scene.viewport);
478
+ const runtime = getMapLibreRuntimeResponse(url.pathname, input.viewport);
442
479
  if (runtime) {
443
480
  await route.fulfill(runtime);
444
481
  return;
@@ -540,61 +577,67 @@ globalThis.maplibregl = maplibregl;`,
540
577
  );
541
578
  await page.addScriptTag({ content: tileflowBrowserModule, type: "module" });
542
579
  await page.addScriptTag({ content: renderMapInPageScript });
543
- phase = "map-load";
544
- const loadResult = await page.evaluate(
545
- (renderInput) => {
546
- const renderWindow = window;
547
- return renderWindow.__tileflowCaptureLoad(renderInput);
548
- },
549
- {
580
+ const step = async (stepPhase, name, value) => {
581
+ phase = stepPhase;
582
+ const result = await page.evaluate(
583
+ ({ name: functionName, value: stepInput }) => {
584
+ const renderWindow = window;
585
+ return renderWindow[functionName](stepInput);
586
+ },
587
+ { name, value }
588
+ );
589
+ assertPhaseSucceeded(result, stepPhase, syntheticAssetFailure, resourceFailures, pageFailed);
590
+ };
591
+ return await run({
592
+ page,
593
+ mapEventTimeoutMs,
594
+ load: (fontFaces) => step("map-load", "__tileflowCaptureLoad", {
550
595
  fontFaces,
551
- mapEventTimeoutMs: Math.max(1, timeoutMs - 1e3),
596
+ mapEventTimeoutMs,
552
597
  style: input.style
553
- }
554
- );
555
- assertPhaseSucceeded(loadResult, phase, syntheticAssetFailure, resourceFailures, pageFailed);
556
- phase = "map-idle";
557
- const idleResult = await page.evaluate(
558
- (renderInput) => {
559
- const renderWindow = window;
560
- return renderWindow.__tileflowCaptureIdle(renderInput);
598
+ }),
599
+ step,
600
+ async screenshot() {
601
+ phase = "screenshot";
602
+ let png;
603
+ try {
604
+ png = new Uint8Array(
605
+ await page.locator("#map").screenshot({ animations: "disabled", type: "png" })
606
+ );
607
+ } catch (error) {
608
+ throw new TileflowCaptureError(
609
+ "SCREENSHOT_FAILED",
610
+ "Tileflow could not produce the map PNG.",
611
+ {
612
+ cause: error,
613
+ details: { phase }
614
+ }
615
+ );
616
+ }
617
+ assertPostScreenshotSucceeded(syntheticAssetFailure, resourceFailures, pageFailed);
618
+ assertPngDimensions(
619
+ png,
620
+ input.viewport.width * input.viewport.dpr,
621
+ input.viewport.height * input.viewport.dpr
622
+ );
623
+ return png;
561
624
  },
562
- {
563
- camera: input.scene.camera,
564
- mapEventTimeoutMs: Math.max(1, timeoutMs - 1e3)
565
- }
566
- );
567
- assertPhaseSucceeded(idleResult, phase, syntheticAssetFailure, resourceFailures, pageFailed);
568
- phase = "screenshot";
569
- let png;
570
- try {
571
- png = new Uint8Array(
572
- await page.locator("#map").screenshot({ animations: "disabled", type: "png" })
573
- );
574
- } catch (error) {
575
- throw new TileflowCaptureError(
576
- "SCREENSHOT_FAILED",
577
- "Tileflow could not produce the map PNG.",
578
- {
579
- cause: error,
580
- details: { phase }
625
+ restartTimeout,
626
+ async outside(work) {
627
+ stopTimeout();
628
+ try {
629
+ return await work();
630
+ } catch (error) {
631
+ throw new CallerFailure(error);
581
632
  }
582
- );
583
- }
584
- assertPostScreenshotSucceeded(syntheticAssetFailure, resourceFailures, pageFailed);
585
- assertPngDimensions(
586
- png,
587
- input.scene.viewport.width * input.scene.viewport.dpr,
588
- input.scene.viewport.height * input.scene.viewport.dpr
589
- );
590
- const warnings = [...remoteOrigins].sort(compareCodeUnits).map((origin) => `Capture requested remote resources from ${origin}.`);
591
- return {
592
- height: input.scene.viewport.height,
593
- networkDependent: remoteOrigins.size > 0,
594
- png,
595
- warnings,
596
- width: input.scene.viewport.width
597
- };
633
+ },
634
+ result() {
635
+ return {
636
+ networkDependent: remoteOrigins.size > 0,
637
+ warnings: [...remoteOrigins].sort(compareCodeUnits).map((origin) => `Capture requested remote resources from ${origin}.`)
638
+ };
639
+ }
640
+ });
598
641
  } catch (error) {
599
642
  if (termination === "aborted") {
600
643
  throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
@@ -605,6 +648,9 @@ globalThis.maplibregl = maplibregl;`,
605
648
  details: { phase }
606
649
  });
607
650
  }
651
+ if (error instanceof CallerFailure) {
652
+ throw error.error;
653
+ }
608
654
  if (error instanceof TileflowCaptureError) {
609
655
  throw error;
610
656
  }
@@ -625,18 +671,26 @@ globalThis.maplibregl = maplibregl;`,
625
671
  }
626
672
  );
627
673
  }
628
- throw new TileflowCaptureError("MAP_LOAD_FAILED", mapFailureMessage(phase), {
629
- cause: error,
630
- details: { phase }
631
- });
674
+ throw new TileflowCaptureError(
675
+ "MAP_LOAD_FAILED",
676
+ mapFailureMessage(phase === "theme-blend" || phase === "map-load" ? phase : "map-idle"),
677
+ {
678
+ cause: error,
679
+ details: { phase }
680
+ }
681
+ );
632
682
  } finally {
633
- if (timeout) {
634
- clearTimeout(timeout);
635
- }
683
+ stopTimeout();
636
684
  input.signal?.removeEventListener("abort", onAbort);
637
685
  await context?.close().catch(() => void 0);
638
686
  }
639
687
  }
688
+ var CallerFailure = class {
689
+ constructor(error) {
690
+ this.error = error;
691
+ }
692
+ error;
693
+ };
640
694
  function createMapLibreRuntimeUrls(origin) {
641
695
  return Object.freeze({
642
696
  css: `${origin}/__runtime/maplibre-gl.css`,
@@ -871,12 +925,15 @@ function resourceLabel(kind) {
871
925
  }
872
926
  function timeoutMessage(phase) {
873
927
  if (phase === "map-load") return "MapLibre did not finish loading before the capture timeout.";
928
+ if (phase === "theme-blend") return "The theme blend was not ready before the capture timeout.";
874
929
  if (phase === "map-idle") return "MapLibre did not become idle before the capture timeout.";
875
930
  if (phase === "screenshot") return "PNG production exceeded the capture timeout.";
876
931
  return "Tileflow capture exceeded its timeout while creating the browser page.";
877
932
  }
878
933
  function mapFailureMessage(phase) {
879
- return phase === "map-load" ? "MapLibre failed while loading the compiled style." : "MapLibre failed while waiting for the committed camera to become idle.";
934
+ if (phase === "map-load") return "MapLibre failed while loading the compiled style.";
935
+ if (phase === "theme-blend") return "Tileflow could not prepare the theme blend.";
936
+ return "MapLibre failed while waiting for the committed camera to become idle.";
880
937
  }
881
938
  function safeMapError(value) {
882
939
  return value.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g, "resource").replace(/[^A-Za-z0-9 _.,:'"@+()[\]/=-]/g, "").trim().slice(0, 200);
@@ -951,6 +1008,7 @@ window.__tileflowCaptureLoad = async function(input) {
951
1008
  style: input.style
952
1009
  });
953
1010
  window.__tileflowMap = map;
1011
+ window.__tileflowCaptureStyle = input.style;
954
1012
  window.__tileflowAttachAtmosphere(map);
955
1013
  return await waitForMapEvent(map, "load", input.mapEventTimeoutMs);
956
1014
  } catch (error) {
@@ -1022,6 +1080,58 @@ window.__tileflowCaptureIdle = async function(input) {
1022
1080
  }
1023
1081
  };
1024
1082
 
1083
+ // A shot prepares its theme blend once; each frame then only moves the blend position.
1084
+ window.__tileflowCapturePrepareBlend = async function(input) {
1085
+ const map = window.__tileflowMap;
1086
+ if (!map) return {status: "failed", reason: "error"};
1087
+
1088
+ try {
1089
+ const controller = window.__tileflowCreateThemeController({
1090
+ initial: {style: window.__tileflowCaptureStyle, theme: input.theme},
1091
+ loadFonts: () => Promise.resolve(),
1092
+ map,
1093
+ timeoutMs: input.mapEventTimeoutMs
1094
+ });
1095
+ const themes = input.themes.map((theme) => ({style: theme.style, theme: theme.theme}));
1096
+ const result = await controller.setBlend({position: input.position, themes});
1097
+ if (result.status !== "applied") {
1098
+ controller.dispose();
1099
+ return {status: "failed", reason: "error", message: String(result.error?.message || "")};
1100
+ }
1101
+ window.__tileflowShotBlend = {controller, themes};
1102
+ return {status: "ok"};
1103
+ } catch (error) {
1104
+ return {status: "failed", reason: "error", message: String(error?.message || error || "")};
1105
+ }
1106
+ };
1107
+
1108
+ window.__tileflowCaptureFrame = async function(input) {
1109
+ const map = window.__tileflowMap;
1110
+ if (!map) return {status: "failed", reason: "error"};
1111
+
1112
+ try {
1113
+ const blend = window.__tileflowShotBlend;
1114
+ if (blend && input.blendPosition !== undefined) {
1115
+ const result = await blend.controller.setBlend({
1116
+ position: input.blendPosition,
1117
+ themes: blend.themes
1118
+ });
1119
+ if (result.status !== "applied") {
1120
+ return {status: "failed", reason: "error", message: String(result.error?.message || "")};
1121
+ }
1122
+ }
1123
+ applyCamera(map, input.camera);
1124
+ map.triggerRepaint();
1125
+ const result = await waitForMapEvent(map, "idle", input.mapEventTimeoutMs);
1126
+ if (result.status === "failed") return result;
1127
+ await nextFrame();
1128
+ await nextFrame();
1129
+ return {status: "ok"};
1130
+ } catch {
1131
+ return {status: "failed", reason: "error"};
1132
+ }
1133
+ };
1134
+
1025
1135
  function applyCamera(mapInstance, camera) {
1026
1136
  if (camera.type === "bounds") {
1027
1137
  mapInstance.fitBounds(
@@ -1509,6 +1619,638 @@ function compareCodeUnits2(left, right) {
1509
1619
  return left < right ? -1 : left > right ? 1 : 0;
1510
1620
  }
1511
1621
 
1622
+ // src/shot.ts
1623
+ import {
1624
+ serializeCanonicalJson,
1625
+ sha256Hex,
1626
+ tileflowCaptureShotFrames
1627
+ } from "@tileflow/core";
1628
+ import { getTileflowStyleFontFaces as getTileflowStyleFontFaces2 } from "@tileflow/core/runtime";
1629
+
1630
+ // src/shot-encoder.ts
1631
+ var encoderDocumentUrl = `${tileflowSyntheticAssetOrigin}/__tileflow/shot-encoder.html`;
1632
+ var encoderFrameUrl = `${tileflowSyntheticAssetOrigin}/__tileflow/shot-frame.png`;
1633
+ async function openTileflowShotVideoEncoder(input) {
1634
+ const timeoutMs = input.timeoutMs ?? 3e4;
1635
+ let context;
1636
+ const state = {};
1637
+ let pendingPng;
1638
+ const chunks = [];
1639
+ const onAbort = () => {
1640
+ state.termination = "aborted";
1641
+ void context?.close().catch(() => void 0);
1642
+ };
1643
+ const close = async () => {
1644
+ input.signal?.removeEventListener("abort", onAbort);
1645
+ await context?.close().catch(() => void 0);
1646
+ };
1647
+ const run = async (work) => {
1648
+ if (input.signal?.aborted || state.termination === "aborted") {
1649
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.");
1650
+ }
1651
+ const timeout = setTimeout(() => {
1652
+ state.termination = "timeout";
1653
+ void context?.close().catch(() => void 0);
1654
+ }, timeoutMs);
1655
+ timeout.unref?.();
1656
+ try {
1657
+ return await work();
1658
+ } catch (error) {
1659
+ const reason = state.termination;
1660
+ if (reason === "aborted") {
1661
+ throw new TileflowCaptureError("ABORTED", "Tileflow capture was aborted.", { cause: error });
1662
+ }
1663
+ if (reason === "timeout") {
1664
+ throw new TileflowCaptureError(
1665
+ "CAPTURE_TIMEOUT",
1666
+ "Video encoding exceeded the capture timeout.",
1667
+ { cause: error, details: { phase: "video-encode" } }
1668
+ );
1669
+ }
1670
+ if (error instanceof TileflowCaptureError) throw error;
1671
+ throw encodeFailed(void 0, error);
1672
+ } finally {
1673
+ clearTimeout(timeout);
1674
+ }
1675
+ };
1676
+ let page;
1677
+ let codec;
1678
+ try {
1679
+ input.signal?.addEventListener("abort", onAbort, { once: true });
1680
+ const opened = await run(async () => {
1681
+ context = await input.browser.newContext({
1682
+ acceptDownloads: false,
1683
+ javaScriptEnabled: true,
1684
+ serviceWorkers: "block",
1685
+ viewport: { height: 64, width: 64 }
1686
+ });
1687
+ await context.route("**/*", async (route) => {
1688
+ const url = route.request().url();
1689
+ if (url === encoderDocumentUrl) {
1690
+ await route.fulfill({
1691
+ body: '<!doctype html><html lang="en"><head><meta charset="utf-8" /></head><body></body></html>',
1692
+ contentType: "text/html; charset=utf-8",
1693
+ status: 200
1694
+ });
1695
+ return;
1696
+ }
1697
+ if (url === encoderFrameUrl && pendingPng) {
1698
+ await route.fulfill({
1699
+ body: Buffer.from(pendingPng),
1700
+ contentType: "image/png",
1701
+ status: 200
1702
+ });
1703
+ return;
1704
+ }
1705
+ await route.abort("blockedbyclient");
1706
+ });
1707
+ await context.routeWebSocket(
1708
+ /.*/,
1709
+ (socket) => socket.close({ code: 1008, reason: "WebSockets are disabled during capture" })
1710
+ );
1711
+ const encoderPage = await context.newPage();
1712
+ await encoderPage.goto(encoderDocumentUrl, { waitUntil: "domcontentloaded" });
1713
+ await encoderPage.addScriptTag({ content: encoderPageScript });
1714
+ const result = await encoderPage.evaluate(
1715
+ (config) => window.__tileflowOpenShotEncoder(config),
1716
+ {
1717
+ bitrate: shotBitrate(input.width, input.height, input.fps),
1718
+ fps: input.fps,
1719
+ frameUrl: encoderFrameUrl,
1720
+ height: input.height,
1721
+ width: input.width
1722
+ }
1723
+ );
1724
+ return { page: encoderPage, result };
1725
+ });
1726
+ page = opened.page;
1727
+ if (opened.result.status === "unsupported" || !opened.result.codec) {
1728
+ throw new TileflowCaptureError(
1729
+ "VIDEO_ENCODER_UNAVAILABLE",
1730
+ `The capture browser cannot encode ${input.width}x${input.height} VP9 or VP8 video. Capture the shot with format "frames" instead.`,
1731
+ { details: { phase: "video-encode" } }
1732
+ );
1733
+ }
1734
+ if (opened.result.status !== "ok") throw encodeFailed(opened.result.message);
1735
+ codec = opened.result.codec;
1736
+ } catch (error) {
1737
+ await close();
1738
+ throw error;
1739
+ }
1740
+ const collect = (result) => {
1741
+ if (result.status !== "ok") throw encodeFailed(result.message);
1742
+ for (const chunk of result.chunks) {
1743
+ chunks.push({
1744
+ data: new Uint8Array(Buffer.from(chunk.data, "base64")),
1745
+ key: chunk.key,
1746
+ timestamp: chunk.timestamp
1747
+ });
1748
+ }
1749
+ };
1750
+ return {
1751
+ codec,
1752
+ async encode(index, png) {
1753
+ await run(async () => {
1754
+ pendingPng = png;
1755
+ try {
1756
+ collect(
1757
+ await page.evaluate(
1758
+ (frame) => window.__tileflowEncodeShotFrame(frame),
1759
+ index
1760
+ )
1761
+ );
1762
+ } finally {
1763
+ pendingPng = void 0;
1764
+ }
1765
+ });
1766
+ },
1767
+ async finish() {
1768
+ await run(
1769
+ async () => collect(
1770
+ await page.evaluate(
1771
+ () => window.__tileflowFinishShotEncoder()
1772
+ )
1773
+ )
1774
+ );
1775
+ return [...chunks];
1776
+ },
1777
+ close
1778
+ };
1779
+ }
1780
+ function shotBitrate(width, height, fps) {
1781
+ return Math.min(24e6, Math.max(25e4, Math.round(width * height * fps * 0.2)));
1782
+ }
1783
+ function encodeFailed(message, cause) {
1784
+ const detail = message ? ` ${message.replace(/[^A-Za-z0-9 _.,:'"()[\]/=-]/g, "").trim().slice(0, 200)}` : "";
1785
+ return new TileflowCaptureError("VIDEO_ENCODE_FAILED", `Video encoding failed.${detail}`, {
1786
+ ...cause === void 0 ? {} : { cause },
1787
+ details: { phase: "video-encode" }
1788
+ });
1789
+ }
1790
+ var encoderPageScript = String.raw`
1791
+ let encoder;
1792
+ let encoderError;
1793
+ let frameDuration = 0;
1794
+ let keyInterval = 1;
1795
+ let frameUrl = "";
1796
+ const pending = [];
1797
+
1798
+ window.__tileflowOpenShotEncoder = async function(input) {
1799
+ if (typeof VideoEncoder !== "function" || typeof VideoFrame !== "function") {
1800
+ return {status: "unsupported"};
1801
+ }
1802
+ frameDuration = 1e6 / input.fps;
1803
+ keyInterval = input.fps;
1804
+ frameUrl = input.frameUrl;
1805
+ // VP9 profile 0, 8-bit; VP8 where VP9 is unavailable.
1806
+ for (const [codec, codecString] of [["vp9", "vp09.00.10.08"], ["vp8", "vp8"]]) {
1807
+ const config = {
1808
+ bitrate: input.bitrate,
1809
+ codec: codecString,
1810
+ framerate: input.fps,
1811
+ height: input.height,
1812
+ latencyMode: "quality",
1813
+ width: input.width
1814
+ };
1815
+ let support;
1816
+ try {
1817
+ support = await VideoEncoder.isConfigSupported(config);
1818
+ } catch {
1819
+ continue;
1820
+ }
1821
+ if (!support.supported) continue;
1822
+ try {
1823
+ encoder = new VideoEncoder({
1824
+ error: (error) => {
1825
+ encoderError = String(error?.message || error || "encoder error");
1826
+ },
1827
+ output: (chunk) => {
1828
+ const data = new Uint8Array(chunk.byteLength);
1829
+ chunk.copyTo(data);
1830
+ pending.push({data: toBase64(data), key: chunk.type === "key", timestamp: chunk.timestamp});
1831
+ }
1832
+ });
1833
+ encoder.configure(config);
1834
+ return {codec, status: "ok"};
1835
+ } catch (error) {
1836
+ return {message: String(error?.message || error || ""), status: "failed"};
1837
+ }
1838
+ }
1839
+ return {status: "unsupported"};
1840
+ };
1841
+
1842
+ window.__tileflowEncodeShotFrame = async function(index) {
1843
+ try {
1844
+ if (encoderError) return {message: encoderError, status: "failed"};
1845
+ const response = await fetch(frameUrl, {cache: "no-store"});
1846
+ if (!response.ok) return {message: "frame unavailable", status: "failed"};
1847
+ const bitmap = await createImageBitmap(await response.blob(), {
1848
+ colorSpaceConversion: "none",
1849
+ premultiplyAlpha: "none"
1850
+ });
1851
+ const frame = new VideoFrame(bitmap, {
1852
+ duration: Math.round(frameDuration),
1853
+ timestamp: Math.round(index * frameDuration)
1854
+ });
1855
+ bitmap.close();
1856
+ try {
1857
+ encoder.encode(frame, {keyFrame: index % keyInterval === 0});
1858
+ } finally {
1859
+ frame.close();
1860
+ }
1861
+ while (encoder.encodeQueueSize > 2 && !encoderError) {
1862
+ await new Promise((resolve) => encoder.addEventListener("dequeue", resolve, {once: true}));
1863
+ }
1864
+ if (encoderError) return {message: encoderError, status: "failed"};
1865
+ return {chunks: pending.splice(0), status: "ok"};
1866
+ } catch (error) {
1867
+ return {message: String(error?.message || error || ""), status: "failed"};
1868
+ }
1869
+ };
1870
+
1871
+ window.__tileflowFinishShotEncoder = async function() {
1872
+ try {
1873
+ await encoder.flush();
1874
+ encoder.close();
1875
+ if (encoderError) return {message: encoderError, status: "failed"};
1876
+ return {chunks: pending.splice(0), status: "ok"};
1877
+ } catch (error) {
1878
+ return {message: String(encoderError || error?.message || error || ""), status: "failed"};
1879
+ }
1880
+ };
1881
+
1882
+ function toBase64(bytes) {
1883
+ let text = "";
1884
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
1885
+ text += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 0x8000));
1886
+ }
1887
+ return btoa(text);
1888
+ }
1889
+ `;
1890
+
1891
+ // src/webm.ts
1892
+ var tileflowWebmElementIds = Object.freeze({
1893
+ ebml: 440786851,
1894
+ ebmlVersion: 17030,
1895
+ ebmlReadVersion: 17143,
1896
+ ebmlMaxIdLength: 17138,
1897
+ ebmlMaxSizeLength: 17139,
1898
+ docType: 17026,
1899
+ docTypeVersion: 17031,
1900
+ docTypeReadVersion: 17029,
1901
+ segment: 408125543,
1902
+ seekHead: 290298740,
1903
+ seek: 19899,
1904
+ seekId: 21419,
1905
+ seekPosition: 21420,
1906
+ info: 357149030,
1907
+ timestampScale: 2807729,
1908
+ duration: 17545,
1909
+ muxingApp: 19840,
1910
+ writingApp: 22337,
1911
+ tracks: 374648427,
1912
+ trackEntry: 174,
1913
+ trackNumber: 215,
1914
+ trackUid: 29637,
1915
+ trackType: 131,
1916
+ flagLacing: 156,
1917
+ defaultDuration: 2352003,
1918
+ codecId: 134,
1919
+ video: 224,
1920
+ pixelWidth: 176,
1921
+ pixelHeight: 186,
1922
+ cluster: 524531317,
1923
+ timestamp: 231,
1924
+ simpleBlock: 163,
1925
+ cues: 475249515,
1926
+ cuePoint: 187,
1927
+ cueTime: 179,
1928
+ cueTrackPositions: 183,
1929
+ cueTrack: 247,
1930
+ cueClusterPosition: 241
1931
+ });
1932
+ var id = tileflowWebmElementIds;
1933
+ var maximumRelativeTimestamp = 32767;
1934
+ function writeTileflowWebm(input) {
1935
+ if (input.chunks.length === 0 || !input.chunks[0].key) {
1936
+ throw new RangeError("A WebM video must start with a key frame.");
1937
+ }
1938
+ const header = element(id.ebml, [
1939
+ unsignedElement(id.ebmlVersion, 1),
1940
+ unsignedElement(id.ebmlReadVersion, 1),
1941
+ unsignedElement(id.ebmlMaxIdLength, 4),
1942
+ unsignedElement(id.ebmlMaxSizeLength, 8),
1943
+ stringElement(id.docType, "webm"),
1944
+ unsignedElement(id.docTypeVersion, 4),
1945
+ unsignedElement(id.docTypeReadVersion, 2)
1946
+ ]);
1947
+ const info = element(id.info, [
1948
+ unsignedElement(id.timestampScale, 1e6),
1949
+ floatElement(id.duration, input.durationMs),
1950
+ stringElement(id.muxingApp, "Tileflow"),
1951
+ stringElement(id.writingApp, "Tileflow")
1952
+ ]);
1953
+ const tracks = element(id.tracks, [
1954
+ element(id.trackEntry, [
1955
+ unsignedElement(id.trackNumber, 1),
1956
+ unsignedElement(id.trackUid, 1),
1957
+ unsignedElement(id.trackType, 1),
1958
+ unsignedElement(id.flagLacing, 0),
1959
+ unsignedElement(id.defaultDuration, Math.round(1e9 / input.fps)),
1960
+ stringElement(id.codecId, input.codec === "vp9" ? "V_VP9" : "V_VP8"),
1961
+ element(id.video, [
1962
+ unsignedElement(id.pixelWidth, input.width),
1963
+ unsignedElement(id.pixelHeight, input.height)
1964
+ ])
1965
+ ])
1966
+ ]);
1967
+ const clusters = [];
1968
+ let current;
1969
+ const closeCluster = () => {
1970
+ if (!current) return;
1971
+ clusters.push({
1972
+ bytes: element(id.cluster, [
1973
+ unsignedElement(id.timestamp, current.timestamp),
1974
+ ...current.blocks
1975
+ ]),
1976
+ timestamp: current.timestamp
1977
+ });
1978
+ current = void 0;
1979
+ };
1980
+ for (const chunk of input.chunks) {
1981
+ const timestamp = Math.round(chunk.timestamp / 1e3);
1982
+ if (!current || chunk.key || timestamp - current.timestamp > maximumRelativeTimestamp) {
1983
+ closeCluster();
1984
+ current = { blocks: [], timestamp };
1985
+ }
1986
+ current.blocks.push(simpleBlock(timestamp - current.timestamp, chunk.key, chunk.data));
1987
+ }
1988
+ closeCluster();
1989
+ const seekHeadSize = seekHead([0, 0, 0]).byteLength;
1990
+ const infoPosition = seekHeadSize;
1991
+ const tracksPosition = infoPosition + info.byteLength;
1992
+ let position = tracksPosition + tracks.byteLength;
1993
+ const cuePoints = [];
1994
+ for (const cluster of clusters) {
1995
+ cuePoints.push(
1996
+ element(id.cuePoint, [
1997
+ unsignedElement(id.cueTime, cluster.timestamp),
1998
+ element(id.cueTrackPositions, [
1999
+ unsignedElement(id.cueTrack, 1),
2000
+ unsignedElement(id.cueClusterPosition, position)
2001
+ ])
2002
+ ])
2003
+ );
2004
+ position += cluster.bytes.byteLength;
2005
+ }
2006
+ const cuesPosition = position;
2007
+ const segment = element(id.segment, [
2008
+ seekHead([infoPosition, tracksPosition, cuesPosition]),
2009
+ info,
2010
+ tracks,
2011
+ ...clusters.map((cluster) => cluster.bytes),
2012
+ element(id.cues, cuePoints)
2013
+ ]);
2014
+ return concat([header, segment]);
2015
+ }
2016
+ function seekHead([info, tracks, cues]) {
2017
+ const seek = (target, offset) => element(id.seek, [
2018
+ binaryElement(id.seekId, elementIdBytes(target)),
2019
+ element(id.seekPosition, [fixedUnsigned(offset, 8)])
2020
+ ]);
2021
+ return element(id.seekHead, [seek(id.info, info), seek(id.tracks, tracks), seek(id.cues, cues)]);
2022
+ }
2023
+ function simpleBlock(relativeTimestamp, key, data) {
2024
+ const header = new Uint8Array(4);
2025
+ header[0] = 129;
2026
+ new DataView(header.buffer).setInt16(1, relativeTimestamp);
2027
+ header[3] = key ? 128 : 0;
2028
+ return binaryElement(id.simpleBlock, concat([header, data]));
2029
+ }
2030
+ function element(elementId, children) {
2031
+ return binaryElement(elementId, concat(children));
2032
+ }
2033
+ function binaryElement(elementId, data) {
2034
+ return concat([elementIdBytes(elementId), sizeBytes(data.byteLength), data]);
2035
+ }
2036
+ function unsignedElement(elementId, value) {
2037
+ return binaryElement(elementId, minimalUnsigned(value));
2038
+ }
2039
+ function stringElement(elementId, value) {
2040
+ return binaryElement(elementId, new TextEncoder().encode(value));
2041
+ }
2042
+ function floatElement(elementId, value) {
2043
+ const bytes = new Uint8Array(8);
2044
+ new DataView(bytes.buffer).setFloat64(0, value);
2045
+ return binaryElement(elementId, bytes);
2046
+ }
2047
+ function elementIdBytes(elementId) {
2048
+ const length = elementId >= 16777216 ? 4 : elementId >= 65536 ? 3 : elementId >= 256 ? 2 : 1;
2049
+ return fixedUnsigned(elementId, length);
2050
+ }
2051
+ function sizeBytes(size) {
2052
+ if (!Number.isSafeInteger(size) || size < 0) throw new RangeError("Invalid EBML element size.");
2053
+ let length = 1;
2054
+ while (length < 8 && size >= 2 ** (7 * length) - 1) length += 1;
2055
+ if (size >= 2 ** (7 * length) - 1) throw new RangeError("EBML element is too large.");
2056
+ const bytes = fixedUnsigned(size, length);
2057
+ bytes[0] = (bytes[0] ?? 0) | 1 << 8 - length;
2058
+ return bytes;
2059
+ }
2060
+ function minimalUnsigned(value) {
2061
+ let length = 1;
2062
+ while (length < 8 && value >= 2 ** (8 * length)) length += 1;
2063
+ return fixedUnsigned(value, length);
2064
+ }
2065
+ function fixedUnsigned(value, length) {
2066
+ if (!Number.isSafeInteger(value) || value < 0) throw new RangeError("Invalid EBML integer.");
2067
+ const bytes = new Uint8Array(length);
2068
+ let remaining = value;
2069
+ for (let index = length - 1; index >= 0; index -= 1) {
2070
+ bytes[index] = remaining % 256;
2071
+ remaining = Math.floor(remaining / 256);
2072
+ }
2073
+ return bytes;
2074
+ }
2075
+ function concat(parts) {
2076
+ const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
2077
+ let offset = 0;
2078
+ for (const part of parts) {
2079
+ bytes.set(part, offset);
2080
+ offset += part.byteLength;
2081
+ }
2082
+ return bytes;
2083
+ }
2084
+
2085
+ // src/shot.ts
2086
+ async function renderTileflowShot(input) {
2087
+ const { shot } = input;
2088
+ const width = shot.viewport.width * shot.viewport.dpr;
2089
+ const height = shot.viewport.height * shot.viewport.dpr;
2090
+ let encoder;
2091
+ try {
2092
+ if (input.format === "webm") {
2093
+ encoder = await openTileflowShotVideoEncoder({
2094
+ browser: input.browser,
2095
+ fps: shot.fps,
2096
+ height,
2097
+ ...input.signal ? { signal: input.signal } : {},
2098
+ ...input.timeoutMs === void 0 ? {} : { timeoutMs: input.timeoutMs },
2099
+ width
2100
+ });
2101
+ }
2102
+ const frameSha256s = [];
2103
+ const rendered = await captureStandaloneTileflowShot({
2104
+ ...input,
2105
+ onFrame: async ({ index, png }) => {
2106
+ const sha2562 = await sha256Hex(png);
2107
+ frameSha256s.push(sha2562);
2108
+ await input.onFrame?.({ index, png, sha256: sha2562, shot: input.name });
2109
+ await encoder?.encode(index, png);
2110
+ }
2111
+ });
2112
+ const durationMs = shot.keyframes.at(-1).at;
2113
+ const framesSha256 = await tileflowShotFramesSha256(frameSha256s);
2114
+ const video = encoder ? writeTileflowWebm({
2115
+ chunks: await encoder.finish(),
2116
+ codec: encoder.codec,
2117
+ durationMs: rendered.frameCount * 1e3 / shot.fps,
2118
+ fps: shot.fps,
2119
+ height,
2120
+ width
2121
+ }) : void 0;
2122
+ const blendThemes = shot.keyframes[0]?.blend?.themes;
2123
+ const styleHashes = /* @__PURE__ */ new Map();
2124
+ for (const theme of /* @__PURE__ */ new Set([shot.theme, ...blendThemes ?? []])) {
2125
+ styleHashes.set(theme, await sha256Hex(serializeCanonicalJson(input.styles[theme])));
2126
+ }
2127
+ const [sha256, shotSha256] = await Promise.all([
2128
+ video ? sha256Hex(video) : Promise.resolve(framesSha256),
2129
+ sha256Hex(serializeCanonicalJson(shot))
2130
+ ]);
2131
+ const styleSha256 = styleHashes.get(shot.theme);
2132
+ const receipt = createTileflowShotReceipt({
2133
+ ...blendThemes ? { blend: blendThemes.map((name) => ({ name, styleSha256: styleHashes.get(name) })) } : {},
2134
+ data: input.data,
2135
+ dpr: shot.viewport.dpr,
2136
+ durationMs,
2137
+ fps: shot.fps,
2138
+ frameCount: rendered.frameCount,
2139
+ framesSha256,
2140
+ height: rendered.height,
2141
+ map: shot.map,
2142
+ networkDependent: rendered.networkDependent,
2143
+ output: encoder ? { codec: encoder.codec, kind: "webm", sha256 } : { kind: "frames", sha256 },
2144
+ renderer: input.renderer,
2145
+ shot: input.name,
2146
+ shotSha256,
2147
+ styleSha256,
2148
+ theme: shot.theme,
2149
+ width: rendered.width
2150
+ });
2151
+ return {
2152
+ shot: input.name,
2153
+ map: shot.map,
2154
+ theme: shot.theme,
2155
+ format: input.format,
2156
+ ...video ? { video } : {},
2157
+ ...encoder ? { codec: encoder.codec } : {},
2158
+ sha256,
2159
+ framesSha256,
2160
+ frameSha256s,
2161
+ shotSha256,
2162
+ styleSha256,
2163
+ fps: shot.fps,
2164
+ frameCount: rendered.frameCount,
2165
+ durationMs,
2166
+ width: rendered.width,
2167
+ height: rendered.height,
2168
+ dpr: shot.viewport.dpr,
2169
+ networkDependent: rendered.networkDependent,
2170
+ renderer: input.renderer,
2171
+ receipt,
2172
+ warnings: rendered.warnings
2173
+ };
2174
+ } finally {
2175
+ await encoder?.close();
2176
+ }
2177
+ }
2178
+ function tileflowShotFramesSha256(frameSha256s) {
2179
+ return sha256Hex(frameSha256s.map((sha256) => `${sha256}
2180
+ `).join(""));
2181
+ }
2182
+ async function captureStandaloneTileflowShot(input) {
2183
+ const { shot } = input;
2184
+ const frames = tileflowCaptureShotFrames(shot);
2185
+ const blendThemes = shot.keyframes[0]?.blend?.themes;
2186
+ const themes = [.../* @__PURE__ */ new Set([shot.theme, ...blendThemes ?? []])];
2187
+ const styles = themes.map((theme) => {
2188
+ const style = Object.hasOwn(input.styles, theme) ? input.styles[theme] : void 0;
2189
+ if (!style) {
2190
+ throw new TileflowCaptureError(
2191
+ "SHOT_NOT_FOUND",
2192
+ `Shot theme "${theme}" has no compiled style on map "${shot.map}".`
2193
+ );
2194
+ }
2195
+ assertValidCaptureStyle(style, `${shot.map}/${theme}`);
2196
+ return [theme, pageStyle(style)];
2197
+ });
2198
+ const pageStyles = Object.fromEntries(styles);
2199
+ return withStandaloneTileflowPage(
2200
+ {
2201
+ assets: input.assets,
2202
+ browser: input.browser,
2203
+ ...input.glyphCacheDirectory ? { glyphCacheDirectory: input.glyphCacheDirectory } : {},
2204
+ ...input.lineBackgroundGlyphs ? { lineBackgroundGlyphs: input.lineBackgroundGlyphs } : {},
2205
+ ...input.localTilesets ? { localTilesets: input.localTilesets } : {},
2206
+ ...input.signal ? { signal: input.signal } : {},
2207
+ style: pageStyles[shot.theme],
2208
+ ...input.timeoutMs === void 0 ? {} : { timeoutMs: input.timeoutMs },
2209
+ viewport: shot.viewport
2210
+ },
2211
+ async (page) => {
2212
+ await page.load(uniqueFontFaces(styles.map(([, style]) => style)));
2213
+ if (blendThemes) {
2214
+ page.restartTimeout();
2215
+ await page.step("theme-blend", "__tileflowCapturePrepareBlend", {
2216
+ mapEventTimeoutMs: page.mapEventTimeoutMs,
2217
+ position: frames[0]?.blendPosition ?? 0,
2218
+ theme: shot.theme,
2219
+ themes: blendThemes.map((theme) => ({ style: pageStyles[theme], theme }))
2220
+ });
2221
+ }
2222
+ for (const frame of frames) {
2223
+ page.restartTimeout();
2224
+ await page.step("map-idle", "__tileflowCaptureFrame", {
2225
+ blendPosition: frame.blendPosition,
2226
+ camera: frame.camera,
2227
+ mapEventTimeoutMs: page.mapEventTimeoutMs
2228
+ });
2229
+ const png = await page.screenshot();
2230
+ await page.outside(async () => input.onFrame({ index: frame.index, png }));
2231
+ }
2232
+ return {
2233
+ frameCount: frames.length,
2234
+ height: shot.viewport.height,
2235
+ width: shot.viewport.width,
2236
+ ...page.result()
2237
+ };
2238
+ }
2239
+ );
2240
+ }
2241
+ function pageStyle(style) {
2242
+ return { ...style, transition: { delay: 0, duration: 0 } };
2243
+ }
2244
+ function uniqueFontFaces(styles) {
2245
+ const faces = /* @__PURE__ */ new Map();
2246
+ for (const style of styles) {
2247
+ for (const face of getTileflowStyleFontFaces2(style) ?? []) {
2248
+ faces.set(JSON.stringify(face), face);
2249
+ }
2250
+ }
2251
+ return [...faces.values()];
2252
+ }
2253
+
1512
2254
  // src/world.ts
1513
2255
  import { createHash } from "crypto";
1514
2256
  import {
@@ -1785,6 +2527,18 @@ async function captureTileflowScenes(options) {
1785
2527
  await session.close();
1786
2528
  }
1787
2529
  }
2530
+ async function captureTileflowShot(options) {
2531
+ const { format, onFrame, shot, ...sessionOptions } = options;
2532
+ const session = createTileflowCaptureSession(sessionOptions);
2533
+ try {
2534
+ return await session.captureShot(shot, {
2535
+ ...format ? { format } : {},
2536
+ ...onFrame ? { onFrame } : {}
2537
+ });
2538
+ } finally {
2539
+ await session.close();
2540
+ }
2541
+ }
1788
2542
  function createTileflowCaptureSession(options = {}) {
1789
2543
  return new TileflowCaptureSessionImpl(options);
1790
2544
  }
@@ -1914,9 +2668,9 @@ var TileflowCaptureSessionImpl = class {
1914
2668
  timeoutMs: this.#options.timeoutMs
1915
2669
  });
1916
2670
  const [sha256, sceneSha256, styleSha256] = await Promise.all([
1917
- sha256Hex(rendered.png),
1918
- sha256Hex(serializeCanonicalJson(scene)),
1919
- sha256Hex(serializeCanonicalJson(style))
2671
+ sha256Hex2(rendered.png),
2672
+ sha256Hex2(serializeCanonicalJson2(scene)),
2673
+ sha256Hex2(serializeCanonicalJson2(style))
1920
2674
  ]);
1921
2675
  const target = scene.target.kind;
1922
2676
  const receipt = createTileflowCaptureReceipt({
@@ -1954,6 +2708,98 @@ var TileflowCaptureSessionImpl = class {
1954
2708
  }
1955
2709
  return { schemaVersion: tileflowCaptureResultSchemaVersion, captures };
1956
2710
  }
2711
+ async captureShot(shot, options = {}) {
2712
+ const result = await this.captureShots([shot], options);
2713
+ return result.shots[0];
2714
+ }
2715
+ async captureAllShots(options = {}) {
2716
+ this.#assertOpen();
2717
+ const artifacts = await this.#createArtifacts();
2718
+ try {
2719
+ return await this.#captureShotArtifacts(
2720
+ artifacts,
2721
+ Object.keys(artifacts.project.shots ?? {}),
2722
+ options
2723
+ );
2724
+ } finally {
2725
+ await disposeCaptureArtifacts(artifacts);
2726
+ }
2727
+ }
2728
+ async captureShots(shots, options = {}) {
2729
+ this.#assertOpen();
2730
+ const artifacts = await this.#createArtifacts();
2731
+ try {
2732
+ return await this.#captureShotArtifacts(artifacts, shots, options);
2733
+ } finally {
2734
+ await disposeCaptureArtifacts(artifacts);
2735
+ }
2736
+ }
2737
+ async #captureShotArtifacts(artifacts, requestedShots, options) {
2738
+ const signal = options.signal ?? this.#options.signal;
2739
+ const format = options.format ?? "webm";
2740
+ if (format !== "webm" && format !== "frames") {
2741
+ throw new TypeError('Expected shot format "webm" or "frames".');
2742
+ }
2743
+ const shotNames = selectTileflowCaptureShotNames(artifacts, requestedShots);
2744
+ const resolved = [];
2745
+ const preparedStyles = /* @__PURE__ */ new Map();
2746
+ for (const name of shotNames) {
2747
+ const shot = normalizeShot(artifacts, name);
2748
+ const themes = [.../* @__PURE__ */ new Set([shot.theme, ...shot.keyframes[0]?.blend?.themes ?? []])];
2749
+ const styles = {};
2750
+ const prepared = [];
2751
+ for (const theme of themes) {
2752
+ const styleKey = `${shot.map}/${theme}`;
2753
+ let preparedStyle = preparedStyles.get(styleKey);
2754
+ if (!preparedStyle) {
2755
+ const style = artifacts.styles[shot.map]?.[theme];
2756
+ if (!style) {
2757
+ throw new TileflowCaptureError(
2758
+ "SHOT_NOT_FOUND",
2759
+ `Shot "${name}" references unavailable theme "${theme}" on map "${shot.map}".`
2760
+ );
2761
+ }
2762
+ preparedStyle = await this.#worldSession.prepare(style, signal);
2763
+ try {
2764
+ assertValidTileflowStyle2(preparedStyle.style, styleKey);
2765
+ } catch (error) {
2766
+ throwCaptureStyleValidationError(error);
2767
+ }
2768
+ preparedStyles.set(styleKey, preparedStyle);
2769
+ }
2770
+ styles[theme] = preparedStyle.style;
2771
+ prepared.push(preparedStyle);
2772
+ }
2773
+ resolved.push({ name, prepared, shot, styles });
2774
+ }
2775
+ throwIfAborted2(signal);
2776
+ const browser = await this.#browserManager.getBrowser(signal);
2777
+ const renderer = createTileflowCaptureRendererIdentity(browser);
2778
+ const shots = [];
2779
+ for (const { name, prepared, shot, styles } of resolved) {
2780
+ throwIfAborted2(signal);
2781
+ const capture = await renderTileflowShot({
2782
+ assets: [...artifacts.assets, ...prepared.flatMap((style) => style.assets ?? [])],
2783
+ browser,
2784
+ data: prepared[0].data,
2785
+ format,
2786
+ ...artifacts.localTilesets ? { localTilesets: artifacts.localTilesets } : {},
2787
+ ...artifacts.lineBackgroundGlyphs ? {
2788
+ glyphCacheDirectory: resolve(this.#options.cwd ?? process.cwd()),
2789
+ lineBackgroundGlyphs: artifacts.lineBackgroundGlyphs
2790
+ } : {},
2791
+ name,
2792
+ ...options.onFrame ? { onFrame: options.onFrame } : {},
2793
+ renderer,
2794
+ shot,
2795
+ ...signal ? { signal } : {},
2796
+ styles,
2797
+ ...this.#options.timeoutMs === void 0 ? {} : { timeoutMs: this.#options.timeoutMs }
2798
+ });
2799
+ shots.push({ ...capture, warnings: [...capture.warnings].sort(compareCodeUnits3) });
2800
+ }
2801
+ return { schemaVersion: tileflowCaptureResultSchemaVersion, shots };
2802
+ }
1957
2803
  async close() {
1958
2804
  if (this.#closed) {
1959
2805
  return;
@@ -2042,6 +2888,53 @@ function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
2042
2888
  }
2043
2889
  return sceneNames;
2044
2890
  }
2891
+ function selectTileflowCaptureShotNames(artifacts, requestedShots) {
2892
+ const availableShots = Object.keys(artifacts.project.shots ?? {}).filter((shotName) => tileflowCaptureShotNameSchema.safeParse(shotName).success).sort(compareCodeUnits3);
2893
+ const shotNames = [...new Set(requestedShots)].sort(compareCodeUnits3);
2894
+ if (shotNames.length === 0) {
2895
+ throw new TileflowCaptureError(
2896
+ "SHOT_NOT_FOUND",
2897
+ "Select at least one capture shot or request every configured shot explicitly."
2898
+ );
2899
+ }
2900
+ for (const shotName of shotNames) {
2901
+ if (!tileflowCaptureShotNameSchema.safeParse(shotName).success) {
2902
+ throw new TileflowCaptureError("SHOT_NOT_FOUND", "Expected a portable Tileflow shot name.");
2903
+ }
2904
+ if (!Object.hasOwn(artifacts.project.shots ?? {}, shotName)) {
2905
+ const suffix = availableShots.length > 0 ? ` Available shots: ${availableShots.join(", ")}.` : " The config defines no capture shots.";
2906
+ throw new TileflowCaptureError(
2907
+ "SHOT_NOT_FOUND",
2908
+ `Unknown Tileflow capture shot "${shotName}".${suffix}`
2909
+ );
2910
+ }
2911
+ }
2912
+ return shotNames;
2913
+ }
2914
+ function normalizeShot(artifacts, shotName) {
2915
+ const shot = artifacts.project.shots[shotName];
2916
+ const normalized = normalizeTileflowCaptureShot(shot);
2917
+ const resolvedMap = artifacts.project.maps[normalized.map];
2918
+ if (!resolvedMap) {
2919
+ throw new TileflowCaptureError(
2920
+ "SHOT_NOT_FOUND",
2921
+ `Shot "${shotName}" references unavailable map "${normalized.map}".`
2922
+ );
2923
+ }
2924
+ const map = parseResolvedTileflowMap(resolvedMap);
2925
+ for (const theme of [normalized.theme, ...normalized.keyframes[0]?.blend?.themes ?? []]) {
2926
+ try {
2927
+ resolveThemeSelection(map, theme);
2928
+ } catch (error) {
2929
+ throw new TileflowCaptureError(
2930
+ "SHOT_NOT_FOUND",
2931
+ `Shot "${shotName}" has an invalid theme: ${error instanceof Error ? error.message : "unknown theme"}`,
2932
+ { cause: error }
2933
+ );
2934
+ }
2935
+ }
2936
+ return normalized;
2937
+ }
2045
2938
  function normalizeScene(artifacts, sceneName) {
2046
2939
  const scene = Object.hasOwn(artifacts.project.scenes ?? {}, sceneName) ? artifacts.project.scenes?.[sceneName] : void 0;
2047
2940
  if (!scene) {
@@ -2092,8 +2985,8 @@ import { types as nodeUtilTypes } from "util";
2092
2985
  import { PNG } from "pngjs";
2093
2986
  import {
2094
2987
  compareCodeUnits as compareCodeUnits4,
2095
- serializeCanonicalJson as serializeCanonicalJson2,
2096
- sha256Hex as sha256Hex2,
2988
+ serializeCanonicalJson as serializeCanonicalJson3,
2989
+ sha256Hex as sha256Hex3,
2097
2990
  tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2
2098
2991
  } from "@tileflow/core";
2099
2992
  var tileflowVisualComparisonSchemaVersion = 1;
@@ -2201,7 +3094,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng, opti
2201
3094
  target: receipt.scene.target,
2202
3095
  dimensionsMatch,
2203
3096
  reference: {
2204
- sha256: await sha256Hex2(referencePng),
3097
+ sha256: await sha256Hex3(referencePng),
2205
3098
  physicalWidth: referenceImage.width,
2206
3099
  physicalHeight: referenceImage.height,
2207
3100
  palette: createQuantizedPalette(referenceImage.data)
@@ -2414,7 +3307,7 @@ function createTileflowVisualComparisonDocument(comparison) {
2414
3307
  return document;
2415
3308
  }
2416
3309
  function serializeTileflowVisualComparison(comparison) {
2417
- return `${serializeCanonicalJson2(createTileflowVisualComparisonDocument(comparison))}
3310
+ return `${serializeCanonicalJson3(createTileflowVisualComparisonDocument(comparison))}
2418
3311
  `;
2419
3312
  }
2420
3313
  function imageIdentity(receipt) {
@@ -2436,7 +3329,7 @@ async function validatePngAgainstReceipt(png, receipt, role) {
2436
3329
  if (image.width !== receipt.image.physicalWidth || image.height !== receipt.image.physicalHeight) {
2437
3330
  throw invalidBaseline(`The ${role} PNG dimensions do not match its receipt.`);
2438
3331
  }
2439
- if (await sha256Hex2(png) !== receipt.image.sha256) {
3332
+ if (await sha256Hex3(png) !== receipt.image.sha256) {
2440
3333
  throw invalidBaseline(`The ${role} PNG hash does not match its receipt.`);
2441
3334
  }
2442
3335
  return image;
@@ -2758,7 +3651,7 @@ function pixelMetric(changedPixels, totalPixels) {
2758
3651
  return { changedPixels, totalPixels, ratio: changedPixels / totalPixels };
2759
3652
  }
2760
3653
  function sameSceneIdentity(baseline, actual) {
2761
- return baseline.scene.name === actual.scene.name && baseline.scene.map === actual.scene.map && baseline.scene.theme === actual.scene.theme && baseline.scene.target === actual.scene.target && baseline.scene.sha256 === actual.scene.sha256 && serializeCanonicalJson2(baseline.data) === serializeCanonicalJson2(actual.data);
3654
+ return baseline.scene.name === actual.scene.name && baseline.scene.map === actual.scene.map && baseline.scene.theme === actual.scene.theme && baseline.scene.target === actual.scene.target && baseline.scene.sha256 === actual.scene.sha256 && serializeCanonicalJson3(baseline.data) === serializeCanonicalJson3(actual.data);
2762
3655
  }
2763
3656
  function sameRuntimeIdentity(baseline, actual) {
2764
3657
  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;
@@ -2781,8 +3674,8 @@ function invalidBaseline(message, cause) {
2781
3674
  import { types as nodeUtilTypes2 } from "util";
2782
3675
  import {
2783
3676
  normalizeTileflowCaptureScene as normalizeTileflowCaptureScene2,
2784
- serializeCanonicalJson as serializeCanonicalJson3,
2785
- sha256Hex as sha256Hex3
3677
+ serializeCanonicalJson as serializeCanonicalJson4,
3678
+ sha256Hex as sha256Hex4
2786
3679
  } from "@tileflow/core";
2787
3680
  var tileflowVisualReviewSchemaVersion = 1;
2788
3681
  var TileflowVisualReviewError = class extends Error {
@@ -3107,7 +4000,7 @@ async function validateReviewCapture(input, role) {
3107
4000
  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)) {
3108
4001
  throw invalidReview(`The ${role} capture metadata does not match its receipt.`);
3109
4002
  }
3110
- const definitionSha256 = await sha256Hex3(serializeCanonicalJson3(definition));
4003
+ const definitionSha256 = await sha256Hex4(serializeCanonicalJson4(definition));
3111
4004
  if (definitionSha256 !== receipt.scene.sha256) {
3112
4005
  throw invalidReview(`The ${role} review definition does not match its capture receipt.`);
3113
4006
  }
@@ -3193,7 +4086,7 @@ function reviewWarnings(status, left, right) {
3193
4086
  return [...new Set(warnings)].sort(compareCodeUnits5);
3194
4087
  }
3195
4088
  function sameCanonicalValue(left, right) {
3196
- return serializeCanonicalJson3(left) === serializeCanonicalJson3(right);
4089
+ return serializeCanonicalJson4(left) === serializeCanonicalJson4(right);
3197
4090
  }
3198
4091
  function compareCodeUnits5(left, right) {
3199
4092
  return left < right ? -1 : left > right ? 1 : 0;
@@ -3209,26 +4102,33 @@ export {
3209
4102
  analyzeTileflowCaptureReference,
3210
4103
  captureErrorMessage,
3211
4104
  captureTileflowScenes,
4105
+ captureTileflowShot,
3212
4106
  compareTileflowCaptureToBaseline,
3213
4107
  compareTileflowCapturesForReview,
3214
4108
  createTileflowCaptureBrowserEnvironment,
3215
4109
  createTileflowCaptureReceipt,
3216
4110
  createTileflowCaptureRendererIdentity,
3217
4111
  createTileflowCaptureSession,
4112
+ createTileflowShotReceipt,
3218
4113
  createTileflowVisualComparisonDocument,
3219
4114
  createTileflowVisualReferenceAnalysisDocument,
3220
4115
  createTileflowVisualReviewDocument,
3221
4116
  launchTileflowCaptureBrowser,
3222
4117
  parseTileflowCaptureReceipt,
4118
+ parseTileflowShotReceipt,
3223
4119
  resolveTileflowApplicationUrl,
3224
4120
  resolveTileflowCaptureWorldTileJson,
3225
4121
  serializeTileflowCaptureReceipt,
4122
+ serializeTileflowShotReceipt,
3226
4123
  serializeTileflowVisualComparison,
3227
4124
  setupTileflowCaptureBrowser,
3228
4125
  tileflowCaptureReceiptLimits,
3229
4126
  tileflowCaptureReceiptSchemaVersion,
3230
4127
  tileflowCaptureResultSchemaVersion,
3231
4128
  tileflowCaptureRuntime,
4129
+ tileflowShotFramesSha256,
4130
+ tileflowShotReceiptLimits,
4131
+ tileflowShotReceiptSchemaVersion,
3232
4132
  tileflowSyntheticAssetOrigin,
3233
4133
  tileflowVisualArtifactLimits,
3234
4134
  tileflowVisualComparisonSchemaVersion,
@@ -3238,5 +4138,6 @@ export {
3238
4138
  tileflowVisualReviewSchemaVersion,
3239
4139
  tileflowWorldCurrentTileJsonUrl,
3240
4140
  validateTileflowCaptureReceipt,
4141
+ validateTileflowShotReceipt,
3241
4142
  validateTileflowVisualReferencePng
3242
4143
  };