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.11.0
8 files changed
+231−29
README.md+7@@ -103,6 +103,13 @@ the exact fix commands for whatever is missing. Install runs it and warns loudly 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. +When the container cannot run Chrome but Adom Desktop answers, Generate falls back to+rendering through the HOST browser (pup): the host opens the render view via the+workspace proxy URL, screenshots it, and the hero is cropped and stamped container-side+(python3+PIL is the only container requirement on that path). The provenance stamp+records `renderer: local` or `renderer: pup`. Pin a leg with `POST /generate+{"renderer":"pup"}` per request or the `HERO_RENDERER` env server-wide.+ ## Generate The Generate button (or `POST /generate`) renders the current hero server-side with
SKILL.md+14−5@@ -54,9 +54,13 @@ server-side (HERO_STUDIO_REQUIRED) unless the PNG carries the studio provenance ## 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)+Generate renders headless Chrome INSIDE the container the server runs in, and+falls back to the HOST browser via Adom Desktop (pup) when the container+cannot: the renderer ladder is local first (full fidelity), then pup. The+provenance stamp records which leg rendered (`renderer: local|pup`). Light+images (Hydrogen Desktop containers) ship no browser runtime, so on them a+fresh install renders via pup when the desktop relay answers, and otherwise+cannot render at all: `pose` then 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: @@ -74,8 +78,13 @@ Run the printed fix commands yourself: you are already inside the container (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.+then pose.++When doctor reports "ready via pup fallback", Generate works today through the+host browser; installing the local runtime is an upgrade (sharper output, no+dependence on the desktop app running), not a blocker. To exercise or test a+specific leg: `POST /generate {"renderer":"pup"}` (or `"local"`) pins it for+one request; the `HERO_RENDERER` env on the server pins it globally. 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
doctor.mjs+41−9@@ -125,7 +125,8 @@ export async function runChecks() { } // 4. the definitive check: a real headless launch through puppeteer, same- // args as render-hero.mjs. If this passes, Generate will render.+ // args as render-hero.mjs. If this passes, the local renderer works.+ let launchOk = false; if (pptr && chromeRuns) { try { const browser = await pptr.launch({@@ -133,6 +134,7 @@ export async function runChecks() { args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], }); await browser.close();+ launchOk = true; pass('headless launch', 'puppeteer launched Chrome and closed it cleanly'); } catch (e) { fail('headless launch', String(e && e.message || e).split('\n')[0],@@ -141,29 +143,59 @@ export async function runChecks() { : 'read the launch error; check PUPPETEER_CORE/CHROME env overrides and OS libraries'); } }+ const localOk = launchOk;++ // 5. the pup fallback: Generate can render through the HOST browser (Adom+ // Desktop) when the container itself cannot run Chrome (#7). Needs the+ // desktop relay answering plus a proxy URL the host can reach the studio on.+ let pupOk = false;+ const proxied = !!process.env.VSCODE_PROXY_URI;+ const ad = await run('adom-desktop', ['browser_status', '{}'], 12000);+ const adAnswered = ad.ok && !ad.stdout.includes('"error"');+ if (adAnswered && proxied) {+ pupOk = true;+ pass('pup fallback', 'Adom Desktop answers; the host browser can render');+ } else {+ fail('pup fallback', !adAnswered+ ? 'Adom Desktop did not answer browser_status (no host relay from this container)'+ : 'VSCODE_PROXY_URI is not set, so the host browser cannot reach the studio',+ 'optional when the local renderer works; otherwise start Adom Desktop on the host and retry');+ } - // 5. python3 + PIL stamp the provenance metadata after the render+ // 6. python3 + PIL stamp the provenance metadata after the render (and crop+ // the pup screenshot); required on BOTH render paths const pil = await run('python3', ['-c', 'import PIL']);+ const pilOk = pil.ok; 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 };+ const renderer = localOk ? 'local' : (pupOk ? 'pup' : null);+ return { ok: pilOk && !!renderer, renderer, checks }; } export function printReport(report, out = console.error) { for (const c of report.checks) {- out((c.ok ? ' ok ' : ' FAIL ') + c.name + ': ' + c.detail);+ // a failed check that the chosen render path does not need is a miss, not+ // a failure: with the local renderer green, a silent pup relay is fine,+ // and with pup green, the local Chrome stack is optional fidelity.+ out((c.ok ? ' ok ' : (report.ok ? ' miss ' : ' 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.');+ if (report.renderer === 'pup') {+ out('renderer ready via pup fallback: the HOST browser will render Generate.');+ out('Full fidelity comes from the local runtime; the fixes above get you there.');+ } else {+ out('renderer ready (local): 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');+ out('RENDERER_NOT_READY: Generate renders headless Chrome INSIDE this container,');+ out('or falls back to the host browser through Adom Desktop; neither path works');+ out('here. The checks above name what is missing and the exact fix. Run the fix');+ out('commands yourself (you are already in the container; no one needs to SSH');+ out('anywhere), then re-run: hero-studio doctor'); } }
package.json+2−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.10.1",+ "version": "0.11.0", "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": [@@ -88,6 +88,7 @@ "docs/index-cards.png", "docs/component-mode.png", "render-hero.mjs",+ "render-pup.mjs", "screenshots/hero.png" ] }
page.json+2−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.10.1",+ "version": "0.11.0", "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": [@@ -89,6 +89,7 @@ "docs/index-cards.png", "docs/component-mode.png", "render-hero.mjs",+ "render-pup.mjs", "screenshots/hero.png" ], "org": "adom",
pose.mjs+5@@ -52,6 +52,11 @@ if (!preflight.ok) { console.error('human until `hero-studio doctor` passes.'); process.exit(1); }+if (preflight.renderer === 'pup') {+ console.error('note: local renderer unavailable; Generate will render via the HOST browser');+ console.error('(pup fallback). Works, slightly lower fidelity; `hero-studio doctor` lists');+ console.error('what the full local runtime needs.');+} const jf = async (p, opts) => { const r = await fetch(BASE + p, opts);
render-pup.mjsadded+112@@ -0,0 +1,112 @@+#!/usr/bin/env node+// Renders the studio's ?render=1 view via the HOST browser (Adom Desktop pup)+// for containers that cannot run Chrome themselves (hero-studio#7). The host+// opens the render view through the container proxy URL, screenshots it, and+// the #hero region is cropped out container-side with python3+PIL (already+// required for the provenance stamp).+//+// Usage: node render-pup.mjs <url-reachable-from-host> <out.png>+// Exit 0 with the cropped PNG at out.png; nonzero with a plain message.+import { execFile } from 'child_process';+import fs from 'fs';++const [,, url, out] = process.argv;+if (!url || !out) { console.error('usage: render-pup.mjs <url> <out.png>'); process.exit(1); }++const SESSION = 'hero-render';+const sh = (args, timeout = 20000) => new Promise((res) => {+ execFile('adom-desktop', args, { timeout }, (err, stdout, stderr) =>+ res({ ok: !err, out: String(stdout || '') + String(stderr || '') }));+});+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));+const die = (msg) => { console.error('pup renderer: ' + msg); process.exit(1); };++// 1. is the desktop app there at all?+const status = await sh(['browser_status', '{}'], 10000);+if (!status.ok || status.out.includes('"error"')) {+ die('Adom Desktop did not answer browser_status; the host relay is not reachable from this container.');+}++// 2. open or reuse the dedicated render session (never the human's studio+// window). A cold first open can be slow; give it real time.+const hasSession = status.out.includes(SESSION);+const opened = hasSession+ ? await sh(['browser_navigate', JSON.stringify({ sessionId: SESSION, url })], 30000)+ : await sh(['browser_open_window', JSON.stringify({ sessionId: SESSION, profile: SESSION, url })], 60000);+if (!opened.ok) die('could not open or navigate the host render window.');++// 3. wait for the render view. Sentinel-wrapped plain words survive whatever+// JSON wrapping/escaping the CLI applies to eval results.+const evalIn = async (expr, timeout = 15000) => {+ const r = await sh(['browser_eval', JSON.stringify({ sessionId: SESSION, expr })], timeout);+ return r.ok ? r.out : null;+};+let ready = false;+for (let i = 0; i < 30 && !ready; i++) {+ const r = await evalIn("document.readyState === 'complete' && document.querySelector('#hero') ? 'HS-READY' : 'HS-NO'");+ if (r && r.includes('HS-READY')) ready = true;+ else await sleep(1000);+}+if (!ready) die('render view never became ready (#hero not found at ' + url + ').');++// 4. size the window up so the crop clears the gate's 1600px floor where the+// host allows it, then let fonts and images settle.+await evalIn('window.resizeTo(1720, 1180)');+await sleep(2000);++// 5. measure #hero in viewport pixels + devicePixelRatio. Numbers only,+// joined with commas: no quotes means no escaping ambiguity in the transport.+const boxRaw = await evalIn(+ "(function(){var b=document.querySelector('#hero').getBoundingClientRect();" ++ "return 'HSBOX@'+[b.x,b.y,b.width,b.height,window.devicePixelRatio].join(',')+'@XOBSH';})()");+const m = boxRaw && boxRaw.match(/HSBOX@([\d.,\-]+)@XOBSH/);+if (!m) die('could not measure #hero (unexpected browser_eval output).');+const [bx, by, bw, bh, dpr] = m[1].split(',').map(Number);+if (!(bw > 50 && bh > 50)) die('#hero measured degenerate (' + bw + 'x' + bh + ').');++// 6. screenshot the host window. The CLI hands back a PNG either as a path+// (written where the container can read it) or as base64 in the JSON.+const shot = await sh(['browser_screenshot', JSON.stringify({ sessionId: SESSION })], 45000);+if (!shot.ok) die('browser_screenshot failed.');+let srcPng = null;+const pathMatch = shot.out.match(/[^"\s]+\.png/);+if (pathMatch && fs.existsSync(pathMatch[0])) srcPng = pathMatch[0];+if (!srcPng) {+ const b64 = shot.out.match(/"(?:data|base64|image|png)"\s*:\s*"([A-Za-z0-9+/=]{200,})"/);+ if (b64) {+ srcPng = out + '.full.png';+ fs.writeFileSync(srcPng, Buffer.from(b64[1], 'base64'));+ }+}+if (!srcPng) die('browser_screenshot returned neither a readable path nor base64 (raw head: ' + shot.out.slice(0, 200) + ')');++// 7. crop #hero out (viewport rect * dpr, clamped) and sanity-check aspect.+// The screenshot is assumed to be the page viewport; if the host returns a+// native window shot including chrome, the crop will land off and the aspect+// warning below is the tell.+const py = [+ 'import sys',+ 'from PIL import Image',+ 'src, out = sys.argv[1], sys.argv[2]',+ 'x, y, w, h, dpr = [float(v) for v in sys.argv[3:8]]',+ 'im = Image.open(src)',+ 'box = (max(0, round(x*dpr)), max(0, round(y*dpr)), min(im.size[0], round((x+w)*dpr)), min(im.size[1], round((y+h)*dpr)))',+ 'im2 = im.crop(box)',+ 'im2.save(out)',+ 'print("%dx%d" % im2.size)',+].join('\n');+const crop = await new Promise((res) => {+ execFile('python3', ['-c', py, srcPng, out, String(bx), String(by), String(bw), String(bh), String(dpr)],+ { timeout: 30000 }, (err, stdout, stderr) => res({ ok: !err, out: String(stdout || ''), err: String(stderr || '') }));+});+if (srcPng === out + '.full.png') { try { fs.unlinkSync(srcPng); } catch {} }+if (!crop.ok || !fs.existsSync(out)) die('PIL crop failed: ' + crop.err.trim().split('\n').pop());+const [cw, ch] = crop.out.trim().split('x').map(Number);+const aspect = cw / ch;+if (Math.abs(aspect - 1.6) > 0.03) {+ console.error('warning: cropped ' + cw + 'x' + ch + ' (aspect ' + aspect.toFixed(3) + ', want 1.600 +-0.03): the hero may have been clipped by the host viewport; enlarge the host window and regenerate.');+}+if (cw < 1600) {+ console.error('warning: cropped width ' + cw + ' is under the gate\'s 1600px floor; the finalize upscale will cost sharpness. A larger host window or higher devicePixelRatio fixes it.');+}+console.log('OK ' + out + ' (' + cw + 'x' + ch + ' via host browser)');
server.js+48−13@@ -226,6 +226,10 @@ const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'a generate is already running', hint: 'wait for it to finish, then retry' })); }+ return readBody(req, (bodyErr, body) => {+ // body {"renderer":"local"|"pup"} pins one ladder leg for this request+ // (the test hook for the pup path); HERO_RENDERER env pins it server-wide.+ const pinned = (!bodyErr && body && body.renderer) || process.env.HERO_RENDERER; generating = true; const slug = state.mode === 'component' ? ((COMPONENTS.find(c => c.id === state.comp.id) || COMPONENTS[0]).slug)@@ -236,25 +240,56 @@ const server = http.createServer((req, res) => { fs.mkdirSync(outDir, { recursive: true }); const outFile = path.join(outDir, file); logLine('info', 'generate -> ' + file);- const child = spawn(process.execPath, [path.join(__dirname, 'render-hero.mjs'), 'http://localhost:' + PORT + '/?render=1', outFile], {- env: process.env, stdio: ['ignore', 'pipe', 'pipe'],- });- let errBuf = '';- child.stderr.on('data', (c) => { errBuf += c; }); const finish = (payload) => { generating = false; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };- const timer = setTimeout(() => { child.kill(); }, 60000);- child.on('close', (code) => {- 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: "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" });+ // Renderer ladder: local headless Chrome first (full fidelity), then the+ // host browser via pup for containers that cannot run Chrome (#7).+ const attempts = [];+ if (pinned !== 'pup') {+ attempts.push({ script: 'render-hero.mjs', url: 'http://localhost:' + PORT + '/?render=1', label: 'local', timeout: 60000 });+ }+ const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', String(PORT));+ if (pinned !== 'local' && proxyBase) {+ attempts.push({ script: 'render-pup.mjs', url: proxyBase + (proxyBase.endsWith('/') ? '' : '/') + '?render=1', label: 'pup', timeout: 150000 });+ }+ if (!attempts.length) {+ return finish({ ok: false, error: 'no renderer available (pinned to pup but VSCODE_PROXY_URI is not set)', hint: 'drop the renderer pin or run inside a proxied workspace' });+ }+ const failures = [];+ const runRender = (a, cb) => {+ const child = spawn(process.execPath, [path.join(__dirname, a.script), a.url, outFile], {+ env: process.env, stdio: ['ignore', 'pipe', 'pipe'],+ });+ let errBuf = '';+ child.stderr.on('data', (c) => { errBuf += c; });+ const timer = setTimeout(() => { child.kill(); }, a.timeout);+ child.on('close', (code) => {+ clearTimeout(timer);+ if (code === 0 && fs.existsSync(outFile)) return cb(null);+ cb(errBuf.trim().split('\n').pop() || a.label + ' render exited ' + code);+ });+ };+ const tryNext = () => {+ const a = attempts.shift();+ if (!a) {+ logLine('error', 'generate failed: ' + failures.join(' | ').slice(-400));+ return finish({ ok: false, error: failures.join(' | '), hint: "run 'hero-studio doctor': it verifies the local renderer (puppeteer-core, Chrome, launch), the pup fallback, and the PIL stamp step, and prints the exact fix for whatever is missing" }); }+ logLine('info', 'render via ' + a.label);+ runRender(a, (err) => {+ if (err) { failures.push(a.label + ': ' + err); return tryNext(); }+ finalize(a.label);+ });+ };+ tryNext();+ return;++ function finalize(renderedVia) { // 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 } });+ const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, 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 } }); const py = 'import sys\n' + 'from PIL import Image\n' + 'from PIL.PngImagePlugin import PngInfo\n' +@@ -283,8 +318,8 @@ const server = http.createServer((req, res) => { lastToast = { text: 'Hero generated: ' + file, kind: 'success', seq: toastSeq }; finish(lastGen); }+ } });- return; } if (p.startsWith('/generated/')) {