Add HyperFrames skills (from heygen-com/hyperframes, Apache-2.0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXVsD1DgdzZLiPuwtKzMSY
This commit is contained in:
300 files changed
+49175
No files matched your search
@@ -0,0 +1,324 @@
|
||||
// bake-basemap.mjs — bake a real-imagery map camera move for a composition (references/film.md, "Maps").
|
||||
//
|
||||
// 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 `<NAME>-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
|
||||
// From space down to a street (globe, starfield, a turn from FROM to CENTER, then the dive):
|
||||
// NAME=mbs PROJECTION=globe FROM="18,16" CENTER="103.8607,1.2834" ZSTART=1.85 ZEND=16.2 \
|
||||
// PITCH=40 DUR=10 HOLD=0.8 node bake-basemap.mjs
|
||||
// It encodes frames-<NAME>/f%04d.png → <NAME>.mp4 (all-intra: -g 1) and writes <NAME>-coords.json
|
||||
// for the HF composition. One bake at a time: several headless Chromes on a small machine stall.
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
console.log(`usage: [NAME=..] [STYLE=satellite|dark|light|<{z}/{x}/{y} url>] [CENTER="lng,lat"]
|
||||
[PROJECTION=mercator|globe] [FROM="lng,lat"] [PAN=0.35] [ZSTART=..] [ZEND=..] [PITCH=..]
|
||||
[BEARING=..] [FPS=30] [DUR=5] [HOLD=0.5] [COUNTRIES="Name:#hex,Name:#hex"] [OUT=dir]
|
||||
[CHROME=path] node bake-basemap.mjs
|
||||
PROJECTION=globe starts from Earth in space (starfield, atmosphere) and flattens as it zooms in.
|
||||
FROM pans the camera from that point to CENTER over the first PAN of the move, while the zoom
|
||||
runs from PAN/2 to HOLD; ZSTART about 1.8 frames the whole globe, ZEND 16 is a building.
|
||||
Writes <OUT>/<NAME>.mp4 (all-intra basemap) and <OUT>/<NAME>-coords.json (projected borders).
|
||||
Needs puppeteer-core, ffmpeg, a Chrome or chrome-headless-shell, and network for the tiles.
|
||||
Run one bake at a time; it prints its pace and an estimate as it goes.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Loaded after the --help check so usage prints without puppeteer-core installed.
|
||||
const { default: puppeteer } = await import("puppeteer-core");
|
||||
|
||||
// --- 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 PROJECTION = process.env.PROJECTION === "globe" ? "globe" : "mercator";
|
||||
const FROM = process.env.FROM ? process.env.FROM.split(",").map(Number) : null; // pan start; null = no pan
|
||||
const PAN = +(process.env.PAN || 0.35); // p at which the pan from FROM reaches CENTER
|
||||
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,
|
||||
PAN,
|
||||
...(FROM ? { "FROM.lng": FROM[0], "FROM.lat": FROM[1] } : {}),
|
||||
}))
|
||||
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 });
|
||||
|
||||
// A seeded star field behind the globe, so every bake of the same move is identical.
|
||||
let seed = 7;
|
||||
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
||||
const STARS = `<svg id="stars" width="1920" height="1080">${Array.from({ length: 260 }, () => {
|
||||
const x = (rnd() * 1920).toFixed(0),
|
||||
y = (rnd() * 1080).toFixed(0);
|
||||
const r = (rnd() * 1.3 + 0.3).toFixed(2),
|
||||
a = (rnd() * 0.6 + 0.2).toFixed(2);
|
||||
return `<circle cx="${x}" cy="${y}" r="${r}" fill="#fff" opacity="${a}"/>`;
|
||||
}).join("")}</svg>`;
|
||||
|
||||
// Deps PINNED exact (mutable @5/@2 majors would drift the bake over time).
|
||||
const PAGE = `<!doctype html><html><head>
|
||||
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/dist/topojson-client.min.js"></script>
|
||||
<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>
|
||||
</head><body>${PROJECTION === "globe" ? STARS : ""}<div id="map"></div><script>
|
||||
var CENTER=${JSON.stringify(CENTER)}, ZSTART=${ZSTART}, ZEND=${ZEND}, PITCH=${PITCH}, BEARING=${BEARING}, HOLD=${HOLD};
|
||||
var FROM=${JSON.stringify(FROM)}, PAN=${PAN};
|
||||
var MARGIN=${JSON.stringify(MARGIN)}, WANT=${JSON.stringify(COUNTRIES)};
|
||||
var GLOBE=${PROJECTION === "globe"};
|
||||
// On the globe the page behind the map shows through (stars), and the atmosphere fades out as the camera nears the ground.
|
||||
var map=new maplibregl.Map({container:"map",style:{version:8,projection:{type:GLOBE?"globe":"mercator"},
|
||||
sky:GLOBE?{"atmosphere-blend":["interpolate",["linear"],["zoom"],0,1,5,1,7,0]}:undefined,
|
||||
sources:{s:{type:"raster",tiles:[${JSON.stringify(TILES)}],tileSize:256,maxzoom:19}},
|
||||
layers:(GLOBE?[]:[{id:"bg",type:"background",paint:{"background-color":"#05070d"}}]).concat([{id:"s",type:"raster",source:"s"}])},
|
||||
center:FROM||CENTER,zoom:ZSTART,pitch:0,bearing:0,interactive:false,attributionControl:false,fadeDuration:0,preserveDrawingBuffer:true,maxTileCacheSize:6000,
|
||||
canvasContextAttributes:{alpha:GLOBE,preserveDrawingBuffer:true}});
|
||||
function ease(x){return x<0.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;} // easeInOutCubic (= Remotion interpolate+Easing)
|
||||
function sine(x){return -(Math.cos(Math.PI*x)-1)/2;}
|
||||
function seg(p,a,b){return b<=a?1:Math.min(1,Math.max(0,(p-a)/(b-a)));}
|
||||
// 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].
|
||||
function camAt(p){
|
||||
var s=FROM?sine(seg(p,0,PAN)):1, t=ease(seg(p,FROM?PAN/2:0,HOLD));
|
||||
var c=FROM?[FROM[0]+(CENTER[0]-FROM[0])*s, FROM[1]+(CENTER[1]-FROM[1])*s]:CENTER;
|
||||
return {center:c, zoom:ZSTART+(ZEND-ZSTART)*t, pitch:PITCH*t, bearing:BEARING*t}; }
|
||||
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]; }
|
||||
// Keep the polygons in a lon/lat box around the country's MAINLAND (the vertex-richest polygon),
|
||||
// dropping far-flung overseas territories that would blow up the bbox. Generalizes per subject —
|
||||
// no continent-specific constant. Keeps near islands (Corsica, Sicily); drops Guiana, Alaska, Hawaii.
|
||||
function mainland(f){
|
||||
if(!f) return f;
|
||||
if(f.geometry.type!=="MultiPolygon"){ f.__anchorRing=f.geometry.coordinates[0]; return f; } // Polygon: outer ring
|
||||
var polys=f.geometry.coordinates, anchor=polys[0], amax=-1;
|
||||
polys.forEach(function(poly){ if(poly[0].length>amax){amax=poly[0].length;anchor=poly;} });
|
||||
var ac=ringCentroid(anchor[0]);
|
||||
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]; });
|
||||
var nf={type:"Feature",properties:f.properties,geometry:{type:"MultiPolygon",coordinates:kept}}; nf.__anchorRing=anchor[0]; return nf;
|
||||
}
|
||||
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; }
|
||||
// ANTIMERIDIAN unwrap (Russia/Fiji/NZ): make all lon contiguous around the camera-center ref so a
|
||||
// feature touching ±180° doesn't smear when map.project() runs per-vertex (mercatorX is linear and
|
||||
// accepts out-of-range lon, so 181 sits just east of center, not far-west at -179). Mutates in place;
|
||||
// __anchorRing shares the same arrays so it's covered.
|
||||
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; } }
|
||||
var g=f.geometry; if(g.type==="Polygon") g.coordinates.forEach(fix); else g.coordinates.forEach(function(poly){ poly.forEach(fix); }); }
|
||||
var FEATS=[]; window.__warn=[];
|
||||
window.__ready=new Promise(function(res){ map.on("load", function(){
|
||||
if(!WANT.length){ res(); return; }
|
||||
fetch("https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json").then(function(r){return r.json();}).then(function(w){
|
||||
var fc=topojson.feature(w,w.objects.countries);
|
||||
WANT.forEach(function(want){
|
||||
var f=fc.features.filter(function(x){return x.properties.name===want.name;})[0];
|
||||
if(!f){ window.__warn.push("country not found in world-atlas: "+want.name); return; }
|
||||
f=mainland(f);
|
||||
unwrapLon(f, CENTER[0]); // antimeridian: unwrap lons around the camera ref (CENTER must be near the subject) before projecting
|
||||
if(lonSpan(f)>180) window.__warn.push(want.name+" spans >180° lon even after unwrap — projection may still smear.");
|
||||
FEATS.push({name:want.name, color:want.color, f:f});
|
||||
});
|
||||
res();
|
||||
});
|
||||
});});
|
||||
window.__setCam=function(p){ map.jumpTo(camAt(p)); };
|
||||
// Resolves false once every tile for this camera is loaded and painted, true if ms passed first.
|
||||
// Polls rather than waiting for "idle": that event can fire before a listener is attached, which
|
||||
// left every cached frame sitting out the whole timeout.
|
||||
window.__waitIdle=function(ms){ return new Promise(function(res){ var t0=performance.now();
|
||||
(function poll(){ if(map.loaded()&&map.areTilesLoaded()){ map.triggerRepaint(); requestAnimationFrame(function(){requestAnimationFrame(function(){res(false);});}); return; }
|
||||
if(performance.now()-t0>(ms||9000)){ res(!map.areTilesLoaded()); return; } setTimeout(poll,40); })(); }); };
|
||||
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"; }
|
||||
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; }
|
||||
function bboxOf(f){ var mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;
|
||||
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; }); }
|
||||
var g=f.geometry; if(g.type==="Polygon")g.coordinates.forEach(eat); else g.coordinates.forEach(function(poly){poly.forEach(eat);});
|
||||
return {x:+mnx.toFixed(1),y:+mny.toFixed(1),w:+(mxx-mnx).toFixed(1),h:+(mxy-mny).toFixed(1)}; }
|
||||
window.__project=function(){ return {
|
||||
view:{center:CENTER, zoom:ZEND, pitch:PITCH, bearing:BEARING},
|
||||
countries: FEATS.map(function(e){ var lc=ringCentroid(e.f.__anchorRing); var lp=map.project(lc);
|
||||
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)} }; }),
|
||||
}; };
|
||||
</script></body></html>`;
|
||||
|
||||
// --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}, ${PROJECTION}); baking ${N} frames, zoom ${ZSTART}→${ZEND} hold@p=${HOLD}, ${COUNTRIES.length} border(s)`,
|
||||
);
|
||||
const t0 = Date.now();
|
||||
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) {
|
||||
const per = (Date.now() - t0) / 1000 / (i + 1);
|
||||
console.log(
|
||||
` [${NAME}] ${i + 1}/${N} · ${per.toFixed(2)} s/frame · about ${Math.ceil((per * (N - i - 1)) / 60)} min left`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// Node resolves symlinks in the main module's import.meta.url but argv[1] keeps the invoked
|
||||
// spelling (a symlinked skills dir, or /tmp vs /private/tmp on macOS), so compare real paths.
|
||||
export function isMainModule(importMetaUrl) {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return pathToFileURL(realpathSync(process.argv[1])).href === importMetaUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SKILLS = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const CLI_MEDIA_USE = resolve(SKILLS, "../packages/cli/src/media-use");
|
||||
|
||||
// Agents install skills by symlinking the skills dir, so each script must still run its CLI.
|
||||
test("skill CLIs run when invoked through a symlinked skills dir", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "skills-link-"));
|
||||
try {
|
||||
symlinkSync(SKILLS, join(dir, "skills"), "dir");
|
||||
for (const script of ["media-use/audio/scripts/heygen-voice.mjs"]) {
|
||||
const run = spawnSync(process.execPath, [join(dir, "skills", script), "not-a-command"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.notEqual(run.status, 0, `${script} exited ${run.status} without running its CLI`);
|
||||
assert.match(run.stderr, /usage/, script);
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function skillScripts(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) return entry.name === "node_modules" ? [] : skillScripts(path);
|
||||
return /\.(mjs|js)$/.test(entry.name) && !/\.test\.mjs$/.test(entry.name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
test("skill scripts detect their CLI entry through lib/main-module.mjs only", () => {
|
||||
const handRolled = [...skillScripts(SKILLS), ...skillScripts(CLI_MEDIA_USE)].filter(
|
||||
(path) =>
|
||||
!path.endsWith("main-module.mjs") && readFileSync(path, "utf8").includes("process.argv[1]"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
handRolled.map((path) => relative(SKILLS, path)),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
/* locate.mjs — find "X" in an image WITHOUT any detector API or key.
|
||||
*
|
||||
* You (the author model) are weak at regressing pixel coordinates but strong at
|
||||
* "which numbered strip is the target in?" — so localization = RSVP grid-index
|
||||
* (ACL 2025 long.715): pick strips on a numbered grid, reconstruct the box
|
||||
* geometrically. Zero npm deps — node + ffmpeg (both already required by
|
||||
* hyperframes). Accuracy notes: references/film.md.
|
||||
*
|
||||
* THE LOOP (you read images between steps):
|
||||
* 1) node locate.mjs overlay <img> --out DIR
|
||||
* → DIR/gv.png (vertical strips 1..9) + DIR/gh.png (horizontal 1..9).
|
||||
* READ both, pick the strip numbers the target spans.
|
||||
* 2) node locate.mjs region <img> --vids 4,5 --hids 6,7 --out DIR
|
||||
* → prints coarse region + writes DIR/gc.png (region cropped+upscaled,
|
||||
* finer 6×6 grid). READ it, pick the finer strips.
|
||||
* 3) node locate.mjs final <img> --region x0,y0,x1,y1 --vids 3,4 --hids 3,4
|
||||
* → {box, center} normalized to the FULL image.
|
||||
* 4) node locate.mjs mark <img> --box x0,y0,x1,y1 --out DIR/check.png
|
||||
* → VERIFY: read check.png — is the red box on the target? If off,
|
||||
* redo step 2/3 with corrected strips. Never skip this.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
|
||||
|
||||
const N1 = 9,
|
||||
PAD1 = 0.4; // stage 1: strips per axis, padding in STRIP units
|
||||
const N2 = 6,
|
||||
PAD2 = 0.25; // stage 2 (crop)
|
||||
const GREEN = "0x3CDC5A",
|
||||
BLUE = "0x3C82FF",
|
||||
RED = "0xFF3232";
|
||||
|
||||
const ff = (args) =>
|
||||
execFileSync("ffmpeg", ["-v", "error", "-y", ...args], {
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
});
|
||||
function probe(img) {
|
||||
const out = execFileSync("ffprobe", [
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"--",
|
||||
img,
|
||||
])
|
||||
.toString()
|
||||
.trim();
|
||||
const [w, h] = out.split(",").map(Number);
|
||||
return { w, h };
|
||||
}
|
||||
function font() {
|
||||
const dst = "/tmp/locate-font.ttf";
|
||||
if (!existsSync(dst)) {
|
||||
for (const c of [
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
]) {
|
||||
if (existsSync(c)) {
|
||||
copyFileSync(c, dst);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
function gridFilters(w, h, n, axis, fz) {
|
||||
const F = font(),
|
||||
fs = [];
|
||||
if (axis === "v" || axis === "both") {
|
||||
for (let i = 1; i < n; i++)
|
||||
fs.push(`drawbox=x=${Math.round((i * w) / n)}:y=0:w=2:h=${h}:color=${GREEN}@0.8:t=fill`);
|
||||
for (let i = 0; i < n; i++)
|
||||
fs.push(
|
||||
`drawtext=fontfile=${F}:text='${i + 1}':x=${Math.round(((i + 0.5) * w) / n - fz * 0.3)}:y=6:fontsize=${fz}:fontcolor=black:box=1:boxcolor=white@0.95:boxborderw=5`,
|
||||
);
|
||||
}
|
||||
if (axis === "h" || axis === "both") {
|
||||
for (let j = 1; j < n; j++)
|
||||
fs.push(`drawbox=x=0:y=${Math.round((j * h) / n)}:w=${w}:h=2:color=${BLUE}@0.8:t=fill`);
|
||||
for (let j = 0; j < n; j++)
|
||||
fs.push(
|
||||
`drawtext=fontfile=${F}:text='${j + 1}':x=6:y=${Math.round(((j + 0.5) * h) / n - fz * 0.5)}:fontsize=${fz}:fontcolor=black:box=1:boxcolor=white@0.95:boxborderw=5`,
|
||||
);
|
||||
}
|
||||
return fs;
|
||||
}
|
||||
const ids = (s) => String(s).split(",").filter(Boolean).map(Number);
|
||||
const regionOf = (v, h, n, pad) => [
|
||||
Math.max(Math.min(...v) - 1 - pad, 0) / n,
|
||||
Math.max(Math.min(...h) - 1 - pad, 0) / n,
|
||||
Math.min(Math.max(...v) + pad, n) / n,
|
||||
Math.min(Math.max(...h) + pad, n) / n,
|
||||
];
|
||||
const r4 = (x) => Math.round(x * 1e4) / 1e4;
|
||||
const out = (o) => console.log(JSON.stringify(o));
|
||||
|
||||
// --- tiny arg parsing: locate.mjs <cmd> <img?> --k v ---
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const pos = rest.filter((a) => !a.startsWith("--"));
|
||||
const opt = {};
|
||||
for (let i = 0; i < rest.length; i++)
|
||||
if (rest[i].startsWith("--")) opt[rest[i].slice(2)] = rest[i + 1];
|
||||
const img = pos[0];
|
||||
|
||||
if (cmd === "overlay") {
|
||||
const dir = opt.out || ".";
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const n = Number(opt.n || N1);
|
||||
const { w, h } = probe(img);
|
||||
const fz = Math.max(16, Math.round(Math.min(w, h) / 22));
|
||||
ff(["-i", img, "-vf", gridFilters(w, h, n, "v", fz).join(","), `${dir}/gv.png`]);
|
||||
ff(["-i", img, "-vf", gridFilters(w, h, n, "h", fz).join(","), `${dir}/gh.png`]);
|
||||
out({
|
||||
w,
|
||||
h,
|
||||
n,
|
||||
vertical: `${dir}/gv.png`,
|
||||
horizontal: `${dir}/gh.png`,
|
||||
next: "READ gv.png + gh.png, then: locate.mjs region <img> --vids .. --hids .. --out " + dir,
|
||||
});
|
||||
} else if (cmd === "region") {
|
||||
const dir = opt.out || ".";
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const n = Number(opt.n || N1),
|
||||
nf = Number(opt.nf || N2);
|
||||
const reg = regionOf(ids(opt.vids), ids(opt.hids), n, PAD1);
|
||||
const { w, h } = probe(img);
|
||||
const cx = Math.floor(reg[0] * w),
|
||||
cy = Math.floor(reg[1] * h);
|
||||
const cw = Math.max(2, Math.floor((reg[2] - reg[0]) * w)),
|
||||
ch = Math.max(2, Math.floor((reg[3] - reg[1]) * h));
|
||||
const uw = Math.max(cw, 640),
|
||||
uh = Math.round((ch * uw) / cw);
|
||||
const fz = Math.max(15, Math.round(Math.min(uw, uh) / 16));
|
||||
ff([
|
||||
"-i",
|
||||
img,
|
||||
"-vf",
|
||||
[
|
||||
`crop=${cw}:${ch}:${cx}:${cy}`,
|
||||
`scale=${uw}:${uh}`,
|
||||
...gridFilters(uw, uh, nf, "both", fz),
|
||||
].join(","),
|
||||
`${dir}/gc.png`,
|
||||
]);
|
||||
out({
|
||||
region: reg.map(r4),
|
||||
crop_grid: `${dir}/gc.png`,
|
||||
nf,
|
||||
next: `READ gc.png, then: locate.mjs final <img> --region ${reg.map(r4).join(",")} --vids .. --hids ..`,
|
||||
});
|
||||
} else if (cmd === "final") {
|
||||
const nf = Number(opt.nf || N2);
|
||||
const reg = opt.region.split(",").map(Number);
|
||||
const rl = regionOf(ids(opt.vids), ids(opt.hids), nf, PAD2);
|
||||
const [x0, y0, x1, y1] = reg;
|
||||
const box = [
|
||||
x0 + rl[0] * (x1 - x0),
|
||||
y0 + rl[1] * (y1 - y0),
|
||||
x0 + rl[2] * (x1 - x0),
|
||||
y0 + rl[3] * (y1 - y0),
|
||||
].map(r4);
|
||||
out({
|
||||
box,
|
||||
center: [r4((box[0] + box[2]) / 2), r4((box[1] + box[3]) / 2)],
|
||||
next:
|
||||
"VERIFY: locate.mjs mark <img> --box " +
|
||||
box.join(",") +
|
||||
" --out <dir>/check.png — then READ it",
|
||||
});
|
||||
} else if (cmd === "mark") {
|
||||
const box = opt.box.split(",").map(Number);
|
||||
const { w, h } = probe(img);
|
||||
const bx = Math.round(box[0] * w),
|
||||
by = Math.round(box[1] * h);
|
||||
const bw = Math.max(2, Math.round((box[2] - box[0]) * w)),
|
||||
bh = Math.max(2, Math.round((box[3] - box[1]) * h));
|
||||
const dst = opt.out || "./check.png";
|
||||
ff(["-i", img, "-vf", `drawbox=x=${bx}:y=${by}:w=${bw}:h=${bh}:color=${RED}@1:t=6`, dst]);
|
||||
out({
|
||||
marked: dst,
|
||||
next: "READ it — red box on the target? If off, redo region/final with corrected strips.",
|
||||
});
|
||||
} else if (cmd === "auto") {
|
||||
// OPTIONAL fast path — only if a strong detector key happens to exist. Never assume it.
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) {
|
||||
console.error(
|
||||
"auto needs GEMINI_API_KEY; use the grid loop instead (overlay→region→final→mark)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const target = pos[1];
|
||||
const b64 = execFileSync("base64", ["-i", img]).toString().replace(/\n/g, "");
|
||||
const mime = img.match(/\.png$/i) ? "image/png" : "image/jpeg";
|
||||
const body = {
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{ inline_data: { mime_type: mime, data: b64 } },
|
||||
{
|
||||
text: `Detect "${target}". Return ONLY {"box_2d":[ymin,xmin,ymax,xmax]} integers 0-1000.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: { temperature: 0, response_mime_type: "application/json" },
|
||||
};
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=${key}`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) },
|
||||
);
|
||||
const t = (await res.json()).candidates[0].content.parts[0].text;
|
||||
let bb = JSON.parse(t.match(/\{[\s\S]*\}/)[0]).box_2d;
|
||||
if (Array.isArray(bb[0])) bb = bb[0];
|
||||
const s = Math.max(...bb) > 1.5 ? 1000 : 1;
|
||||
const box = [bb[1] / s, bb[0] / s, bb[3] / s, bb[2] / s].map(r4);
|
||||
out({ box, center: [r4((box[0] + box[2]) / 2), r4((box[1] + box[3]) / 2)] });
|
||||
} else {
|
||||
console.error("usage: locate.mjs overlay|region|final|mark|auto … (see header comment)");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
// Run from the user's project; locate the release from this installed file.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, win32 } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isMainModule } from "./lib/main-module.mjs";
|
||||
|
||||
const root = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const manifests = [
|
||||
"plugin.json",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".cursor-plugin/plugin.json",
|
||||
"gemini-extension.json",
|
||||
];
|
||||
|
||||
export function pluginVersion(pluginRoot = root) {
|
||||
for (const path of manifests) {
|
||||
const file = join(pluginRoot, path);
|
||||
if (!existsSync(file)) continue;
|
||||
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
||||
if (
|
||||
manifest.name !== "hyperframes" ||
|
||||
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version ?? "")
|
||||
) {
|
||||
throw new Error(`Invalid HyperFrames plugin release: ${file}`);
|
||||
}
|
||||
return manifest.version;
|
||||
}
|
||||
throw new Error(
|
||||
"No HyperFrames plugin manifest found. Use the standalone skills installation instructions.",
|
||||
);
|
||||
}
|
||||
|
||||
export function invocation(
|
||||
args,
|
||||
{
|
||||
version = pluginVersion(),
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
node = process.execPath,
|
||||
pathExists = existsSync,
|
||||
} = {},
|
||||
) {
|
||||
if (args[0] === "skills")
|
||||
throw new Error(
|
||||
"Update HyperFrames through your agent's plugin manager; bundled skills are release-managed.",
|
||||
);
|
||||
const childEnv = {
|
||||
...env,
|
||||
HYPERFRAMES_SKIP_SKILLS: "1",
|
||||
HYPERFRAMES_SKILL_PKG_VERSION: version,
|
||||
HYPERFRAMES_PLUGIN_VERSION: version,
|
||||
HYPERFRAMES_NO_UPDATE_CHECK: "1",
|
||||
};
|
||||
if (args[0] === "--script") {
|
||||
if (!args[1]) throw new Error("--script requires a Node script path.");
|
||||
return { command: node, args: args.slice(1), env: childEnv };
|
||||
}
|
||||
const cliArgs = ["--yes", `hyperframes@${version}`, ...args];
|
||||
if (platform !== "win32") return { command: "npx", args: cliArgs, env: childEnv };
|
||||
const candidates = [
|
||||
env.npm_execpath && win32.join(win32.dirname(env.npm_execpath), "npx-cli.js"),
|
||||
win32.join(win32.dirname(node), "node_modules", "npm", "bin", "npx-cli.js"),
|
||||
].filter(Boolean);
|
||||
const npx = candidates.find(pathExists);
|
||||
if (!npx)
|
||||
throw new Error(
|
||||
"Cannot find npx-cli.js. Install Node.js with npm or run from an npm environment.",
|
||||
);
|
||||
return { command: node, args: [npx, ...cliArgs], env: childEnv };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
const command = invocation(process.argv.slice(2));
|
||||
const result = spawnSync(command.command, command.args, {
|
||||
env: command.env,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { test } from "node:test";
|
||||
import { invocation, pluginVersion } from "./plugin-cli.mjs";
|
||||
|
||||
function fixture(t, manifest = "plugin.json", version = "1.2.3") {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-plugin-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, "skills/hyperframes/scripts/lib"), { recursive: true });
|
||||
mkdirSync(join(root, manifest, ".."), { recursive: true });
|
||||
writeFileSync(join(root, manifest), JSON.stringify({ name: "hyperframes", version }));
|
||||
const launcher = join(root, "skills/hyperframes/scripts/plugin-cli.mjs");
|
||||
copyFileSync(new URL("./plugin-cli.mjs", import.meta.url), launcher);
|
||||
copyFileSync(
|
||||
new URL("./lib/main-module.mjs", import.meta.url),
|
||||
join(root, "skills/hyperframes/scripts/lib/main-module.mjs"),
|
||||
);
|
||||
return { root, launcher };
|
||||
}
|
||||
|
||||
for (const manifest of [
|
||||
"plugin.json",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".cursor-plugin/plugin.json",
|
||||
"gemini-extension.json",
|
||||
]) {
|
||||
test(`locates ${manifest} without repository packages`, (t) => {
|
||||
const { root } = fixture(t, manifest);
|
||||
assert.equal(pluginVersion(root), "1.2.3");
|
||||
});
|
||||
}
|
||||
|
||||
test("rejects missing and floating plugin versions", (t) => {
|
||||
const { root } = fixture(t, "plugin.json", "latest");
|
||||
assert.throws(() => pluginVersion(root), /Invalid HyperFrames plugin release/);
|
||||
rmSync(join(root, "plugin.json"));
|
||||
assert.throws(() => pluginVersion(root), /No HyperFrames plugin manifest/);
|
||||
});
|
||||
|
||||
test("pins CLI, suppresses standalone refresh, preserves unrelated environment", () => {
|
||||
const result = invocation(["init", "project with spaces", "--non-interactive"], {
|
||||
version: "1.2.3",
|
||||
platform: "darwin",
|
||||
env: { PATH: "/bin", HYPERFRAMES_SKIP_SKILLS: "0" },
|
||||
});
|
||||
assert.equal(result.command, "npx");
|
||||
assert.deepEqual(result.args, [
|
||||
"--yes",
|
||||
"hyperframes@1.2.3",
|
||||
"init",
|
||||
"project with spaces",
|
||||
"--non-interactive",
|
||||
]);
|
||||
assert.deepEqual(result.env, {
|
||||
PATH: "/bin",
|
||||
HYPERFRAMES_SKIP_SKILLS: "1",
|
||||
HYPERFRAMES_SKILL_PKG_VERSION: "1.2.3",
|
||||
HYPERFRAMES_PLUGIN_VERSION: "1.2.3",
|
||||
HYPERFRAMES_NO_UPDATE_CHECK: "1",
|
||||
});
|
||||
assert.throws(() => invocation(["skills", "update"], { version: "1.2.3" }), /plugin manager/);
|
||||
});
|
||||
|
||||
test("Windows launches npx through Node without shell argument interpretation", () => {
|
||||
const result = invocation(["render", "a & b"], {
|
||||
version: "1.2.3",
|
||||
platform: "win32",
|
||||
node: String.raw`C:\Program Files\nodejs\node.exe`,
|
||||
env: { npm_execpath: String.raw`C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js` },
|
||||
pathExists: (p) => p === String.raw`C:\Program Files\nodejs\node_modules\npm\bin\npx-cli.js`,
|
||||
});
|
||||
assert.equal(result.command, String.raw`C:\Program Files\nodejs\node.exe`);
|
||||
assert.deepEqual(result.args, [
|
||||
String.raw`C:\Program Files\nodejs\node_modules\npm\bin\npx-cli.js`,
|
||||
"--yes",
|
||||
"hyperframes@1.2.3",
|
||||
"render",
|
||||
"a & b",
|
||||
]);
|
||||
assert.throws(
|
||||
() => invocation([], { version: "1.2.3", platform: "win32", env: {}, pathExists: () => false }),
|
||||
/Cannot find npx/,
|
||||
);
|
||||
});
|
||||
|
||||
test("installed helper keeps cwd, arguments, release environment and exit code", (t) => {
|
||||
const { root, launcher } = fixture(t);
|
||||
const project = join(root, "user project");
|
||||
mkdirSync(project);
|
||||
const helper = join(root, "helper.mjs");
|
||||
writeFileSync(
|
||||
helper,
|
||||
"console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2), skip: process.env.HYPERFRAMES_SKIP_SKILLS, version: process.env.HYPERFRAMES_SKILL_PKG_VERSION })); process.exit(7);",
|
||||
);
|
||||
const before = readFileSync(join(root, "plugin.json"), "utf8");
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[launcher, "--script", helper, "literal $value & spaces"],
|
||||
{ cwd: project, encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 7, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
cwd: realpathSync(project),
|
||||
args: ["literal $value & spaces"],
|
||||
skip: "1",
|
||||
version: "1.2.3",
|
||||
});
|
||||
assert.equal(readFileSync(join(root, "plugin.json"), "utf8"), before);
|
||||
});
|
||||
Reference in new issue
Block a user