@tileflow/capture 0.1.0-alpha.29 → 0.1.0-alpha.31
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 +70 -2
- package/dist/{chunk-VNDAYGXY.js → chunk-BI5PWIH4.js} +255 -1
- package/dist/index.d.ts +75 -12
- package/dist/index.js +1005 -92
- package/dist/{receipt-DhFOATRO.d.ts → receipt-CfZkzyXd.d.ts} +92 -1
- package/dist/receipt.d.ts +1 -1
- package/dist/receipt.js +15 -3
- package/docs/receipts-and-comparison.md +8 -0
- package/package.json +7 -7
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
|
-
|
|
10
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
394
|
-
width: input.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
596
|
+
mapEventTimeoutMs,
|
|
552
597
|
style: input.style
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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(
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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({
|
|
@@ -1935,6 +2689,7 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1935
2689
|
width: rendered.width
|
|
1936
2690
|
});
|
|
1937
2691
|
captures.push({
|
|
2692
|
+
definition: scene,
|
|
1938
2693
|
scene: sceneName,
|
|
1939
2694
|
map: scene.map,
|
|
1940
2695
|
theme: scene.theme,
|
|
@@ -1954,6 +2709,98 @@ var TileflowCaptureSessionImpl = class {
|
|
|
1954
2709
|
}
|
|
1955
2710
|
return { schemaVersion: tileflowCaptureResultSchemaVersion, captures };
|
|
1956
2711
|
}
|
|
2712
|
+
async captureShot(shot, options = {}) {
|
|
2713
|
+
const result = await this.captureShots([shot], options);
|
|
2714
|
+
return result.shots[0];
|
|
2715
|
+
}
|
|
2716
|
+
async captureAllShots(options = {}) {
|
|
2717
|
+
this.#assertOpen();
|
|
2718
|
+
const artifacts = await this.#createArtifacts();
|
|
2719
|
+
try {
|
|
2720
|
+
return await this.#captureShotArtifacts(
|
|
2721
|
+
artifacts,
|
|
2722
|
+
Object.keys(artifacts.project.shots ?? {}),
|
|
2723
|
+
options
|
|
2724
|
+
);
|
|
2725
|
+
} finally {
|
|
2726
|
+
await disposeCaptureArtifacts(artifacts);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
async captureShots(shots, options = {}) {
|
|
2730
|
+
this.#assertOpen();
|
|
2731
|
+
const artifacts = await this.#createArtifacts();
|
|
2732
|
+
try {
|
|
2733
|
+
return await this.#captureShotArtifacts(artifacts, shots, options);
|
|
2734
|
+
} finally {
|
|
2735
|
+
await disposeCaptureArtifacts(artifacts);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
async #captureShotArtifacts(artifacts, requestedShots, options) {
|
|
2739
|
+
const signal = options.signal ?? this.#options.signal;
|
|
2740
|
+
const format = options.format ?? "webm";
|
|
2741
|
+
if (format !== "webm" && format !== "frames") {
|
|
2742
|
+
throw new TypeError('Expected shot format "webm" or "frames".');
|
|
2743
|
+
}
|
|
2744
|
+
const shotNames = selectTileflowCaptureShotNames(artifacts, requestedShots);
|
|
2745
|
+
const resolved = [];
|
|
2746
|
+
const preparedStyles = /* @__PURE__ */ new Map();
|
|
2747
|
+
for (const name of shotNames) {
|
|
2748
|
+
const shot = normalizeShot(artifacts, name);
|
|
2749
|
+
const themes = [.../* @__PURE__ */ new Set([shot.theme, ...shot.keyframes[0]?.blend?.themes ?? []])];
|
|
2750
|
+
const styles = {};
|
|
2751
|
+
const prepared = [];
|
|
2752
|
+
for (const theme of themes) {
|
|
2753
|
+
const styleKey = `${shot.map}/${theme}`;
|
|
2754
|
+
let preparedStyle = preparedStyles.get(styleKey);
|
|
2755
|
+
if (!preparedStyle) {
|
|
2756
|
+
const style = artifacts.styles[shot.map]?.[theme];
|
|
2757
|
+
if (!style) {
|
|
2758
|
+
throw new TileflowCaptureError(
|
|
2759
|
+
"SHOT_NOT_FOUND",
|
|
2760
|
+
`Shot "${name}" references unavailable theme "${theme}" on map "${shot.map}".`
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
preparedStyle = await this.#worldSession.prepare(style, signal);
|
|
2764
|
+
try {
|
|
2765
|
+
assertValidTileflowStyle2(preparedStyle.style, styleKey);
|
|
2766
|
+
} catch (error) {
|
|
2767
|
+
throwCaptureStyleValidationError(error);
|
|
2768
|
+
}
|
|
2769
|
+
preparedStyles.set(styleKey, preparedStyle);
|
|
2770
|
+
}
|
|
2771
|
+
styles[theme] = preparedStyle.style;
|
|
2772
|
+
prepared.push(preparedStyle);
|
|
2773
|
+
}
|
|
2774
|
+
resolved.push({ name, prepared, shot, styles });
|
|
2775
|
+
}
|
|
2776
|
+
throwIfAborted2(signal);
|
|
2777
|
+
const browser = await this.#browserManager.getBrowser(signal);
|
|
2778
|
+
const renderer = createTileflowCaptureRendererIdentity(browser);
|
|
2779
|
+
const shots = [];
|
|
2780
|
+
for (const { name, prepared, shot, styles } of resolved) {
|
|
2781
|
+
throwIfAborted2(signal);
|
|
2782
|
+
const capture = await renderTileflowShot({
|
|
2783
|
+
assets: [...artifacts.assets, ...prepared.flatMap((style) => style.assets ?? [])],
|
|
2784
|
+
browser,
|
|
2785
|
+
data: prepared[0].data,
|
|
2786
|
+
format,
|
|
2787
|
+
...artifacts.localTilesets ? { localTilesets: artifacts.localTilesets } : {},
|
|
2788
|
+
...artifacts.lineBackgroundGlyphs ? {
|
|
2789
|
+
glyphCacheDirectory: resolve(this.#options.cwd ?? process.cwd()),
|
|
2790
|
+
lineBackgroundGlyphs: artifacts.lineBackgroundGlyphs
|
|
2791
|
+
} : {},
|
|
2792
|
+
name,
|
|
2793
|
+
...options.onFrame ? { onFrame: options.onFrame } : {},
|
|
2794
|
+
renderer,
|
|
2795
|
+
shot,
|
|
2796
|
+
...signal ? { signal } : {},
|
|
2797
|
+
styles,
|
|
2798
|
+
...this.#options.timeoutMs === void 0 ? {} : { timeoutMs: this.#options.timeoutMs }
|
|
2799
|
+
});
|
|
2800
|
+
shots.push({ ...capture, warnings: [...capture.warnings].sort(compareCodeUnits3) });
|
|
2801
|
+
}
|
|
2802
|
+
return { schemaVersion: tileflowCaptureResultSchemaVersion, shots };
|
|
2803
|
+
}
|
|
1957
2804
|
async close() {
|
|
1958
2805
|
if (this.#closed) {
|
|
1959
2806
|
return;
|
|
@@ -2042,6 +2889,53 @@ function selectTileflowCaptureSceneNames(artifacts, requestedScenes) {
|
|
|
2042
2889
|
}
|
|
2043
2890
|
return sceneNames;
|
|
2044
2891
|
}
|
|
2892
|
+
function selectTileflowCaptureShotNames(artifacts, requestedShots) {
|
|
2893
|
+
const availableShots = Object.keys(artifacts.project.shots ?? {}).filter((shotName) => tileflowCaptureShotNameSchema.safeParse(shotName).success).sort(compareCodeUnits3);
|
|
2894
|
+
const shotNames = [...new Set(requestedShots)].sort(compareCodeUnits3);
|
|
2895
|
+
if (shotNames.length === 0) {
|
|
2896
|
+
throw new TileflowCaptureError(
|
|
2897
|
+
"SHOT_NOT_FOUND",
|
|
2898
|
+
"Select at least one capture shot or request every configured shot explicitly."
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
2901
|
+
for (const shotName of shotNames) {
|
|
2902
|
+
if (!tileflowCaptureShotNameSchema.safeParse(shotName).success) {
|
|
2903
|
+
throw new TileflowCaptureError("SHOT_NOT_FOUND", "Expected a portable Tileflow shot name.");
|
|
2904
|
+
}
|
|
2905
|
+
if (!Object.hasOwn(artifacts.project.shots ?? {}, shotName)) {
|
|
2906
|
+
const suffix = availableShots.length > 0 ? ` Available shots: ${availableShots.join(", ")}.` : " The config defines no capture shots.";
|
|
2907
|
+
throw new TileflowCaptureError(
|
|
2908
|
+
"SHOT_NOT_FOUND",
|
|
2909
|
+
`Unknown Tileflow capture shot "${shotName}".${suffix}`
|
|
2910
|
+
);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
return shotNames;
|
|
2914
|
+
}
|
|
2915
|
+
function normalizeShot(artifacts, shotName) {
|
|
2916
|
+
const shot = artifacts.project.shots[shotName];
|
|
2917
|
+
const normalized = normalizeTileflowCaptureShot(shot);
|
|
2918
|
+
const resolvedMap = artifacts.project.maps[normalized.map];
|
|
2919
|
+
if (!resolvedMap) {
|
|
2920
|
+
throw new TileflowCaptureError(
|
|
2921
|
+
"SHOT_NOT_FOUND",
|
|
2922
|
+
`Shot "${shotName}" references unavailable map "${normalized.map}".`
|
|
2923
|
+
);
|
|
2924
|
+
}
|
|
2925
|
+
const map = parseResolvedTileflowMap(resolvedMap);
|
|
2926
|
+
for (const theme of [normalized.theme, ...normalized.keyframes[0]?.blend?.themes ?? []]) {
|
|
2927
|
+
try {
|
|
2928
|
+
resolveThemeSelection(map, theme);
|
|
2929
|
+
} catch (error) {
|
|
2930
|
+
throw new TileflowCaptureError(
|
|
2931
|
+
"SHOT_NOT_FOUND",
|
|
2932
|
+
`Shot "${shotName}" has an invalid theme: ${error instanceof Error ? error.message : "unknown theme"}`,
|
|
2933
|
+
{ cause: error }
|
|
2934
|
+
);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
return normalized;
|
|
2938
|
+
}
|
|
2045
2939
|
function normalizeScene(artifacts, sceneName) {
|
|
2046
2940
|
const scene = Object.hasOwn(artifacts.project.scenes ?? {}, sceneName) ? artifacts.project.scenes?.[sceneName] : void 0;
|
|
2047
2941
|
if (!scene) {
|
|
@@ -2092,8 +2986,8 @@ import { types as nodeUtilTypes } from "util";
|
|
|
2092
2986
|
import { PNG } from "pngjs";
|
|
2093
2987
|
import {
|
|
2094
2988
|
compareCodeUnits as compareCodeUnits4,
|
|
2095
|
-
serializeCanonicalJson as
|
|
2096
|
-
sha256Hex as
|
|
2989
|
+
serializeCanonicalJson as serializeCanonicalJson3,
|
|
2990
|
+
sha256Hex as sha256Hex3,
|
|
2097
2991
|
tileflowCaptureSceneLimits as tileflowCaptureSceneLimits2
|
|
2098
2992
|
} from "@tileflow/core";
|
|
2099
2993
|
var tileflowVisualComparisonSchemaVersion = 1;
|
|
@@ -2201,7 +3095,7 @@ async function analyzeTileflowCaptureReference(actualCapture, referencePng, opti
|
|
|
2201
3095
|
target: receipt.scene.target,
|
|
2202
3096
|
dimensionsMatch,
|
|
2203
3097
|
reference: {
|
|
2204
|
-
sha256: await
|
|
3098
|
+
sha256: await sha256Hex3(referencePng),
|
|
2205
3099
|
physicalWidth: referenceImage.width,
|
|
2206
3100
|
physicalHeight: referenceImage.height,
|
|
2207
3101
|
palette: createQuantizedPalette(referenceImage.data)
|
|
@@ -2414,7 +3308,7 @@ function createTileflowVisualComparisonDocument(comparison) {
|
|
|
2414
3308
|
return document;
|
|
2415
3309
|
}
|
|
2416
3310
|
function serializeTileflowVisualComparison(comparison) {
|
|
2417
|
-
return `${
|
|
3311
|
+
return `${serializeCanonicalJson3(createTileflowVisualComparisonDocument(comparison))}
|
|
2418
3312
|
`;
|
|
2419
3313
|
}
|
|
2420
3314
|
function imageIdentity(receipt) {
|
|
@@ -2436,7 +3330,7 @@ async function validatePngAgainstReceipt(png, receipt, role) {
|
|
|
2436
3330
|
if (image.width !== receipt.image.physicalWidth || image.height !== receipt.image.physicalHeight) {
|
|
2437
3331
|
throw invalidBaseline(`The ${role} PNG dimensions do not match its receipt.`);
|
|
2438
3332
|
}
|
|
2439
|
-
if (await
|
|
3333
|
+
if (await sha256Hex3(png) !== receipt.image.sha256) {
|
|
2440
3334
|
throw invalidBaseline(`The ${role} PNG hash does not match its receipt.`);
|
|
2441
3335
|
}
|
|
2442
3336
|
return image;
|
|
@@ -2758,7 +3652,7 @@ function pixelMetric(changedPixels, totalPixels) {
|
|
|
2758
3652
|
return { changedPixels, totalPixels, ratio: changedPixels / totalPixels };
|
|
2759
3653
|
}
|
|
2760
3654
|
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 &&
|
|
3655
|
+
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
3656
|
}
|
|
2763
3657
|
function sameRuntimeIdentity(baseline, actual) {
|
|
2764
3658
|
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 +3675,8 @@ function invalidBaseline(message, cause) {
|
|
|
2781
3675
|
import { types as nodeUtilTypes2 } from "util";
|
|
2782
3676
|
import {
|
|
2783
3677
|
normalizeTileflowCaptureScene as normalizeTileflowCaptureScene2,
|
|
2784
|
-
serializeCanonicalJson as
|
|
2785
|
-
sha256Hex as
|
|
3678
|
+
serializeCanonicalJson as serializeCanonicalJson4,
|
|
3679
|
+
sha256Hex as sha256Hex4
|
|
2786
3680
|
} from "@tileflow/core";
|
|
2787
3681
|
var tileflowVisualReviewSchemaVersion = 1;
|
|
2788
3682
|
var TileflowVisualReviewError = class extends Error {
|
|
@@ -2876,7 +3770,9 @@ function snapshotReviewInputs(leftInput, rightInput) {
|
|
|
2876
3770
|
}
|
|
2877
3771
|
function inspectReviewInput(input, role) {
|
|
2878
3772
|
const wrapper = readPlainDataRecord(input, `${role} review input`, ["capture", "definition"]);
|
|
2879
|
-
const capture = readPlainDataRecord(wrapper.capture, `${role} capture`, reviewCaptureKeys
|
|
3773
|
+
const capture = readPlainDataRecord(wrapper.capture, `${role} capture`, reviewCaptureKeys, [
|
|
3774
|
+
"definition"
|
|
3775
|
+
]);
|
|
2880
3776
|
const definition = clonePlainDataValue(
|
|
2881
3777
|
wrapper.definition,
|
|
2882
3778
|
`${role} definition`
|
|
@@ -2893,6 +3789,12 @@ function inspectReviewInput(input, role) {
|
|
|
2893
3789
|
const png = requireReviewPng(capture.png, role);
|
|
2894
3790
|
return {
|
|
2895
3791
|
capture: {
|
|
3792
|
+
...capture.definition === void 0 ? {} : {
|
|
3793
|
+
definition: clonePlainDataValue(
|
|
3794
|
+
capture.definition,
|
|
3795
|
+
`${role} capture.definition`
|
|
3796
|
+
)
|
|
3797
|
+
},
|
|
2896
3798
|
scene: capture.scene,
|
|
2897
3799
|
map: capture.map,
|
|
2898
3800
|
theme: capture.theme,
|
|
@@ -3047,7 +3949,7 @@ function clonePlainDataValue(value, path, state = {
|
|
|
3047
3949
|
state.ancestors.delete(value);
|
|
3048
3950
|
}
|
|
3049
3951
|
}
|
|
3050
|
-
function readPlainDataRecord(value, path, expectedKeys) {
|
|
3952
|
+
function readPlainDataRecord(value, path, expectedKeys, optionalKeys = []) {
|
|
3051
3953
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
3052
3954
|
throw invalidReview(`The ${path} must be a plain data object.`);
|
|
3053
3955
|
}
|
|
@@ -3071,7 +3973,7 @@ function readPlainDataRecord(value, path, expectedKeys) {
|
|
|
3071
3973
|
entries.push([key, descriptor.value]);
|
|
3072
3974
|
}
|
|
3073
3975
|
if (expectedKeys) {
|
|
3074
|
-
const actual = entries.map(([key]) => key).sort(compareCodeUnits5);
|
|
3976
|
+
const actual = entries.map(([key]) => key).filter((key) => !optionalKeys.includes(key)).sort(compareCodeUnits5);
|
|
3075
3977
|
const wanted = [...expectedKeys].sort(compareCodeUnits5);
|
|
3076
3978
|
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
3077
3979
|
throw invalidReview(`The ${path} contains missing or unsupported fields.`);
|
|
@@ -3090,6 +3992,9 @@ async function validateReviewCapture(input, role) {
|
|
|
3090
3992
|
if (!sameCanonicalValue(input.definition, definition)) {
|
|
3091
3993
|
throw invalidReview(`The ${role} review definition must be the exact normalized shape.`);
|
|
3092
3994
|
}
|
|
3995
|
+
if (input.capture.definition !== void 0 && !sameCanonicalValue(input.capture.definition, definition)) {
|
|
3996
|
+
throw invalidReview(`The ${role} retained capture definition does not match its review.`);
|
|
3997
|
+
}
|
|
3093
3998
|
let parsedReceipt;
|
|
3094
3999
|
try {
|
|
3095
4000
|
parsedReceipt = validateTileflowCaptureReceipt(input.capture.receipt);
|
|
@@ -3107,7 +4012,7 @@ async function validateReviewCapture(input, role) {
|
|
|
3107
4012
|
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
4013
|
throw invalidReview(`The ${role} capture metadata does not match its receipt.`);
|
|
3109
4014
|
}
|
|
3110
|
-
const definitionSha256 = await
|
|
4015
|
+
const definitionSha256 = await sha256Hex4(serializeCanonicalJson4(definition));
|
|
3111
4016
|
if (definitionSha256 !== receipt.scene.sha256) {
|
|
3112
4017
|
throw invalidReview(`The ${role} review definition does not match its capture receipt.`);
|
|
3113
4018
|
}
|
|
@@ -3193,7 +4098,7 @@ function reviewWarnings(status, left, right) {
|
|
|
3193
4098
|
return [...new Set(warnings)].sort(compareCodeUnits5);
|
|
3194
4099
|
}
|
|
3195
4100
|
function sameCanonicalValue(left, right) {
|
|
3196
|
-
return
|
|
4101
|
+
return serializeCanonicalJson4(left) === serializeCanonicalJson4(right);
|
|
3197
4102
|
}
|
|
3198
4103
|
function compareCodeUnits5(left, right) {
|
|
3199
4104
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
@@ -3209,26 +4114,33 @@ export {
|
|
|
3209
4114
|
analyzeTileflowCaptureReference,
|
|
3210
4115
|
captureErrorMessage,
|
|
3211
4116
|
captureTileflowScenes,
|
|
4117
|
+
captureTileflowShot,
|
|
3212
4118
|
compareTileflowCaptureToBaseline,
|
|
3213
4119
|
compareTileflowCapturesForReview,
|
|
3214
4120
|
createTileflowCaptureBrowserEnvironment,
|
|
3215
4121
|
createTileflowCaptureReceipt,
|
|
3216
4122
|
createTileflowCaptureRendererIdentity,
|
|
3217
4123
|
createTileflowCaptureSession,
|
|
4124
|
+
createTileflowShotReceipt,
|
|
3218
4125
|
createTileflowVisualComparisonDocument,
|
|
3219
4126
|
createTileflowVisualReferenceAnalysisDocument,
|
|
3220
4127
|
createTileflowVisualReviewDocument,
|
|
3221
4128
|
launchTileflowCaptureBrowser,
|
|
3222
4129
|
parseTileflowCaptureReceipt,
|
|
4130
|
+
parseTileflowShotReceipt,
|
|
3223
4131
|
resolveTileflowApplicationUrl,
|
|
3224
4132
|
resolveTileflowCaptureWorldTileJson,
|
|
3225
4133
|
serializeTileflowCaptureReceipt,
|
|
4134
|
+
serializeTileflowShotReceipt,
|
|
3226
4135
|
serializeTileflowVisualComparison,
|
|
3227
4136
|
setupTileflowCaptureBrowser,
|
|
3228
4137
|
tileflowCaptureReceiptLimits,
|
|
3229
4138
|
tileflowCaptureReceiptSchemaVersion,
|
|
3230
4139
|
tileflowCaptureResultSchemaVersion,
|
|
3231
4140
|
tileflowCaptureRuntime,
|
|
4141
|
+
tileflowShotFramesSha256,
|
|
4142
|
+
tileflowShotReceiptLimits,
|
|
4143
|
+
tileflowShotReceiptSchemaVersion,
|
|
3232
4144
|
tileflowSyntheticAssetOrigin,
|
|
3233
4145
|
tileflowVisualArtifactLimits,
|
|
3234
4146
|
tileflowVisualComparisonSchemaVersion,
|
|
@@ -3238,5 +4150,6 @@ export {
|
|
|
3238
4150
|
tileflowVisualReviewSchemaVersion,
|
|
3239
4151
|
tileflowWorldCurrentTileJsonUrl,
|
|
3240
4152
|
validateTileflowCaptureReceipt,
|
|
4153
|
+
validateTileflowShotReceipt,
|
|
3241
4154
|
validateTileflowVisualReferencePng
|
|
3242
4155
|
};
|