app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
#!/usr/bin/env node
// hero-studio drive verbs: everything an AI pushes into a LIVE studio session.
// The open studio follows within a second (SSE + poll), so the human watches
// the pose change as commands land.
//
// hero-studio showcase --file <png> [--full] [--cover] [--chrome none|fake|bare] load a real screenshot
// hero-studio showcase --from-page <owner/slug> [--path <repo image>] take one off a wiki page
// hero-studio set '<json>' | key=value [...] patch posing state
// hero-studio state print current state
// hero-studio toast "<text>" [--kind info|success|warn] narrate to the human
// hero-studio reroll re-roll the circuitry pattern
import path from 'path';
// --port <n> / HERO_STUDIO_PORT target a specific instance (hero-studio#15).
const pi = process.argv.indexOf('--port');
const PORT = Number((pi >= 0 && process.argv[pi + 1]) || process.env.HERO_STUDIO_PORT || 8846);
if (pi >= 0) process.argv.splice(pi, 2);
// Global --ai-thread (#39): the settled platform term; travels as a top-level
// aiThread field on every POST. Env fallback is for single-agent boxes only.
const ti = process.argv.indexOf('--ai-thread');
const AI_THREAD = (ti >= 0 && process.argv[ti + 1]) || process.env.ADOM_AI_THREAD || '';
if (ti >= 0) process.argv.splice(ti, 2);
const BASE = 'http://localhost:' + PORT + '/';
const args = process.argv.slice(2);
const verb = args.shift();
const jf = async (p, body) => {
if (body !== undefined && !AI_THREAD) {
console.error('caller_identity_required: this verb changes state, so it needs the ai-thread name.');
console.error(' Re-run: hero-studio --ai-thread "<name>" ' + (verb || '') + ' ...');
console.error(' Pick a short name a HUMAN would recognize for THIS conversation (like');
console.error(" 'kicad board review', not 'agent' or a UUID) and reuse it on every call.");
process.exit(1);
}
const r = await fetch(BASE + p, body === undefined ? undefined : {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, body)),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
if (j && j.errorCode === 'caller_identity_required') throw new Error(j.error + ' ' + j.hint);
throw new Error(p + ' -> ' + r.status);
}
return j;
};
const die = (msg) => { console.error(msg); process.exit(1); };
try {
if (verb === 'showcase') {
const ui = args.indexOf('--from-url');
if (ui >= 0) {
// #41: the studio drives the capture itself: slot math, DPR, sentinel
// verification, and aspect cropping all happen here, not in the caller.
const url2 = args[ui + 1];
if (!url2) die('usage: hero-studio showcase --from-url <url> [--selector <css>] [--full] [--cover]');
const si = args.indexOf('--selector');
const st = await jf('state');
const slot = st.slotSize || { w: 1280, h: 960 };
const { captureUrl } = await import('./capture.mjs');
const os = await import('os');
const tmp = path.join(os.tmpdir(), 'hs-showcase-' + Date.now() + '.png');
console.error('capturing ' + url2 + ' at slot aspect ' + (slot.w / slot.h).toFixed(2) + ' (' + slot.w + 'x' + slot.h + ') via the desktop bridge...');
const cap = await captureUrl({
url: url2, selector: si >= 0 ? args[si + 1] : null,
aspect: slot.w / slot.h, outFile: tmp,
target: process.env.ADOM_TARGET || '', thread: AI_THREAD,
});
if (!cap.ok) die('ERROR: ' + cap.error);
const out2 = await jf('showcase-file', { path: tmp, full: args.includes('--full'), cover: args.includes('--cover'), chrome: '' });
if (!out2.ok) die('ERROR: ' + out2.error);
console.log(JSON.stringify({ ok: true, captured: cap.width + 'x' + cap.height, bytes: out2.bytes }));
console.error('showcase captured and loaded; the open studio updates within a second');
process.exit(0);
}
const fi = args.indexOf('--file');
const pi = args.indexOf('--from-page');
if (pi >= 0) {
const pref = args[pi + 1];
if (!pref || pref.startsWith('--')) die('usage: hero-studio showcase --from-page <owner/slug> [--path <repo-relative image>]');
const pathIdx = args.indexOf('--path');
const want = pathIdx >= 0 ? args[pathIdx + 1] : '';
const qs = 'showcase-from-page?page=' + encodeURIComponent(pref) + (want ? '&path=' + encodeURIComponent(want) : '');
const r = await jf(qs, { method: 'POST' });
if (!r.ok) die('showcase --from-page: ' + (r.error || 'failed'));
console.log(JSON.stringify({ ok: true, picked: r.picked, bytes: r.bytes }));
console.error('showcase <- ' + pref + '/' + r.picked + ' (' + Math.round(r.bytes / 1024) + ' KB); the open studio updates within a second');
process.exit(0);
}
if (fi < 0 || !args[fi + 1]) die('usage: hero-studio showcase --file <png> | --from-page <owner/slug> [--path <p>] | --from-url <url> [--selector <css>] [--full] [--cover] [--chrome none|fake|bare]');
const file = path.resolve(args[fi + 1]);
const ci = args.indexOf('--chrome');
const chrome = ci >= 0 ? args[ci + 1] : '';
if (chrome && !['none', 'fake', 'bare'].includes(chrome)) die('--chrome takes none|fake|bare (bare: no frame, bg, or shadow at all; default: auto, which is none for imported screenshots)');
const out = await jf('showcase-file', {
path: file,
full: args.includes('--full'),
cover: args.includes('--cover'),
chrome,
});
if (!out.ok) die('ERROR: ' + out.error + (out.error && out.error.includes('cannot read') ? '\n hint: the studio server reads the path locally; pass an absolute path on this machine' : ''));
const slot = out.slot ? ' (slot wants ' + out.slot.w + 'x' + out.slot.h + ')' : '';
console.log(JSON.stringify({ ok: true, bytes: out.bytes, slot: out.slot }));
console.error('showcase loaded' + slot + '; the open studio updates within a second');
} else if (verb === 'set') {
// --list: print the valid enum tokens (hero-studio#24 ask 2)
if (args.includes('--list')) {
const st = await jf('state');
console.log(JSON.stringify({ enums: st.enums }, null, 2));
process.exit(0);
}
let patch = {};
if (args.length === 1 && args[0].trim().startsWith('{')) {
patch = JSON.parse(args[0]);
} else if (args.length) {
for (const kv of args) {
const eq = kv.indexOf('=');
if (eq < 1) die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list');
const k = kv.slice(0, eq);
const v = kv.slice(eq + 1);
const val = /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v === 'true' ? true : v === 'false' ? false : v;
// dot-paths build nested patches (#24 ask 1: fontSize.title=9 used to
// land as a literal "fontSize.title" key the page ignored)
const parts = k.split('.');
let node = patch;
for (let i = 0; i < parts.length - 1; i++) node = node[parts[i]] = node[parts[i]] || {};
node[parts[parts.length - 1]] = val;
}
} else {
die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list');
}
const r = await fetch(BASE + 'state', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, patch)),
});
const out = await r.json().catch(() => ({}));
if (!r.ok || out.ok === false) {
die('ERROR: ' + (out.error || 'state rejected') + (out.valid ? '\n valid: ' + out.valid.join(' | ') : ''));
}
console.log(JSON.stringify({ ok: true, applied: patch }));
} else if (verb === 'bg') {
const fi = args.indexOf('--file');
if (fi < 0 || !args[fi + 1]) die('usage: hero-studio bg --file <image> [--cover] (imports background art as theme "image")');
const out = await jf('bg-file', { path: path.resolve(args[fi + 1]), cover: args.includes('--cover') });
if (!out.ok) die('ERROR: ' + out.error);
console.log(JSON.stringify({ ok: true, theme: 'image', bytes: out.bytes }));
console.error('background art imported; theme switched to image (composed text, layouts, and the linter still apply)');
} else if (verb === 'changes') {
console.log(JSON.stringify(await jf('changes'), null, 2));
} else if (verb === 'state') {
console.log(JSON.stringify(await jf('state'), null, 2));
} else if (verb === 'toast') {
const text = args.filter((a) => !a.startsWith('--')).join(' ');
const ki = args.indexOf('--kind');
await jf('ui/toast', { text, kind: ki >= 0 ? args[ki + 1] : 'info' });
console.log('{"ok":true}');
} else if (verb === 'reroll') {
console.log(JSON.stringify(await jf('reroll', {})));
} else if (verb === 'preset') {
// named family design systems (#34.2); no arg lists what exists
const name = args.find((a) => !a.startsWith('--'));
const r = await fetch(BASE + 'preset', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || '', aiThread: AI_THREAD }),
});
const out = await r.json().catch(() => ({}));
if (!r.ok || out.ok === false) {
if (out.valid) { console.log(JSON.stringify({ presets: out.valid }, null, 2)); process.exit(name ? 1 : 0); }
die('ERROR: ' + (out.error || 'preset rejected'));
}
console.log(JSON.stringify(out));
console.error('preset ' + out.applied + ' applied; the open studio updates within a second');
} else if (verb === 'ls') {
// the instance registry (#39): live studios, liveness-checked, dead
// entries pruned, version drift vs the on-disk package flagged
const fsm = await import('fs');
const os = await import('os');
const dir = path.join(os.homedir(), '.adom', 'instances', 'hero-studio');
const diskVer = JSON.parse(fsm.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
const rows = [];
let files = [];
try { files = fsm.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch {}
for (const f of files) {
let e;
try { e = JSON.parse(fsm.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }
try { process.kill(e.pid, 0); } catch { try { fsm.unlinkSync(path.join(dir, f)); } catch {}; continue; }
const idleMin = Math.round((Date.now() - new Date(e.lastActivity || e.startedAt).getTime()) / 60000);
rows.push({ port: e.port, thread: e.thread, task: e.task, version: e.version, idleMinutes: idleMin, pid: e.pid, drift: e.version !== diskVer });
}
rows.sort((a, b) => a.port - b.port);
console.log(JSON.stringify({ instances: rows, diskVersion: diskVer }, null, 2));
for (const r2 of rows) {
if (r2.drift) console.error('WARN: port ' + r2.port + ' runs ' + r2.version + ', disk has ' + diskVer + '. Shut it down (hero-studio stop --port ' + r2.port + ') and respawn.');
}
if (!rows.length) console.error('no live instances; hero-studio --ai-thread "<name>" starts one (or pose does)');
} else if (verb === 'fit') {
// auto-fit the title (#40): solved in the open surface with real metrics
const out = await jf('fit', {});
if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n hint: ' + out.hint : ''));
console.log(JSON.stringify(out));
console.error('fit requested; read state.fontSize.title in about a second');
} else if (verb === 'style') {
// style save <name> | style list -- the human-gated template for bulk
const sub = args.find((a) => !a.startsWith('--'));
if (sub === 'list' || !sub) {
console.log(JSON.stringify(await jf('styles'), null, 2));
} else if (sub === 'save') {
const name = args[args.indexOf('save') + 1];
if (!name) die('usage: hero-studio style save <name> (after a human-approved Generate)');
const out = await jf('style', { name });
if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n hint: ' + out.hint : ''));
console.log(JSON.stringify(out));
console.error('style "' + out.name + '" saved, gated by the ' + out.gate.page + ' approval; batch with: hero-studio batch --style ' + out.name + ' --parts <parts.json>');
} else die('usage: hero-studio style save <name> | style list');
} else if (verb === 'batch') {
// The bulk half of the once-through-the-gate model: apply a human-gated
// style to many parts, generating each locally (no wiki calls, no rate
// limits here); --pace spaces the renders.
const si = args.indexOf('--style');
const pi2 = args.indexOf('--parts');
if (si < 0 || pi2 < 0) die('usage: hero-studio batch --style <name> --parts <parts.json> [--pace SECS] [--human <who>] [--quote "their words"]\n parts.json: [{"ref":"adom/x","title":"X","showcase":"render.png","kind":"component"}, ...]');
const styleName = args[si + 1];
const fsm = await import('fs');
const parts = JSON.parse(fsm.readFileSync(args[pi2 + 1], 'utf8'));
const pace = Number(args[args.indexOf('--pace') + 1]) || 5;
const human = args.includes('--human') ? args[args.indexOf('--human') + 1] : '';
const quote = args.includes('--quote') ? args[args.indexOf('--quote') + 1] : '';
if (!human || !quote) die('the batch carries the human\'s attestation: --human <who approved the style> --quote "their approval words"');
const styles = await jf('styles');
const st2 = (styles.styles || []).find((x) => x.name === styleName);
if (!st2) die('unknown style "' + styleName + '"; hero-studio style list shows saved ones');
const stylePatch = JSON.parse(fsm.readFileSync((await import('os')).homedir() + '/.adom/instances/hero-studio/styles/' + styleName + '.json', 'utf8')).style;
const results = [];
console.error('batch: ' + parts.length + ' part(s), style "' + styleName + '" (gated by ' + st2.gate.page + '), pace ' + pace + 's');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
try {
await jf('api/pages', { owner: part.ref.split('/')[0], slug: part.ref.split('/')[1], title: part.title, type: part.type || 'hardware', kind: part.kind });
await jf('state', Object.assign({}, stylePatch, { page: part.ref, headline: part.headline || '', subtitle: part.subtitle || '', showcase: Object.assign({ src: '', full: false }, stylePatch.showcase || {}) }));
if (part.showcase) {
const v = await jf('showcase-file', { path: path.resolve(part.showcase), cover: !!(stylePatch.showcase || {}).cover, chrome: (stylePatch.showcase || {}).chrome || '' });
if (!v.ok) throw new Error('showcase: ' + v.error);
}
const g = await jf('generate', { attest: { style: styleName, human, quote } });
if (!g.ok) throw new Error(g.error);
// wait for the finalize (poll state._gen)
let done = null;
for (let w = 0; w < 60 && !done; w++) {
await new Promise((r) => setTimeout(r, 1000));
const stt = await jf('state');
if (stt._gen && stt._gen.ok && stt._gen.file && (!results.length || stt._gen.file !== results[results.length - 1].file)) done = stt._gen;
}
if (!done) throw new Error('generate did not finish in 60s');
results.push({ ref: part.ref, ok: true, file: done.file });
console.error(' [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' -> ' + done.file);
} catch (e) {
results.push({ ref: part.ref, ok: false, error: e.message });
console.error(' [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' FAILED: ' + e.message);
}
if (i < parts.length - 1) await new Promise((r) => setTimeout(r, pace * 1000));
}
console.log(JSON.stringify({ style: styleName, results }, null, 1));
const bad = results.filter((r) => !r.ok).length;
console.error('batch done: ' + (results.length - bad) + ' ok, ' + bad + ' failed; PNGs in generated/ (copy each to its part\'s docs/hero.png before publish)');
} else if (verb === 'instances') {
// every live studio on this box (#26): scan the port neighborhood
const ports = new Set([PORT]);
for (let p2 = 8846; p2 <= 8866; p2++) ports.add(p2);
const rows = [];
await Promise.all([...ports].map(async (p2) => {
try {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 900);
const r = await fetch('http://localhost:' + p2 + '/state', { signal: ac.signal });
clearTimeout(t);
if (!r.ok) return;
const st = await r.json();
if (!st || !st.page) return;
rows.push({ port: p2, page: st.page, gateOpen: !!st.verify, posedAt: st.posedAt || null, approved: st.approved ? st.approved.file : null });
} catch {}
}));
rows.sort((a, b) => a.port - b.port);
console.log(JSON.stringify({ instances: rows }, null, 2));
if (!rows.length) console.error('no live studio instances on ports 8846-8866; `hero-studio` or `hero-studio pose ...` starts one');
} else if (verb === 'stop') {
// polite shutdown of one instance (#26); refuses nothing, targets by --port
const out = await jf('shutdown', {});
console.log(JSON.stringify(out));
console.error('studio on :' + (out.port || PORT) + ' is shutting down');
} else if (verb === 'compose') {
// headless compose of the CURRENT pose (#36.2): verify staging without a
// browser. Not the gate: no provenance stamp, never publishable.
const fsm = await import('fs');
const outFile = path.resolve(args.find((a) => !a.startsWith('--')) || 'compose.png');
const r = await fetch(BASE + 'compose.png');
if (!r.ok) die('ERROR: compose render failed (' + r.status + '): ' + (await r.text()).slice(0, 300));
const buf = Buffer.from(await r.arrayBuffer());
fsm.writeFileSync(outFile, buf);
// hero-studio#51: compose measures lint server-side in the same render;
// print the fresh findings so this verb IS the reliable gate read.
let lint = null, lintMeta = null;
try {
const st = await (await fetch(BASE + 'state')).json();
lint = st.lint || [];
lintMeta = st.lintMeta || null;
} catch {}
console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length, lint, lintMeta }));
console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)');
if (lint) {
for (const l of lint) console.error('lint ' + l.level + ' [' + l.id + '] ' + l.msg);
if (lintMeta && !lintMeta.stale) console.error('lint measured fresh by this compose (poseRev ' + lintMeta.poseRev + ')');
}
} else {
die('verbs: pose | showcase | set | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | instances | stop');
}
} catch (e) {
die('ERROR: ' + e.message + '\n hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');
}