← Commit history

Publish 0.11.0

Colby Knox ·bc8e67c0e6 ·2mo ago ·parent 3acdef2
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/')) {