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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
#!/usr/bin/env node
import { createRequire } from 'module';
// 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';
import { makeBridge } from './bridge.mjs';
const [,, url, out] = process.argv;
if (!url || !out) { console.error('usage: render-pup.mjs <url> <out.png>'); process.exit(1); }
const SESSION = 'hero-render';
// ADOM_TARGET addresses a specific desktop when several share the relay (#24);
// the adapter resolves the binary + verb prefix (post-rename boxes, #31).
const bridge = await makeBridge(process.env.ADOM_TARGET || '');
const sh = async (args, timeout = 20000) => {
// args[0] is a legacy browser_* verb name; map by suffix through the adapter
const suffix = args[0].replace(/^browser_/, '');
const r = await bridge.call(suffix, args[1] ? JSON.parse(args[1]) : {}, timeout);
return { ok: r.ok, out: r.out + r.errOut };
};
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);
let tracesReady = false;
for (let i = 0; i < 30 && !tracesReady; i++) {
const r = await evalIn("window.__traceError ? 'HS-TRACE-ERROR' : window.__tracePending ? 'HS-TRACE-WAIT' : 'HS-TRACE-READY'");
if (r && r.includes('HS-TRACE-ERROR')) die('circuit composition failed; read the studio console');
if (r && r.includes('HS-TRACE-READY')) tracesReady = true;
else await sleep(1000);
}
if (!tracesReady) die('circuit composition did not settle before export');
// 5. measure #hero in viewport pixels + devicePixelRatio. Numbers only,
// joined with commas: no quotes means no escaping ambiguity in the transport.
// Also paint a sentinel square in the viewport's top-left corner: the crop
// step verifies it is present in the screenshot, proving the relay shot THIS
// window and not whatever was focused (hero-studio#8: a relay that ignores
// sessionId captures the human's interactive studio, tooltips and all).
const boxRaw = await evalIn(
"(function(){var s=document.getElementById('hs-sentinel');if(!s){s=document.createElement('div');" +
"s.id='hs-sentinel';s.style.cssText='position:fixed;top:0;left:0;width:16px;height:16px;" +
"background:#ff00fe;z-index:2147483647';document.body.appendChild(s);}" +
"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. verify the sentinel, then crop #hero out (viewport rect * dpr, clamped).
// Both checks are HARD failures: a hero that would fail the registry gate
// must never be handed to the human as a success (hero-studio#8).
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).convert("RGB")',
'W, H = im.size',
'# locate the 16px #ff00fe sentinel painted at viewport (0,0). Its found',
'# position IS the viewport origin inside the screenshot: a native window',
'# shot includes chrome above the viewport, which would silently shift the',
'# crop while keeping its aspect, so the origin must come from the pixels.',
'ox = oy = -1',
'strip = im.crop((0, 0, min(W, 240), min(H, 480)))',
'px = strip.load()',
'for yy in range(strip.size[1]):',
' for xx in range(strip.size[0]):',
' r, g, b = px[xx, yy]',
' if r > 200 and g < 80 and b > 200:',
' ox, oy = xx, yy',
' break',
' if ox >= 0:',
' break',
'if ox < 0:',
' print("NO-SENTINEL"); sys.exit(3)',
'box = (max(0, ox + round(x*dpr)), max(0, oy + round(y*dpr)), min(W, ox + round((x+w)*dpr)), min(H, oy + round((y+h)*dpr)))',
'im2 = im.crop(box)',
'aspect = im2.size[0] / im2.size[1]',
'if abs(aspect - 1.6) > 0.03:',
' print("BAD-ASPECT %dx%d" % im2.size); sys.exit(4)',
'im2.save(out)',
'print("%dx%d" % im2.size)',
].join('\n');
const crop = await new Promise((res) => {
const PY = createRequire(import.meta.url)('./platform.cjs').pythonWithPillow() || { cmd: 'python3', args: [] };
execFile(PY.cmd, [...PY.args, '-c', py, srcPng, out, String(bx), String(by), String(bw), String(bh), String(dpr)],
{ timeout: 30000 }, (err, stdout, stderr) => res({ code: err ? (err.code || 1) : 0, out: String(stdout || ''), err: String(stderr || '') }));
});
if (srcPng === out + '.full.png') { try { fs.unlinkSync(srcPng); } catch {} }
if (crop.code === 3) {
die('the screenshot does NOT contain the render window (sentinel missing: ' + crop.out.trim() + '). ' +
'The relay captured a different window than the hero-render session, so the shot was discarded; ' +
'this is the wrong-window class from hero-studio#8. Report which adom-desktop build this is.');
}
if (crop.code === 4) {
die('cropped ' + crop.out.trim().replace('BAD-ASPECT ', '') + ', not 16:10 within 0.03: the hero was clipped by the host viewport. ' +
'Enlarge the host render window and regenerate; refusing to ship a hero the registry would reject.');
}
if (crop.code !== 0 || !fs.existsSync(out)) die('PIL crop failed: ' + crop.err.trim().split('\n').pop());
const [cw, ch] = crop.out.trim().split('x').map(Number);
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)');