Setting the file. One moment.
Bake Basemap · Motion Graphics · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page 28.10
Module · kinetic-type
categories/maps/ bake-basemap.mjs
JavaScript · 273 lines · 14 KB
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 // Then encode frames-<NAME>/f%04d.png → <NAME>.mp4 (all-intra: -g 1) and feed <NAME>-coords.json
19 // to the HF composition.
20 import puppeteer from "puppeteer-core" ;
21 import { fileURLToPath } from "node:url" ;
22 import { dirname, join } from "node:path" ;
23 import { mkdirSync, writeFileSync, readdirSync, existsSync } from "node:fs" ;
24 import { homedir } from "node:os" ;
25 import { spawnSync } from "node:child_process" ;
26
27 const __dirname = dirname ( fileURLToPath ( import . meta .url));
28
29 // --- resolve Chrome dynamically (no hardcoded machine path) ---
30 function resolveChrome () {
31 if (process.env. CHROME && existsSync (process.env. CHROME )) return process.env. CHROME ;
32 const exe = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell" ;
33 const base = join ( homedir (), ".cache" , "puppeteer" , "chrome-headless-shell" );
34 if ( existsSync (base)) {
35 for ( const v of readdirSync (base). sort (). reverse ()) {
36 // lexical sort; any working binary is fine
37 try {
38 for ( const inner of readdirSync ( join (base, v))) {
39 const bin = join (base, v, inner, exe);
40 if ( existsSync (bin)) return bin;
41 }
42 } catch {
43 /* skip */
44 }
45 }
46 }
47 for ( const c of [
48 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ,
49 "/usr/bin/google-chrome" ,
50 "/usr/bin/chromium" ,
51 "/usr/bin/chromium-browser" ,
52 ])
53 if ( existsSync (c)) return c;
54 throw new Error (
55 "Chrome not found. Set CHROME=/path/to/chrome-headless-shell, or install one: \n " +
56 " npx puppeteer browsers install chrome-headless-shell" ,
57 );
58 }
59
60 // --- params (all overridable by env) ---
61 const NAME = process.env. NAME || "basemap" ;
62 const STYLE = process.env. STYLE || "satellite" ; // satellite | dark | light | raw {z}/{x}/{y} template
63 const CENTER = (process.env. CENTER || "2.6,46.6" ). split ( "," ). map (Number);
64 const ZSTART = + (process.env. ZSTART || 4.2 );
65 const ZEND = + (process.env. ZEND || 5.4 );
66 const PITCH = + (process.env. PITCH || 0 );
67 const BEARING = + (process.env. BEARING || 0 );
68 const FPS = + (process.env. FPS || 30 ),
69 DUR = + (process.env. DUR || 5 ),
70 N = Math. max ( 1 , Math. round ( FPS * DUR ));
71 const HOLD = + (process.env. HOLD || 0.5 ); // p∈[0,1] at which the zoom finishes; camera holds after
72 const MARGIN = (process.env. KEEPMARGIN || "16,13" ). split ( "," ). map (Number); // [lon°,lat°] keep-box around each country's mainland
73 // COUNTRIES="Name:#hex,Name:#hex" — borders to project (optional; omit for a pure zoom-to / pin shot)
74 const COUNTRIES = (process.env. COUNTRIES || "" )
75 . split ( "," )
76 . map (( s ) => s. trim ())
77 . filter (Boolean)
78 . map (( s ) => {
79 const [ name , color ] = s. split ( ":" );
80 return { name, color: color || "#38bdf8" };
81 });
82
83 // fail fast on bad numeric env — otherwise NaN silently bakes zero/garbage frames and still prints "done"
84 for ( const [ k , v ] of Object. entries ({
85 "CENTER.lng" : CENTER [ 0 ],
86 "CENTER.lat" : CENTER [ 1 ],
87 ZSTART,
88 ZEND,
89 PITCH,
90 BEARING,
91 FPS,
92 DUR,
93 HOLD,
94 }))
95 if ( ! Number. isFinite (v))
96 throw new Error (
97 `bad numeric env: ${ k }=${ v } — check CENTER="lng,lat" / ZSTART / ZEND / FPS / DUR` ,
98 );
99
100 // IMPORTANT: tileSize:256 matches Esri/CARTO raster endpoints. MapLibre's INTERNAL world width is
101 // 512·2^zoom regardless — a 512px (@2x/retina/vector) tile source needs tileSize:512 or every zoom
102 // level is off by one. Keep 256 for these raster sources.
103 const TILES =
104 {
105 satellite:
106 "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" ,
107 dark: "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png" ,
108 light: "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png" ,
109 }[ STYLE ] || STYLE ; // STYLE may also be a raw {z}/{x}/{y} template
110
111 const OUT = process.env. OUT || process. cwd (); // artifacts → workspace (cwd), NOT the installed skill dir
112 const framesDir = join ( OUT , "frames-" + NAME );
113 mkdirSync (framesDir, { recursive: true });
114
115 // Deps PINNED exact (mutable @5/@2 majors would drift the bake over time).
116 const PAGE = `<!doctype html><html><head>
117 <link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css" rel="stylesheet">
118 <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
119 <script src="https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/dist/topojson-client.min.js"></script>
120 <style>*{margin:0}html,body{width:1920px;height:1080px;overflow:hidden;background:#05070d}#map{width:1920px;height:1080px}.maplibregl-control-container{display:none!important}</style>
121 </head><body><div id="map"></div><script>
122 var CENTER=${ JSON . stringify ( CENTER ) }, ZSTART=${ ZSTART }, ZEND=${ ZEND }, PITCH=${ PITCH }, BEARING=${ BEARING }, HOLD=${ HOLD };
123 var MARGIN=${ JSON . stringify ( MARGIN ) }, WANT=${ JSON . stringify ( COUNTRIES ) };
124 var map=new maplibregl.Map({container:"map",style:{version:8,projection:{type:"mercator"},
125 sources:{s:{type:"raster",tiles:[${ JSON . stringify ( TILES ) }],tileSize:256,maxzoom:19}},
126 layers:[{id:"bg",type:"background",paint:{"background-color":"#05070d"}},{id:"s",type:"raster",source:"s"}]},
127 center:CENTER,zoom:ZSTART,pitch:0,bearing:0,interactive:false,attributionControl:false,fadeDuration:0,preserveDrawingBuffer:true,maxTileCacheSize:6000});
128 function ease(x){return x<0.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;} // easeInOutCubic (= Remotion interpolate+Easing)
129 function camAt(p){ var t=ease(Math.min(1,p/HOLD)); return {center:CENTER, zoom:ZSTART+(ZEND-ZSTART)*t, pitch:PITCH*t, bearing:BEARING*t}; }
130 function 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]; }
131 // Keep the polygons in a lon/lat box around the country's MAINLAND (the vertex-richest polygon),
132 // dropping far-flung overseas territories that would blow up the bbox. Generalizes per subject —
133 // no continent-specific constant. Keeps near islands (Corsica, Sicily); drops Guiana, Alaska, Hawaii.
134 function mainland(f){
135 if(!f) return f;
136 if(f.geometry.type!=="MultiPolygon"){ f.__anchorRing=f.geometry.coordinates[0]; return f; } // Polygon: outer ring
137 var polys=f.geometry.coordinates, anchor=polys[0], amax=-1;
138 polys.forEach(function(poly){ if(poly[0].length>amax){amax=poly[0].length;anchor=poly;} });
139 var ac=ringCentroid(anchor[0]);
140 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]; });
141 var nf={type:"Feature",properties:f.properties,geometry:{type:"MultiPolygon",coordinates:kept}}; nf.__anchorRing=anchor[0]; return nf;
142 }
143 function 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; }
144 // ANTIMERIDIAN unwrap (Russia/Fiji/NZ): make all lon contiguous around the camera-center ref so a
145 // feature touching ±180° doesn't smear when map.project() runs per-vertex (mercatorX is linear and
146 // accepts out-of-range lon, so 181 sits just east of center, not far-west at -179). Mutates in place;
147 // __anchorRing shares the same arrays so it's covered.
148 function 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; } }
149 var g=f.geometry; if(g.type==="Polygon") g.coordinates.forEach(fix); else g.coordinates.forEach(function(poly){ poly.forEach(fix); }); }
150 var FEATS=[]; window.__warn=[];
151 window.__ready=new Promise(function(res){ map.on("load", function(){
152 if(!WANT.length){ res(); return; }
153 fetch("https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json").then(function(r){return r.json();}).then(function(w){
154 var fc=topojson.feature(w,w.objects.countries);
155 WANT.forEach(function(want){
156 var f=fc.features.filter(function(x){return x.properties.name===want.name;})[0];
157 if(!f){ window.__warn.push("country not found in world-atlas: "+want.name); return; }
158 f=mainland(f);
159 unwrapLon(f, CENTER[0]); // antimeridian: unwrap lons around the camera ref (CENTER must be near the subject) before projecting
160 if(lonSpan(f)>180) window.__warn.push(want.name+" spans >180° lon even after unwrap — projection may still smear.");
161 FEATS.push({name:want.name, color:want.color, f:f});
162 });
163 res();
164 });
165 });});
166 window.__setCam=function(p){ map.jumpTo(camAt(p)); };
167 // returns true if the idle event did NOT fire within ms (i.e. tiles may be incomplete)
168 // returns true only if tiles are GENUINELY not loaded at timeout — CARTO/Esri idle is flaky and
169 // often never fires even when every tile is painted, so check areTilesLoaded() before crying timeout.
170 window.__waitIdle=function(ms){ return new Promise(function(res){ var done=false; function fin(t){if(done)return;done=true;res(t);} map.once("idle",function(){fin(false);}); setTimeout(function(){ fin(!map.areTilesLoaded()); }, ms||9000); }); };
171 function 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"; }
172 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; }
173 function bboxOf(f){ var mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;
174 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; }); }
175 var g=f.geometry; if(g.type==="Polygon")g.coordinates.forEach(eat); else g.coordinates.forEach(function(poly){poly.forEach(eat);});
176 return {x:+mnx.toFixed(1),y:+mny.toFixed(1),w:+(mxx-mnx).toFixed(1),h:+(mxy-mny).toFixed(1)}; }
177 window.__project=function(){ return {
178 view:{center:CENTER, zoom:ZEND, pitch:PITCH, bearing:BEARING},
179 countries: FEATS.map(function(e){ var lc=ringCentroid(e.f.__anchorRing); var lp=map.project(lc);
180 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)} }; }),
181 }; };
182 </script></body></html>` ;
183
184 // --no-sandbox is intentional: trusted Source-time bake, headless, often root/CI; deps are version-pinned above.
185 const browser = await puppeteer. launch ({
186 executablePath: resolveChrome (),
187 headless: true ,
188 args: [
189 "--no-sandbox" ,
190 "--hide-scrollbars" ,
191 "--use-gl=angle" ,
192 "--use-angle=swiftshader" ,
193 "--enable-unsafe-swiftshader" ,
194 "--enable-webgl" ,
195 "--window-size=1920,1080" ,
196 ],
197 });
198 try {
199 const page = await browser. newPage ();
200 await page. setViewport ({ width: 1920 , height: 1080 , deviceScaleFactor: 1 });
201 await page. setContent ( PAGE , { waitUntil: "load" });
202 await page. evaluate (() => window.__ready);
203 for ( const w of await page. evaluate (() => window.__warn)) console. warn ( `[${ NAME }] WARN: ${ w }` );
204 console. log (
205 `[${ NAME }] ready (${ STYLE }); baking ${ N } frames, zoom ${ ZSTART }→${ ZEND } hold@p=${ HOLD }, ${ COUNTRIES . length } border(s)` ,
206 );
207 let coords = null ;
208 const timeouts = [];
209 for ( let i = 0 ; i < N ; i ++ ) {
210 const p = N === 1 ? 1 : i / ( N - 1 );
211 await page. evaluate (( pp ) => window. __setCam (pp), p);
212 const timedOut = await page. evaluate (( ms ) => window. __waitIdle (ms), 9000 );
213 if (timedOut) {
214 timeouts. push (i);
215 console. warn ( `[${ NAME }] idle TIMEOUT at frame ${ i } — tiles may be incomplete` );
216 }
217 await page. screenshot ({
218 path: join (framesDir, `f${ String ( i ). padStart ( 4 , "0" ) }.png` ),
219 clip: { x: 0 , y: 0 , width: 1920 , height: 1080 },
220 optimizeForSpeed: true ,
221 });
222 if (p >= HOLD && ! coords && COUNTRIES . length )
223 coords = await page. evaluate (() => window. __project ()); // capture at first hold frame
224 if (i % 20 === 0 || i === N - 1 ) console. log ( ` [${ NAME }] ${ i + 1 }/${ N }` );
225 }
226 // encode frames → all-intra MP4 (every frame seekable for HF); fall back to printing the command if ffmpeg is absent
227 const mp4 = join ( OUT , NAME + ".mp4" ),
228 pat = join (framesDir, "f%04d.png" );
229 const ff = spawnSync (
230 "ffmpeg" ,
231 [
232 "-y" ,
233 "-framerate" ,
234 String ( FPS ),
235 "-i" ,
236 pat,
237 "-c:v" ,
238 "libx264" ,
239 "-pix_fmt" ,
240 "yuv420p" ,
241 "-g" ,
242 "1" ,
243 "-crf" ,
244 "16" ,
245 "-movflags" ,
246 "+faststart" ,
247 mp4,
248 ],
249 { stdio: "ignore" },
250 );
251 if (ff.status === 0 ) console. log ( `[${ NAME }] encoded → ${ mp4 }` );
252 else
253 console. warn (
254 `[${ 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 }` ,
255 );
256 if (coords) {
257 writeFileSync ( join ( OUT , NAME + "-coords.json" ), JSON . stringify (coords));
258 console. log (
259 `[${ NAME }] coords written: ${ coords . countries . map (( c ) => c . name + "(" + c . d . length + "ch)" ). join ( ", " ) }` ,
260 );
261 }
262 if (timeouts. length ) {
263 // FAIL LOUD: the asset exists but is suspect — don't let a half-loaded bake pass silently
264 console. error (
265 `[${ 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.` ,
266 );
267 process.exitCode = 1 ;
268 } else {
269 console. log ( `[${ NAME }] done — all ${ N } frames reached map idle (complete tiles).` );
270 }
271 } finally {
272 await browser. close ();
273 }