// bake-basemap.mjs — canonical basemap-lane helper for the `maps` skill. // // WHAT IT DOES (and why baking at all): drives MapLibre in headless Chrome to record ONLY the // real-imagery basemap as an MP4 (camera zoom→hold), and projects each requested country's border // to SCREEN coordinates at the held view. The HF composition then plays the MP4 on track 0 and // animates a *live* SVG overlay (border draw-on, colour-block / flag fills, labels, pins) from the // exported `coords.json`. Borders/fills are NOT baked into the video — they stay editable in HF. // // Why bake the imagery at all (this is the real reason, not "smoothness"): HF forbids render-time // network and requires deterministic output. Live raster tiles re-fetch every render and can change // → non-deterministic. Baking FREEZES the imagery into pixels = deterministic + offline-reproducible. // (Exposing the engine's per-frame `onBeforeCapture` hook would let MapLibre run live and smooth, but // it would NOT remove the need to freeze tiles for determinism — so this bake step stays relevant.) // // PARAMETRIC — drive everything by env. Example (Brazil + Argentina on satellite): // NAME=br-ar STYLE=satellite COUNTRIES="Brazil:#22d3ee,Argentina:#f59e0b" \ // CENTER="-60,-25" ZSTART=2.4 ZEND=3.4 FPS=30 DUR=5 node bake-basemap.mjs // Then encode frames-/f%04d.png → .mp4 (all-intra: -g 1) and feed -coords.json // to the HF composition. import puppeteer from "puppeteer-core"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { mkdirSync, writeFileSync, readdirSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; const __dirname = dirname(fileURLToPath(import.meta.url)); // --- resolve Chrome dynamically (no hardcoded machine path) --- function resolveChrome() { if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME; const exe = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; const base = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell"); if (existsSync(base)) { for (const v of readdirSync(base).sort().reverse()) { // lexical sort; any working binary is fine try { for (const inner of readdirSync(join(base, v))) { const bin = join(base, v, inner, exe); if (existsSync(bin)) return bin; } } catch { /* skip */ } } } for (const c of [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", ]) if (existsSync(c)) return c; throw new Error( "Chrome not found. Set CHROME=/path/to/chrome-headless-shell, or install one:\n" + " npx puppeteer browsers install chrome-headless-shell", ); } // --- params (all overridable by env) --- const NAME = process.env.NAME || "basemap"; const STYLE = process.env.STYLE || "satellite"; // satellite | dark | light | raw {z}/{x}/{y} template const CENTER = (process.env.CENTER || "2.6,46.6").split(",").map(Number); const ZSTART = +(process.env.ZSTART || 4.2); const ZEND = +(process.env.ZEND || 5.4); const PITCH = +(process.env.PITCH || 0); const BEARING = +(process.env.BEARING || 0); const FPS = +(process.env.FPS || 30), DUR = +(process.env.DUR || 5), N = Math.max(1, Math.round(FPS * DUR)); const HOLD = +(process.env.HOLD || 0.5); // p∈[0,1] at which the zoom finishes; camera holds after const MARGIN = (process.env.KEEPMARGIN || "16,13").split(",").map(Number); // [lon°,lat°] keep-box around each country's mainland // COUNTRIES="Name:#hex,Name:#hex" — borders to project (optional; omit for a pure zoom-to / pin shot) const COUNTRIES = (process.env.COUNTRIES || "") .split(",") .map((s) => s.trim()) .filter(Boolean) .map((s) => { const [name, color] = s.split(":"); return { name, color: color || "#38bdf8" }; }); // fail fast on bad numeric env — otherwise NaN silently bakes zero/garbage frames and still prints "done" for (const [k, v] of Object.entries({ "CENTER.lng": CENTER[0], "CENTER.lat": CENTER[1], ZSTART, ZEND, PITCH, BEARING, FPS, DUR, HOLD, })) if (!Number.isFinite(v)) throw new Error( `bad numeric env: ${k}=${v} — check CENTER="lng,lat" / ZSTART / ZEND / FPS / DUR`, ); // IMPORTANT: tileSize:256 matches Esri/CARTO raster endpoints. MapLibre's INTERNAL world width is // 512·2^zoom regardless — a 512px (@2x/retina/vector) tile source needs tileSize:512 or every zoom // level is off by one. Keep 256 for these raster sources. const TILES = { satellite: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", dark: "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", light: "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", }[STYLE] || STYLE; // STYLE may also be a raw {z}/{x}/{y} template const OUT = process.env.OUT || process.cwd(); // artifacts → workspace (cwd), NOT the installed skill dir const framesDir = join(OUT, "frames-" + NAME); mkdirSync(framesDir, { recursive: true }); // Deps PINNED exact (mutable @5/@2 majors would drift the bake over time). const PAGE = `
`; // --no-sandbox is intentional: trusted Source-time bake, headless, often root/CI; deps are version-pinned above. const browser = await puppeteer.launch({ executablePath: resolveChrome(), headless: true, args: [ "--no-sandbox", "--hide-scrollbars", "--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--enable-webgl", "--window-size=1920,1080", ], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 }); await page.setContent(PAGE, { waitUntil: "load" }); await page.evaluate(() => window.__ready); for (const w of await page.evaluate(() => window.__warn)) console.warn(`[${NAME}] WARN: ${w}`); console.log( `[${NAME}] ready (${STYLE}); baking ${N} frames, zoom ${ZSTART}→${ZEND} hold@p=${HOLD}, ${COUNTRIES.length} border(s)`, ); let coords = null; const timeouts = []; for (let i = 0; i < N; i++) { const p = N === 1 ? 1 : i / (N - 1); await page.evaluate((pp) => window.__setCam(pp), p); const timedOut = await page.evaluate((ms) => window.__waitIdle(ms), 9000); if (timedOut) { timeouts.push(i); console.warn(`[${NAME}] idle TIMEOUT at frame ${i} — tiles may be incomplete`); } await page.screenshot({ path: join(framesDir, `f${String(i).padStart(4, "0")}.png`), clip: { x: 0, y: 0, width: 1920, height: 1080 }, optimizeForSpeed: true, }); if (p >= HOLD && !coords && COUNTRIES.length) coords = await page.evaluate(() => window.__project()); // capture at first hold frame if (i % 20 === 0 || i === N - 1) console.log(` [${NAME}] ${i + 1}/${N}`); } // encode frames → all-intra MP4 (every frame seekable for HF); fall back to printing the command if ffmpeg is absent const mp4 = join(OUT, NAME + ".mp4"), pat = join(framesDir, "f%04d.png"); const ff = spawnSync( "ffmpeg", [ "-y", "-framerate", String(FPS), "-i", pat, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-g", "1", "-crf", "16", "-movflags", "+faststart", mp4, ], { stdio: "ignore" }, ); if (ff.status === 0) console.log(`[${NAME}] encoded → ${mp4}`); else console.warn( `[${NAME}] ffmpeg unavailable (status ${ff.status}) — encode manually:\n ffmpeg -y -framerate ${FPS} -i ${pat} -c:v libx264 -pix_fmt yuv420p -g 1 -crf 16 -movflags +faststart ${mp4}`, ); if (coords) { writeFileSync(join(OUT, NAME + "-coords.json"), JSON.stringify(coords)); console.log( `[${NAME}] coords written: ${coords.countries.map((c) => c.name + "(" + c.d.length + "ch)").join(", ")}`, ); } if (timeouts.length) { // FAIL LOUD: the asset exists but is suspect — don't let a half-loaded bake pass silently console.error( `[${NAME}] ${timeouts.length}/${N} frame(s) hit the idle timeout (frames ${timeouts.slice(0, 8).join(",")}${timeouts.length > 8 ? "…" : ""}). The basemap MP4 may have INCOMPLETE tiles. Re-run with a slower zoom / larger timeout / check the tile server.`, ); process.exitCode = 1; } else { console.log(`[${NAME}] done — all ${N} frames reached map idle (complete tiles).`); } } finally { await browser.close(); }