From 30ca51c615fce20f5266278cdf5f4c97fd691c88 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Jul 2026 01:08:02 -0700 Subject: [PATCH] feat(engine): warn when a live map viewport is detected at capture init --- .../frameCapture-liveMapWarning.test.ts | 24 +++++++++ packages/engine/src/services/frameCapture.ts | 49 +++++++++++++++++++ packages/engine/src/types.ts | 3 +- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/engine/src/services/frameCapture-liveMapWarning.test.ts diff --git a/packages/engine/src/services/frameCapture-liveMapWarning.test.ts b/packages/engine/src/services/frameCapture-liveMapWarning.test.ts new file mode 100644 index 000000000..1808cf7b9 --- /dev/null +++ b/packages/engine/src/services/frameCapture-liveMapWarning.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { buildLiveMapWarning } from "./frameCapture.js"; + +describe("buildLiveMapWarning", () => { + it("names every detected map library in the message and details", () => { + const warning = buildLiveMapWarning(["Leaflet", "MapLibre GL"]); + expect(warning.code).toBe("live_map_detected"); + expect(warning.message).toContain("Leaflet, MapLibre GL"); + expect(warning.details?.sources).toEqual(["Leaflet", "MapLibre GL"]); + }); + + it("points the author at the bake pipeline instead of just describing the failure", () => { + const warning = buildLiveMapWarning(["Leaflet"]); + expect(warning.message).toContain("bake-basemap.mjs"); + expect(warning.message).toContain("deterministic-render"); + }); + + it("copies the libraries array so later mutation cannot alter the recorded warning", () => { + const libraries = ["Leaflet"]; + const warning = buildLiveMapWarning(libraries); + libraries.push("Mapbox GL"); + expect(warning.details?.sources).toEqual(["Leaflet"]); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index c6948fcba..0c7346dc7 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -1716,6 +1716,53 @@ function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): v ]); } +/** + * Runtime-mounted map-viewport markers, keyed by the CSS selector each map + * library stamps on its live container. Selector presence means an actual map + * INSTANCE is rendering in the page — not merely that a map library script + * loaded — which keeps false positives low (a baked map video carries none of + * these). + */ +const LIVE_MAP_MARKERS: ReadonlyArray<{ selector: string; library: string }> = [ + { selector: ".leaflet-container", library: "Leaflet" }, + { selector: ".maplibregl-map", library: "MapLibre GL" }, + { selector: ".mapboxgl-map", library: "Mapbox GL" }, + { selector: ".gm-style", library: "Google Maps" }, + { selector: ".ol-viewport", library: "OpenLayers" }, +]; + +/** + * Build the `live_map_detected` warning for the detected map libraries. + * A live tile map violates the deterministic-render contract (tiles are + * render-time network fetches): none of the readiness polls cover late-added + * tile images or map canvases, so frames captured before tiles arrive ship a + * blank/partial map with no error — the render exits success. Wild signature: + * PRINFRA-300 (blank hook scene, zero diagnostics, "fixed" by re-render). + */ +export function buildLiveMapWarning(libraries: readonly string[]): CaptureWarning { + return { + code: "live_map_detected", + message: + `Live map viewport(s) detected in the composition (${libraries.join(", ")}). ` + + `Map tiles load over the network at render time, which the deterministic-render ` + + `contract forbids — frames captured before tiles arrive ship a blank or partial map ` + + `with no error. Bake the map to a video first (see the motion-graphics maps skill / ` + + `bake-basemap.mjs) and use the baked file as the imagery layer.`, + details: { sources: [...libraries] }, + }; +} + +/** Detect live map viewports in the page and record the warning. */ +async function recordLiveMapWarning(session: CaptureSession, page: Page): Promise { + const libraries = (await page.evaluate( + (markers: ReadonlyArray<{ selector: string; library: string }>) => + markers.filter((m) => document.querySelector(m.selector) !== null).map((m) => m.library), + LIVE_MAP_MARKERS, + )) as string[]; + if (libraries.length === 0) return; + recordCaptureWarnings(session, [buildLiveMapWarning(libraries)]); +} + // Force every successfully-loaded `` to be GPU-uploaded before the first // frame capture. `naturalWidth > 0` means the bitmap has been decoded into // CPU memory, but compositor-side GPU upload can still happen lazily on first @@ -2002,6 +2049,7 @@ export async function initializeSession(session: CaptureSession): Promise session, await collectMediaReadinessWarnings(page, skipVideoIds, pageReadyTimeout), ); + await recordLiveMapWarning(session, page); await recordSessionInitTelemetry(session, initStart); @@ -2164,6 +2212,7 @@ export async function initializeSession(session: CaptureSession): Promise session, await collectMediaReadinessWarnings(page, bfSkipVideoIds, pageReadyTimeout), ); + await recordLiveMapWarning(session, page); await recordSessionInitTelemetry(session, initStart); diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 96d858e32..483a7b4de 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -18,7 +18,8 @@ export type CaptureWarningCode = | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" - | "sub_timeline_script_failure"; + | "sub_timeline_script_failure" + | "live_map_detected"; /** Structured correctness warning produced while preparing a capture session. */ export interface CaptureWarning {