master
Colby Knox Merge PR #181: Add circuit generators that follow rendered text contours b00fdcb 18d ago
#!/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)');