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
← Commit history
Publish 0.10.1
12 files changed
+388−51
README.md+24−4@@ -75,8 +75,8 @@ designing a real hero. hero-studio # serves on port 8846 ``` -Open `/proxy/8846/` in a webview (or `http://localhost:8846` locally). No dependencies,-plain Node.+Open `/proxy/8846/` in a webview (or `http://localhost:8846` locally). The server itself+is plain Node with no dependencies; only Generate needs the renderer runtime below. Refresh the sample data from the live wiki any time: @@ -84,6 +84,25 @@ Refresh the sample data from the live wiki any time: ./refresh-data.sh # needs the adom-wiki CLI, then restart the server ``` +## Runtime requirements (Generate)++Generate renders headless Chrome INSIDE the container the server runs in. Light images+(Hydrogen Desktop containers, for example) do not ship any of this, on purpose. What the+render pipeline needs:++- **puppeteer-core** (npm). On Node below 22.12 pin `puppeteer-core@24`; 25.x declares+ `engines: node >=22.12` and can fail at launch rather than at import.+- **A Chrome binary**: `npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer`+ (that `--path` puts it where the renderer scans), or point `CHROME` at any binary.+- **The browser's OS libraries** (libnss3, libgbm1, libasound2t64 on noble, and friends);+ a Chrome binary that exists but cannot start is the classic light-container trap.+- **python3 + PIL** for the export downscale and provenance stamp.++`hero-studio doctor` checks all of it, including that Chrome actually launches, and prints+the exact fix commands for whatever is missing. Install runs it and warns loudly; `pose`+refuses to open the gate for a human until it passes. Whoever installed the package is+already inside the container, so the fixes never require outside access.+ ## Generate The Generate button (or `POST /generate`) renders the current hero server-side with@@ -91,8 +110,9 @@ Puppeteer to the hero-standard 2000x1250 PNG, pops a toast and a preview modal i open studio, and writes `generated/latest.png`. Each PNG carries a `comment` metadata chunk recording that hero-studio produced it, plus the exact studio state, so publish tooling can verify a hero came through the studio rather than being hand-rolled slop.-The canonical flow for an agent: pose the studio, `POST /generate`, then-`adom-wiki page hero <owner>/<slug> --image generated/latest.png`.+The canonical flow for an agent is the pose gate: `hero-studio pose <owner>/<slug>`,+which opens the studio in front of the human and blocks until THEY click Generate. An+agent never calls `POST /generate` itself; a hero a human did not make should not exist. ## Drive it (AI-first)
SKILL.md+31−2@@ -1,7 +1,6 @@ --- name: hero-studio-description: Open and drive Hero Studio, the hero-image lab with a human gate. Use when a package needs a hero image, when a publish is rejected with HERO_STUDIO_REQUIRED, or when the user wants to design or preview a wiki hero, try layouts, themes, owner brand colors, or see a hero inside the wiki marquee and index grid. Trigger phrases - open hero studio, design a hero, make a hero image, hero playground, pose a hero, preview hero in marquee, hero layouts, hero themes.-user-invocable: true+description: Open and drive Hero Studio, the hero-image lab with a human gate. Use when a package needs a hero image, when a publish is rejected with HERO_STUDIO_REQUIRED, or when the user wants to design or preview a wiki hero, try layouts, themes, owner brand colors, or see a hero inside the wiki marquee and index grid. Trigger phrases - open hero studio, design a hero, make a hero image, hero playground, pose a hero, preview hero in marquee, hero layouts, hero themes, hero studio doctor, hero generate failed, renderer not ready. --- # Hero Studio@@ -53,6 +52,36 @@ Do not call POST /generate yourself to produce a hero; that bypasses the human a the result should not be published. Publishes of new app/skill heroes are rejected server-side (HERO_STUDIO_REQUIRED) unless the PNG carries the studio provenance stamp. +## Light containers: doctor must pass before the gate opens++Generate renders headless Chrome INSIDE the container the server runs in. Light+images (Hydrogen Desktop containers) ship no browser runtime, so on them a fresh+install cannot render, and `pose` REFUSES to open the gate (RENDERER_NOT_READY)+rather than waste a human review on a Generate that cannot work. The check and+the fix are the same command:++```bash+hero-studio doctor+# verifies: puppeteer-core resolves (and fits this Node), a Chrome binary+# exists, Chrome actually LAUNCHES (catches missing OS libraries, where a+# file-exists test lies), and python3+PIL for the provenance stamp.+# exit 0 ready; exit 1 prints the exact fix commands for each failure.+```++Run the printed fix commands yourself: you are already inside the container+(no SSH, no outside access involved), and install already warned about this at+`pkg install` time. Typical light-container fix: `npm install puppeteer-core@24`+(Node below 22.12 cannot use 25.x), `npx @puppeteer/browsers install+chrome@stable --path ~/.cache/puppeteer`, then the apt library list doctor+prints (note: `libasound2t64` on Ubuntu 24.04). Re-run doctor until it passes,+then pose. pup being able to drive the host browser is irrelevant here: the+render is server-side, in the container.++If `hero-studio vitrine|set|state|toast|reroll` dies with MODULE_NOT_FOUND,+the install is hero-studio 0.10.0, which shipped without drive.mjs; update the+package (`adom-wiki pkg install adom/hero-studio`) or POST to the server+directly (`/vitrine-file` with `{"path": "<abs path>"}`).+ ## Platform-split siblings (twins) Policy: NO hero inheritance. Every sibling page (adom-desktop-macos and
bin/hero-studio+1@@ -6,6 +6,7 @@ SELF="$(readlink -f "$0")" DIR="$(cd "$(dirname "$SELF")/.." && pwd)" case "$1" in pose) shift; exec node "$DIR/pose.mjs" "$@" ;;+ doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;; vitrine|set|state|toast|reroll) exec node "$DIR/drive.mjs" "$@" ;; *) exec node "$DIR/server.js" "$@" ;; esac
data/pages.json+13@@ -1,4 +1,17 @@ [+ {+ "type": "app",+ "title": "US History Jeopardy",+ "author": "",+ "score": 0,+ "stars": 0,+ "installs30d": 0,+ "brief": "Host-run US History trivia: two rounds, daily doubles, Final Jeopardy",+ "tags": [],+ "updated_at": "2026-07-29T18:43:31.630Z",+ "owner": "kcknox",+ "slug": "us-history-jeopardy"+ }, { "type": "skill", "title": "Hardware Pipeline",
doctor.mjsadded+176@@ -0,0 +1,176 @@+#!/usr/bin/env node+// hero-studio doctor: verify the server-side render pipeline actually works,+// BEFORE a human is put in front of the pose gate. Checks that puppeteer-core+// resolves and fits this Node, that a Chrome binary exists, that the binary+// RUNS (a file-exists test passes right past missing shared libraries), and+// that python3+PIL are present for the provenance stamp.+//+// Exit 0 all green, exit 1 with named failures and exact remediation.+// Light containers (Hydrogen Desktop) ship none of this on purpose; the agent+// installing hero-studio there runs the printed commands itself.+import { existsSync, readdirSync, readFileSync } from 'fs';+import { execFile } from 'child_process';+import { homedir } from 'os';+import path from 'path';+import { fileURLToPath } from 'url';++const DIR = path.dirname(fileURLToPath(import.meta.url));++// ---- shared discovery (render-hero.mjs imports these; keep them in sync-proof) ----++export function puppeteerCandidates() {+ return [+ process.env.PUPPETEER_CORE,+ path.join(DIR, 'node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'),+ '/home/adom/project/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',+ homedir() + '/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',+ ].filter(Boolean);+}++export async function loadPuppeteer() {+ for (const c of puppeteerCandidates()) {+ try { return (await import(c)).default; } catch {}+ }+ try { return (await import('puppeteer-core')).default; } catch {}+ throw new Error('puppeteer-core not found. Set PUPPETEER_CORE to its puppeteer-core.js path.');+}++export function findChrome() {+ if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;+ const base = homedir() + '/.cache/puppeteer/chrome';+ if (existsSync(base)) {+ for (const d of readdirSync(base).sort().reverse()) {+ const p = base + '/' + d + '/chrome-linux64/chrome';+ if (existsSync(p)) return p;+ }+ }+ throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path.');+}++// ---- checks ----++const run = (cmd, args, timeout = 15000) => new Promise((res) => {+ execFile(cmd, args, { timeout }, (err, stdout, stderr) =>+ res({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || '') }));+});++function puppeteerEngineFit() {+ // puppeteer-core 25.x imports fine on Node 18 and then fails at launch, so+ // the mismatch never surfaces where you would look. Check engines up front.+ for (const c of puppeteerCandidates().map((p) => p.replace(/lib\/puppeteer\/puppeteer-core\.js$/, 'package.json'))) {+ try {+ const pkg = JSON.parse(readFileSync(c, 'utf8'));+ const want = pkg.engines && pkg.engines.node;+ const m = want && want.match(/>=\s*(\d+)\.(\d+)/);+ if (!m) return { fit: true, version: pkg.version };+ const [maj, min] = process.versions.node.split('.').map(Number);+ const fit = maj > +m[1] || (maj === +m[1] && min >= +m[2]);+ return { fit, version: pkg.version, want };+ } catch {}+ }+ // resolved via bare import only; try its package.json through require paths+ return { fit: true, version: null };+}++export async function runChecks() {+ const checks = [];+ const fail = (name, detail, fix) => checks.push({ name, ok: false, detail, fix });+ const pass = (name, detail) => checks.push({ name, ok: true, detail });++ // 1. puppeteer-core resolves. An engines mismatch is a WARNING, not a+ // failure: the launch check below is the ground truth (25.x's engines say+ // node >=22.12 yet it launches fine on some Node 18 images), but when the+ // launch DOES fail on mismatched engines, the pin is the first fix to try.+ let pptr = null;+ let engineWarn = null;+ try {+ pptr = await loadPuppeteer();+ const eng = puppeteerEngineFit();+ if (!eng.fit) engineWarn = `engines want node ${eng.want}; this is ${process.versions.node}`;+ pass('puppeteer-core', 'resolves' + (eng.version ? ` (${eng.version})` : '') + (engineWarn ? `; warning: ${engineWarn}, trusting the launch check` : ''));+ } catch (e) {+ fail('puppeteer-core', e.message,+ `cd ${DIR} && npm install puppeteer-core@24 # 24.x supports Node 18; 25.x wants >=22.12`);+ }++ // 2. a Chrome binary exists+ let chrome = null;+ try {+ chrome = findChrome();+ pass('chrome binary', chrome);+ } catch (e) {+ fail('chrome binary', e.message,+ 'npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n' ++ ' # --path matters: findChrome() scans ~/.cache/puppeteer/chrome/');+ }++ // 3. the binary actually RUNS. This is the check that catches missing shared+ // libraries; a file-exists test lies on rootfs images never built for a browser.+ let chromeRuns = false;+ if (chrome) {+ const v = await run(chrome, ['--version']);+ if (v.ok) {+ chromeRuns = true;+ pass('chrome runs', v.stdout.trim());+ } else {+ const ldd = await run('ldd', [chrome]);+ const missing = ldd.stdout.split('\n').filter((l) => l.includes('not found'))+ .map((l) => l.trim().split(' ')[0]);+ fail('chrome runs', 'chrome --version failed' + (missing.length ? '; missing libraries: ' + missing.join(', ') : ''),+ 'sudo apt-get install -y libglib2.0-0 libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \\\n' ++ ' libdbus-1-3 libcups2 libxkbcommon0 libasound2t64 libgbm1 libpango-1.0-0 libcairo2 \\\n' ++ ' libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libatspi2.0-0 fonts-liberation\n' ++ ' # libasound2t64 is the Ubuntu 24.04 (noble) name; older releases use libasound2');+ }+ }++ // 4. the definitive check: a real headless launch through puppeteer, same+ // args as render-hero.mjs. If this passes, Generate will render.+ if (pptr && chromeRuns) {+ try {+ const browser = await pptr.launch({+ executablePath: chrome, headless: 'new',+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],+ });+ await browser.close();+ pass('headless launch', 'puppeteer launched Chrome and closed it cleanly');+ } catch (e) {+ fail('headless launch', String(e && e.message || e).split('\n')[0],+ engineWarn+ ? `cd ${DIR} && npm install puppeteer-core@24 # ${engineWarn}; the pin is the first fix to try`+ : 'read the launch error; check PUPPETEER_CORE/CHROME env overrides and OS libraries');+ }+ }++ // 5. python3 + PIL stamp the provenance metadata after the render+ const pil = await run('python3', ['-c', 'import PIL']);+ if (pil.ok) pass('python3 + PIL', 'provenance stamping ready');+ else fail('python3 + PIL', 'the export downscale + provenance stamp needs Pillow',+ 'sudo apt-get install -y python3-pil # or: pip3 install pillow');++ return { ok: checks.every((c) => c.ok), checks };+}++export function printReport(report, out = console.error) {+ for (const c of report.checks) {+ out((c.ok ? ' ok ' : ' FAIL ') + c.name + ': ' + c.detail);+ if (!c.ok && c.fix) out(' fix: ' + c.fix);+ }+ if (report.ok) {+ out('renderer ready: Generate will work in this container.');+ } else {+ out('');+ out('RENDERER_NOT_READY: Generate renders headless Chrome INSIDE this container;');+ out('the checks above name what is missing and the exact fix. Run the fix commands');+ out('yourself (you are already in the container; no one needs to SSH anywhere),');+ out('then re-run: hero-studio doctor');+ }+}++// CLI entry: `hero-studio doctor` (also run by install.sh in warn mode)+if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {+ const report = await runChecks();+ printReport(report);+ console.log(JSON.stringify({ ok: report.ok, checks: report.checks }));+ process.exit(report.ok ? 0 : 1);+}
drive.mjsadded+73@@ -0,0 +1,73 @@+#!/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 vitrine --file <png> [--full] [--cover] load a real screenshot+// 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';++const BASE = 'http://localhost:8846/';+const args = process.argv.slice(2);+const verb = args.shift();++const jf = async (p, body) => {+ const r = await fetch(BASE + p, body === undefined ? undefined : {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify(body),+ });+ const j = await r.json().catch(() => ({}));+ if (!r.ok) throw new Error(p + ' -> ' + r.status);+ return j;+};+const die = (msg) => { console.error(msg); process.exit(1); };++try {+ if (verb === 'vitrine') {+ const fi = args.indexOf('--file');+ if (fi < 0 || !args[fi + 1]) die('usage: hero-studio vitrine --file <png> [--full] [--cover]');+ const file = path.resolve(args[fi + 1]);+ const out = await jf('vitrine-file', {+ path: file,+ full: args.includes('--full'),+ cover: args.includes('--cover'),+ });+ 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('vitrine loaded' + slot + '; the open studio updates within a second');+ } else if (verb === 'set') {+ 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 [k, v] = kv.split('=');+ if (!k || v === undefined) die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora gradAngle=200');+ patch[k] = /^-?\d+$/.test(v) ? Number(v) : v === 'true' ? true : v === 'false' ? false : v;+ }+ } else {+ die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora gradAngle=200');+ }+ const out = await jf('state', patch);+ console.log(JSON.stringify({ ok: true, applied: patch }));+ void out;+ } 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 {+ die('verbs: pose | vitrine | set | state | toast | reroll');+ }+} catch (e) {+ die('ERROR: ' + e.message + '\n hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');+}
install.sh+15@@ -6,3 +6,18 @@ ln -sfn "$PWD/bin/hero-studio" "$HOME/.local/bin/hero-studio" echo "installed: hero-studio" echo "run 'hero-studio' (serves on port 8846), then open /proxy/8846/ in a webview" echo "refresh the sample data with ./refresh-data.sh (needs the adom-wiki CLI)"++# Renderer preflight, warn mode: install stays exit 0 (driving the studio and+# fixing deps later are legitimate on light containers), but a broken renderer+# must be impossible to read as success. Generate spawns headless Chrome INSIDE+# this container; light images (Hydrogen Desktop) do not ship that runtime.+if node "$PWD/doctor.mjs" >/dev/null 2>&1; then+ echo "renderer: OK (headless Chrome + stamping verified; Generate will work)"+else+ echo ""+ echo "!! RENDERER_NOT_READY -- installed, BUT GENERATE WILL FAIL IN THIS CONTAINER !!"+ echo "!! Do NOT open the pose gate for a human yet: run 'hero-studio doctor', !!"+ echo "!! run the fix commands it prints (you have the shell; no SSH needed), !!"+ echo "!! and re-run it until it passes. pose refuses to open until then. !!"+ node "$PWD/doctor.mjs" 2>&1 | sed 's/^/ /' || true+fi
package.json+10−4@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.10.0",+ "version": "0.10.1", "description": "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. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.", "license": "MIT", "tags": [@@ -40,7 +40,11 @@ "import a hero", "re-stamp a hero", "verify a hero",- "vitrine"+ "vitrine",+ "hero studio doctor",+ "hero generate failed",+ "renderer not ready",+ "hero studio light container" ], "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.", "sample_prompts": [@@ -74,14 +78,16 @@ "refresh-data.sh", "server.js", "pose.mjs",+ "drive.mjs",+ "doctor.mjs", "bin/hero-studio", "public/**", "data/**",- "docs/hero.png", "docs/contexts.png", "docs/themes.png", "docs/index-cards.png", "docs/component-mode.png",- "render-hero.mjs"+ "render-hero.mjs",+ "screenshots/hero.png" ] }
page.json+10−4@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.10.0",+ "version": "0.10.1", "description": "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. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.", "license": "MIT", "tags": [@@ -40,7 +40,11 @@ "import a hero", "re-stamp a hero", "verify a hero",- "vitrine"+ "vitrine",+ "hero studio doctor",+ "hero generate failed",+ "renderer not ready",+ "hero studio light container" ], "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.", "sample_prompts": [@@ -75,15 +79,17 @@ "refresh-data.sh", "server.js", "pose.mjs",+ "drive.mjs",+ "doctor.mjs", "bin/hero-studio", "public/**", "data/**",- "docs/hero.png", "docs/contexts.png", "docs/themes.png", "docs/index-cards.png", "docs/component-mode.png",- "render-hero.mjs"+ "render-hero.mjs",+ "screenshots/hero.png" ], "org": "adom", "author": {
pose.mjs+30−7@@ -11,6 +11,7 @@ import { execFile, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path'; import fs from 'fs';+import { runChecks, printReport } from './doctor.mjs'; const DIR = path.dirname(fileURLToPath(import.meta.url)); const BASE = 'http://localhost:8846/';@@ -38,13 +39,27 @@ if (surface !== 'webview' && surface !== 'pup') { const [owner, slug] = ref.split('/'); const timeoutSecs = Number(flags.timeout || 900); +// 0. PREFLIGHT: prove the renderer works BEFORE a human is summoned. Without+// this, the human walks the full checklist, clicks Generate, and only then+// learns the container cannot render (light Hydrogen Desktop images ship no+// headless-Chrome runtime). That wasted review is the worst failure this tool+// can produce, so the gate refuses to open over a dead renderer.+const preflight = await runChecks();+if (!preflight.ok) {+ console.error('ERROR [RENDERER_NOT_READY]: refusing to open the pose gate.');+ printReport(preflight);+ console.error('Fix the renderer FIRST, then re-run pose. Do not open the studio for the');+ console.error('human until `hero-studio doctor` passes.');+ process.exit(1);+}+ const jf = async (p, opts) => { const r = await fetch(BASE + p, opts); if (!r.ok) throw new Error(p + ' -> ' + r.status); return r.json(); };-const sh = (cmd, a) => new Promise((res) => {- execFile(cmd, a, { timeout: 20000 }, (err, stdout) => res(err ? null : stdout));+const sh = (cmd, a, timeout = 20000) => new Promise((res) => {+ execFile(cmd, a, { timeout }, (err, stdout) => res(err ? null : stdout)); }); // 1. ensure the server is up@@ -95,17 +110,25 @@ if (surface === 'pup') { // spawn a duplicate window (browser_status first, then navigate-or-open). const status = await sh('adom-desktop', ['browser_status', '{}']); const hasSession = !!(status && status.includes('hero-studio'));- const opened = hasSession+ let opened = hasSession ? await sh('adom-desktop', ['browser_navigate', JSON.stringify({ sessionId: 'hero-studio', url: proxyBase })])- : await sh('adom-desktop', ['browser_open_window', JSON.stringify({ sessionId: 'hero-studio', profile: 'hero-studio', url: proxyBase })]);+ : await sh('adom-desktop', ['browser_open_window', JSON.stringify({ sessionId: 'hero-studio', profile: 'hero-studio', url: proxyBase })], 60000);+ if (opened === null && !hasSession) {+ // A first open on a cold profile can outlive its exec timeout while the+ // window is still coming up; poll for the session before calling desktop+ // dead, or a slow start gets misread as PUP_UNREACHABLE.+ for (let i = 0; i < 6 && opened === null; i++) {+ await new Promise((r) => setTimeout(r, 5000));+ const st = await sh('adom-desktop', ['browser_status', '{}']);+ if (st && st.includes('hero-studio')) opened = st;+ }+ } if (opened === null) { console.error('WARN [PUP_UNREACHABLE]: Adom Desktop did not answer; falling back to a webview tab so the gate still reaches the human.');+ await openWebview(); } else { await sh('adom-desktop', ['browser_alert_window', JSON.stringify({ sessionId: 'hero-studio' })]); }- if (opened === null) {- await openWebview();- } } else { await openWebview(); }
render-hero.mjs+4−29@@ -1,36 +1,11 @@ // Renders the studio's ?render=1 view to a PNG. Usage: node render-hero.mjs <url> <out.png>-// Needs puppeteer-core (PUPPETEER_CORE env or a known location) and Chrome (CHROME env or-// the puppeteer cache). Exits nonzero with a plain message when either is missing.-import { existsSync, readdirSync } from 'fs';-import { homedir } from 'os';+// Discovery of puppeteer-core and Chrome lives in doctor.mjs (one source of+// truth; `hero-studio doctor` preflights the same paths this render uses).+// Exits nonzero with a plain message when either is missing.+import { loadPuppeteer, findChrome } from './doctor.mjs'; const [,, url, out] = process.argv; -async function loadPuppeteer() {- const candidates = [- process.env.PUPPETEER_CORE,- '/home/adom/project/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',- homedir() + '/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',- ].filter(Boolean);- for (const c of candidates) {- try { return (await import(c)).default; } catch {}- }- try { return (await import('puppeteer-core')).default; } catch {}- throw new Error('puppeteer-core not found. Set PUPPETEER_CORE to its puppeteer-core.js path.');-}--function findChrome() {- if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;- const base = homedir() + '/.cache/puppeteer/chrome';- if (existsSync(base)) {- for (const d of readdirSync(base).sort().reverse()) {- const p = base + '/' + d + '/chrome-linux64/chrome';- if (existsSync(p)) return p;- }- }- throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path.');-}- const puppeteer = await loadPuppeteer(); const browser = await puppeteer.launch({ executablePath: findChrome(), headless: 'new',
server.js+1−1@@ -251,7 +251,7 @@ const server = http.createServer((req, res) => { clearTimeout(timer); if (code !== 0 || !fs.existsSync(outFile)) { logLine('error', 'generate failed: ' + errBuf.slice(-400));- return finish({ ok: false, error: errBuf.trim().split('\n').pop() || 'render exited ' + code, hint: 'set CHROME and PUPPETEER_CORE env vars for the server process' });+ return finish({ ok: false, error: errBuf.trim().split('\n').pop() || 'render exited ' + code, hint: "run 'hero-studio doctor': it verifies puppeteer-core, Chrome (including that the binary RUNS), and the PIL stamp step, and prints the exact fix for whatever is missing" }); } // downscale to the hero standard 2000x1250 and stamp provenance metadata const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined } });