Skill Tracer
Skills › HyperFrames › hyperframes › bake-basemap.mjs

bake-basemap.mjs

324 lines · skills/hyperframes/scripts/bake-basemap.mjs at a595755

1// bake-basemap.mjs — bake a real-imagery map camera move for a composition (references/film.md, "Maps").
2//
3// WHAT IT DOES (and why baking at all): drives MapLibre in headless Chrome to record ONLY the
4// real-imagery basemap as an MP4 (camera zoom→hold), and projects each requested country's border
5// to SCREEN coordinates at the held view. The HF composition then plays the MP4 on track 0 and
6// animates a *live* SVG overlay (border draw-on, colour-block / flag fills, labels, pins) from the
7// exported `<NAME>-coords.json`. Borders/fills are NOT baked into the video — they stay editable in HF.
8//
9// Why bake the imagery at all (this is the real reason, not "smoothness"): HF forbids render-time
10// network and requires deterministic output. Live raster tiles re-fetch every render and can change
11// → non-deterministic. Baking FREEZES the imagery into pixels = deterministic + offline-reproducible.
12// (Exposing the engine's per-frame `onBeforeCapture` hook would let MapLibre run live and smooth, but
13// it would NOT remove the need to freeze tiles for determinism — so this bake step stays relevant.)
14//
15// PARAMETRIC — drive everything by env. Example (Brazil + Argentina on satellite):
16// NAME=br-ar STYLE=satellite COUNTRIES="Brazil:#22d3ee,Argentina:#f59e0b" \
17// CENTER="-60,-25" ZSTART=2.4 ZEND=3.4 FPS=30 DUR=5 node bake-basemap.mjs
18// From space down to a street (globe, starfield, a turn from FROM to CENTER, then the dive):
19// NAME=mbs PROJECTION=globe FROM="18,16" CENTER="103.8607,1.2834" ZSTART=1.85 ZEND=16.2 \
20// PITCH=40 DUR=10 HOLD=0.8 node bake-basemap.mjs
21// It encodes frames-<NAME>/f%04d.png → <NAME>.mp4 (all-intra: -g 1) and writes <NAME>-coords.json
22// for the HF composition. One bake at a time: several headless Chromes on a small machine stall.
23import { join } from "node:path";
24import { mkdirSync, writeFileSync, readdirSync, existsSync } from "node:fs";
25import { homedir } from "node:os";
26import { spawnSync } from "node:child_process";
28if (process.argv.includes("--help") || process.argv.includes("-h")) {
29 console.log(`usage: [NAME=..] [STYLE=satellite|dark|light|<{z}/{x}/{y} url>] [CENTER="lng,lat"]
30 [PROJECTION=mercator|globe] [FROM="lng,lat"] [PAN=0.35] [ZSTART=..] [ZEND=..] [PITCH=..]
31 [BEARING=..] [FPS=30] [DUR=5] [HOLD=0.5] [COUNTRIES="Name:#hex,Name:#hex"] [OUT=dir]
32 [CHROME=path] node bake-basemap.mjs
33PROJECTION=globe starts from Earth in space (starfield, atmosphere) and flattens as it zooms in.
34FROM pans the camera from that point to CENTER over the first PAN of the move, while the zoom
35runs from PAN/2 to HOLD; ZSTART about 1.8 frames the whole globe, ZEND 16 is a building.
36Writes <OUT>/<NAME>.mp4 (all-intra basemap) and <OUT>/<NAME>-coords.json (projected borders).
37Needs puppeteer-core, ffmpeg, a Chrome or chrome-headless-shell, and network for the tiles.
38Run one bake at a time; it prints its pace and an estimate as it goes.`);
39 process.exit(0);
42// Loaded after the --help check so usage prints without puppeteer-core installed.
43const { default: puppeteer } = await import("puppeteer-core");
45// --- resolve Chrome dynamically (no hardcoded machine path) ---
46function resolveChrome() {
47 if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
48 const exe = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
49 const base = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
50 if (existsSync(base)) {
51 for (const v of readdirSync(base).sort().reverse()) {
52 // lexical sort; any working binary is fine
53 try {
54 for (const inner of readdirSync(join(base, v))) {
55 const bin = join(base, v, inner, exe);
56 if (existsSync(bin)) return bin;
57 }
58 } catch {
59 /* skip */
60 }
61 }
62 }
63 for (const c of [
64 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
65 "/usr/bin/google-chrome",
66 "/usr/bin/chromium",
67 "/usr/bin/chromium-browser",
68 ])
69 if (existsSync(c)) return c;
70 throw new Error(
71 "Chrome not found. Set CHROME=/path/to/chrome-headless-shell, or install one:\n" +
72 " npx puppeteer browsers install chrome-headless-shell",
73 );
76// --- params (all overridable by env) ---
77const NAME = process.env.NAME || "basemap";
78const STYLE = process.env.STYLE || "satellite"; // satellite | dark | light | raw {z}/{x}/{y} template
79const CENTER = (process.env.CENTER || "2.6,46.6").split(",").map(Number);
80const ZSTART = +(process.env.ZSTART || 4.2);
81const ZEND = +(process.env.ZEND || 5.4);
82const PITCH = +(process.env.PITCH || 0);
83const BEARING = +(process.env.BEARING || 0);
84const FPS = +(process.env.FPS || 30),
85 DUR = +(process.env.DUR || 5),
86 N = Math.max(1, Math.round(FPS * DUR));
87const HOLD = +(process.env.HOLD || 0.5); // p∈[0,1] at which the zoom finishes; camera holds after
88const PROJECTION = process.env.PROJECTION === "globe" ? "globe" : "mercator";
89const FROM = process.env.FROM ? process.env.FROM.split(",").map(Number) : null; // pan start; null = no pan
90const PAN = +(process.env.PAN || 0.35); // p at which the pan from FROM reaches CENTER
91const MARGIN = (process.env.KEEPMARGIN || "16,13").split(",").map(Number); // [lon°,lat°] keep-box around each country's mainland
92// COUNTRIES="Name:#hex,Name:#hex" — borders to project (optional; omit for a pure zoom-to / pin shot)
93const COUNTRIES = (process.env.COUNTRIES || "")
94 .split(",")
95 .map((s) => s.trim())
96 .filter(Boolean)
97 .map((s) => {
98 const [name, color] = s.split(":");
99 return { name, color: color || "#38bdf8" };
100 });
102// fail fast on bad numeric env — otherwise NaN silently bakes zero/garbage frames and still prints "done"
103for (const [k, v] of Object.entries({
104 "CENTER.lng": CENTER[0],
105 "CENTER.lat": CENTER[1],
106 ZSTART,
107 ZEND,
108 PITCH,
109 BEARING,
110 FPS,
111 DUR,
112 HOLD,
113 PAN,
114 ...(FROM ? { "FROM.lng": FROM[0], "FROM.lat": FROM[1] } : {}),
115}))
116 if (!Number.isFinite(v))
117 throw new Error(
118 `bad numeric env: ${k}=${v} — check CENTER="lng,lat" / ZSTART / ZEND / FPS / DUR`,
119 );
121// IMPORTANT: tileSize:256 matches Esri/CARTO raster endpoints. MapLibre's INTERNAL world width is
122// 512·2^zoom regardless — a 512px (@2x/retina/vector) tile source needs tileSize:512 or every zoom
123// level is off by one. Keep 256 for these raster sources.
124const TILES =
125 {
126 satellite:
127 "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
128 dark: "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
129 light: "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
130 }[STYLE] || STYLE; // STYLE may also be a raw {z}/{x}/{y} template
132const OUT = process.env.OUT || process.cwd(); // artifacts → workspace (cwd), NOT the installed skill dir
133const framesDir = join(OUT, "frames-" + NAME);
134mkdirSync(framesDir, { recursive: true });
136// A seeded star field behind the globe, so every bake of the same move is identical.
137let seed = 7;
138const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
139const STARS = `<svg id="stars" width="1920" height="1080">${Array.from({ length: 260 }, () => {
140 const x = (rnd() * 1920).toFixed(0),
141 y = (rnd() * 1080).toFixed(0);
142 const r = (rnd() * 1.3 + 0.3).toFixed(2),
143 a = (rnd() * 0.6 + 0.2).toFixed(2);
144 return `<circle cx="${x}" cy="${y}" r="${r}" fill="#fff" opacity="${a}"/>`;
145}).join("")}</svg>`;
147// Deps PINNED exact (mutable @5/@2 majors would drift the bake over time).
148const PAGE = `<!doctype html><html><head>
149<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css" rel="stylesheet">
150<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
151<script src="https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/dist/topojson-client.min.js"></script>
152<style>*{margin:0}html,body{width:1920px;height:1080px;overflow:hidden;background:radial-gradient(ellipse at 50% 55%,#0b1630 0%,#050812 55%,#020308 100%)}#stars{position:absolute;inset:0}#map{position:absolute;inset:0;width:1920px;height:1080px}.maplibregl-control-container{display:none!important}</style>
153</head><body>${PROJECTION === "globe" ? STARS : ""}<div id="map"></div><script>
154var CENTER=${JSON.stringify(CENTER)}, ZSTART=${ZSTART}, ZEND=${ZEND}, PITCH=${PITCH}, BEARING=${BEARING}, HOLD=${HOLD};
155var FROM=${JSON.stringify(FROM)}, PAN=${PAN};
156var MARGIN=${JSON.stringify(MARGIN)}, WANT=${JSON.stringify(COUNTRIES)};
157var GLOBE=${PROJECTION === "globe"};
158// On the globe the page behind the map shows through (stars), and the atmosphere fades out as the camera nears the ground.
159var map=new maplibregl.Map({container:"map",style:{version:8,projection:{type:GLOBE?"globe":"mercator"},
160 sky:GLOBE?{"atmosphere-blend":["interpolate",["linear"],["zoom"],0,1,5,1,7,0]}:undefined,
161 sources:{s:{type:"raster",tiles:[${JSON.stringify(TILES)}],tileSize:256,maxzoom:19}},
162 layers:(GLOBE?[]:[{id:"bg",type:"background",paint:{"background-color":"#05070d"}}]).concat([{id:"s",type:"raster",source:"s"}])},
163 center:FROM||CENTER,zoom:ZSTART,pitch:0,bearing:0,interactive:false,attributionControl:false,fadeDuration:0,preserveDrawingBuffer:true,maxTileCacheSize:6000,
164 canvasContextAttributes:{alpha:GLOBE,preserveDrawingBuffer:true}});
165function ease(x){return x<0.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;} // easeInOutCubic (= Remotion interpolate+Easing)
166function sine(x){return -(Math.cos(Math.PI*x)-1)/2;}
167function seg(p,a,b){return b<=a?1:Math.min(1,Math.max(0,(p-a)/(b-a)));}
168// With FROM, the camera turns to CENTER over [0,PAN] while the zoom runs over [PAN/2,HOLD]; without it, the zoom runs over [0,HOLD].
169function camAt(p){
170 var s=FROM?sine(seg(p,0,PAN)):1, t=ease(seg(p,FROM?PAN/2:0,HOLD));
171 var c=FROM?[FROM[0]+(CENTER[0]-FROM[0])*s, FROM[1]+(CENTER[1]-FROM[1])*s]:CENTER;
172 return {center:c, zoom:ZSTART+(ZEND-ZSTART)*t, pitch:PITCH*t, bearing:BEARING*t}; }
173function ringCentroid(r){ var sx=0,sy=0; for(var k=0;k<r.length;k++){sx+=r[k][0];sy+=r[k][1];} return [sx/r.length, sy/r.length]; }
174// Keep the polygons in a lon/lat box around the country's MAINLAND (the vertex-richest polygon),
175// dropping far-flung overseas territories that would blow up the bbox. Generalizes per subject —
176// no continent-specific constant. Keeps near islands (Corsica, Sicily); drops Guiana, Alaska, Hawaii.
177function mainland(f){
178 if(!f) return f;
179 if(f.geometry.type!=="MultiPolygon"){ f.__anchorRing=f.geometry.coordinates[0]; return f; } // Polygon: outer ring
180 var polys=f.geometry.coordinates, anchor=polys[0], amax=-1;
181 polys.forEach(function(poly){ if(poly[0].length>amax){amax=poly[0].length;anchor=poly;} });
182 var ac=ringCentroid(anchor[0]);
183 var kept=polys.filter(function(poly){ var c=ringCentroid(poly[0]); return Math.abs(c[0]-ac[0])<=MARGIN[0] && Math.abs(c[1]-ac[1])<=MARGIN[1]; });
184 var nf={type:"Feature",properties:f.properties,geometry:{type:"MultiPolygon",coordinates:kept}}; nf.__anchorRing=anchor[0]; return nf;
186function lonSpan(f){ var mn=1e9,mx=-1e9; (f.geometry.type==="Polygon"?[f.geometry.coordinates]:f.geometry.coordinates).forEach(function(poly){poly[0].forEach(function(c){if(c[0]<mn)mn=c[0];if(c[0]>mx)mx=c[0];});}); return mx-mn; }
187// ANTIMERIDIAN unwrap (Russia/Fiji/NZ): make all lon contiguous around the camera-center ref so a
188// feature touching ±180° doesn't smear when map.project() runs per-vertex (mercatorX is linear and
189// accepts out-of-range lon, so 181 sits just east of center, not far-west at -179). Mutates in place;
190// __anchorRing shares the same arrays so it's covered.
191function unwrapLon(f, ref){ if(!f) return; function fix(r){ for(var i=0;i<r.length;i++){ var lon=r[i][0]; while(lon-ref>180)lon-=360; while(lon-ref<-180)lon+=360; r[i][0]=lon; } }
192 var g=f.geometry; if(g.type==="Polygon") g.coordinates.forEach(fix); else g.coordinates.forEach(function(poly){ poly.forEach(fix); }); }
193var FEATS=[]; window.__warn=[];
194window.__ready=new Promise(function(res){ map.on("load", function(){
195 if(!WANT.length){ res(); return; }
196 fetch("https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json").then(function(r){return r.json();}).then(function(w){
197 var fc=topojson.feature(w,w.objects.countries);
198 WANT.forEach(function(want){
199 var f=fc.features.filter(function(x){return x.properties.name===want.name;})[0];
200 if(!f){ window.__warn.push("country not found in world-atlas: "+want.name); return; }
201 f=mainland(f);
202 unwrapLon(f, CENTER[0]); // antimeridian: unwrap lons around the camera ref (CENTER must be near the subject) before projecting
203 if(lonSpan(f)>180) window.__warn.push(want.name+" spans >180° lon even after unwrap — projection may still smear.");
204 FEATS.push({name:want.name, color:want.color, f:f});
205 });
206 res();
207 });
208});});
209window.__setCam=function(p){ map.jumpTo(camAt(p)); };
210// Resolves false once every tile for this camera is loaded and painted, true if ms passed first.
211// Polls rather than waiting for "idle": that event can fire before a listener is attached, which
212// left every cached frame sitting out the whole timeout.
213window.__waitIdle=function(ms){ return new Promise(function(res){ var t0=performance.now();
214 (function poll(){ if(map.loaded()&&map.areTilesLoaded()){ map.triggerRepaint(); requestAnimationFrame(function(){requestAnimationFrame(function(){res(false);});}); return; }
215 if(performance.now()-t0>(ms||9000)){ res(!map.areTilesLoaded()); return; } setTimeout(poll,40); })(); }); };
216function featurePath(f){ function ring(r){ return r.map(function(c,i){ var p=map.project(c); return (i?"L":"M")+p.x.toFixed(1)+" "+p.y.toFixed(1); }).join(" ")+"Z"; }
217 var g=f.geometry,d=""; if(g.type==="Polygon") g.coordinates.forEach(function(r){d+=ring(r);}); else g.coordinates.forEach(function(poly){poly.forEach(function(r){d+=ring(r);});}); return d; }
218function bboxOf(f){ var mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;
219 function eat(r){ r.forEach(function(c){ var p=map.project(c); if(p.x<mnx)mnx=p.x; if(p.y<mny)mny=p.y; if(p.x>mxx)mxx=p.x; if(p.y>mxy)mxy=p.y; }); }
220 var g=f.geometry; if(g.type==="Polygon")g.coordinates.forEach(eat); else g.coordinates.forEach(function(poly){poly.forEach(eat);});
221 return {x:+mnx.toFixed(1),y:+mny.toFixed(1),w:+(mxx-mnx).toFixed(1),h:+(mxy-mny).toFixed(1)}; }
222window.__project=function(){ return {
223 view:{center:CENTER, zoom:ZEND, pitch:PITCH, bearing:BEARING},
224 countries: FEATS.map(function(e){ var lc=ringCentroid(e.f.__anchorRing); var lp=map.project(lc);
225 return { name:e.name, color:e.color, d:featurePath(e.f), bbox:bboxOf(e.f), label:{x:+lp.x.toFixed(1),y:+lp.y.toFixed(1)} }; }),
226}; };
227</script></body></html>`;
229// --no-sandbox is intentional: trusted Source-time bake, headless, often root/CI; deps are version-pinned above.
230const browser = await puppeteer.launch({
231 executablePath: resolveChrome(),
232 headless: true,
233 args: [
234 "--no-sandbox",
235 "--hide-scrollbars",
236 "--use-gl=angle",
237 "--use-angle=swiftshader",
238 "--enable-unsafe-swiftshader",
239 "--enable-webgl",
240 "--window-size=1920,1080",
241 ],
242});
243try {
244 const page = await browser.newPage();
245 await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
246 await page.setContent(PAGE, { waitUntil: "load" });
247 await page.evaluate(() => window.__ready);
248 for (const w of await page.evaluate(() => window.__warn)) console.warn(`[${NAME}] WARN: ${w}`);
249 console.log(
250 `[${NAME}] ready (${STYLE}, ${PROJECTION}); baking ${N} frames, zoom ${ZSTART}→${ZEND} hold@p=${HOLD}, ${COUNTRIES.length} border(s)`,
251 );
252 const t0 = Date.now();
253 let coords = null;
254 const timeouts = [];
255 for (let i = 0; i < N; i++) {
256 const p = N === 1 ? 1 : i / (N - 1);
257 await page.evaluate((pp) => window.__setCam(pp), p);
258 const timedOut = await page.evaluate((ms) => window.__waitIdle(ms), 9000);
259 if (timedOut) {
260 timeouts.push(i);
261 console.warn(`[${NAME}] idle TIMEOUT at frame ${i} — tiles may be incomplete`);
262 }
263 await page.screenshot({
264 path: join(framesDir, `f${String(i).padStart(4, "0")}.png`),
265 clip: { x: 0, y: 0, width: 1920, height: 1080 },
266 optimizeForSpeed: true,
267 });
268 if (p >= HOLD && !coords && COUNTRIES.length)
269 coords = await page.evaluate(() => window.__project()); // capture at first hold frame
270 if (i % 20 === 0 || i === N - 1) {
271 const per = (Date.now() - t0) / 1000 / (i + 1);
272 console.log(
273 ` [${NAME}] ${i + 1}/${N} · ${per.toFixed(2)} s/frame · about ${Math.ceil((per * (N - i - 1)) / 60)} min left`,
274 );
275 }
276 }
277 // encode frames → all-intra MP4 (every frame seekable for HF); fall back to printing the command if ffmpeg is absent
278 const mp4 = join(OUT, NAME + ".mp4"),
279 pat = join(framesDir, "f%04d.png");
280 const ff = spawnSync(
281 "ffmpeg",
282 [
283 "-y",
284 "-framerate",
285 String(FPS),
286 "-i",
287 pat,
288 "-c:v",
289 "libx264",
290 "-pix_fmt",
291 "yuv420p",
292 "-g",
293 "1",
294 "-crf",
295 "16",
296 "-movflags",
297 "+faststart",
298 mp4,
299 ],
300 { stdio: "ignore" },
301 );
302 if (ff.status === 0) console.log(`[${NAME}] encoded → ${mp4}`);
303 else
304 console.warn(
305 `[${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}`,
306 );
307 if (coords) {
308 writeFileSync(join(OUT, NAME + "-coords.json"), JSON.stringify(coords));
309 console.log(
310 `[${NAME}] coords written: ${coords.countries.map((c) => c.name + "(" + c.d.length + "ch)").join(", ")}`,
311 );
312 }
313 if (timeouts.length) {
314 // FAIL LOUD: the asset exists but is suspect — don't let a half-loaded bake pass silently
315 console.error(
316 `[${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.`,
317 );
318 process.exitCode = 1;
319 } else {
320 console.log(`[${NAME}] done — all ${N} frames reached map idle (complete tiles).`);
321 }
322} finally {
323 await browser.close();