master
Colby Knox Publish 0.20.0 b8ac69a 2mo ago
#!/usr/bin/env node
// Showcase capture helper (hero-studio#41): the studio drives the desktop
// browser itself instead of leaving slot math, DPR math, and cropping to the
// driving AI. Opens a dedicated pup session at the right shape, screenshots,
// verifies the shot came from OUR window (sentinel, the #8 wrong-window
// class), and crops to the slot aspect with PIL.
import { execFile } from 'child_process';
import fs from 'fs';
import { makeBridge } from './bridge.mjs';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const SESSION = 'showcase-shot';

/// captureUrl({ url, selector?, aspect, outFile, target?, thread? })
/// -> { ok, file?, width?, height?, error? }
export async function captureUrl(opts) {
  const { url, selector, aspect, outFile } = opts;
  const bridge = await makeBridge(opts.target || '', opts.thread || '');
  const bad = (error) => ({ ok: false, error });

  const status = await bridge.call('status', {}, 10000);
  if (!status.ok || (status.out + status.errOut).includes('_adom_use_cli_instead')) {
    return bad('the desktop bridge did not answer (' + bridge.generation + '); no host browser to capture with. Capture manually and load it with: hero-studio showcase --file <shot.png>');
  }
  const hasSession = status.out.includes(SESSION);
  const opened = hasSession
    ? await bridge.call('navigate', { sessionId: SESSION, url }, 30000)
    : await bridge.call('open_window', { sessionId: SESSION, profile: SESSION, url }, 60000);
  if (!opened.ok) return bad('could not open or navigate the capture window');

  const evalIn = async (expr, timeout = 15000) => {
    const r = await bridge.call('eval', { 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' ? 'HS-READY' : 'HS-NO'");
    if (r && r.includes('HS-READY')) ready = true;
    else await sleep(1000);
  }
  if (!ready) return bad('page never became ready at ' + url);

  // Size the window so the VIEWPORT approximates the slot aspect (outer size
  // includes chrome; one correction pass using the measured innerHeight).
  await evalIn('window.resizeTo(1400, Math.round(1400/' + aspect + ') + (window.outerHeight - window.innerHeight))');
  await sleep(1500);
  if (selector) {
    await evalIn("(function(){var el=document.querySelector(" + JSON.stringify(selector) + ");if(el)el.scrollIntoView({block:'center'});return 'ok';})()");
    await sleep(500);
  }

  // Sentinel + geometry: viewport size, DPR, and (optionally) the selector
  // rect, joined as plain numbers to survive CLI JSON wrapping.
  const geomRaw = 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);}" +
    (selector
      ? "var el=document.querySelector(" + JSON.stringify(selector) + ");var b=el?el.getBoundingClientRect():{x:0,y:0,width:window.innerWidth,height:window.innerHeight};"
      : "var b={x:0,y:0,width:window.innerWidth,height:window.innerHeight};") +
    "return 'HSCAP@'+[b.x,b.y,b.width,b.height,window.devicePixelRatio].join(',')+'@PACSH';})()");
  const m = geomRaw && geomRaw.match(/HSCAP@([\d.,\-]+)@PACSH/);
  if (!m) return bad('could not measure the capture region');
  const [gx, gy, gw, gh, dpr] = m[1].split(',').map(Number);
  if (!(gw > 50 && gh > 50)) return bad('capture region measured degenerate (' + gw + 'x' + gh + ')');

  const shot = await bridge.call('screenshot', { sessionId: SESSION }, 45000);
  if (!shot.ok) return bad('screenshot failed');
  let srcPng = null;
  const pathMatch = (shot.out + shot.errOut).match(/[^"\s]+\.png/);
  if (pathMatch && fs.existsSync(pathMatch[0])) srcPng = pathMatch[0];
  if (!srcPng) {
    const b64 = (shot.out + shot.errOut).match(/"(?:data|base64|image|png)"\s*:\s*"([A-Za-z0-9+/=]{200,})"/);
    if (b64) { srcPng = outFile + '.full.png'; fs.writeFileSync(srcPng, Buffer.from(b64[1], 'base64')); }
  }
  if (!srcPng) return bad('screenshot returned neither a readable path nor base64');

  // Crop: sentinel-anchored viewport origin, then the region, then a CENTER
  // crop to the slot aspect (never squeeze).
  const py = [
    'import sys',
    'from PIL import Image',
    'src, out = sys.argv[1], sys.argv[2]',
    'x, y, w, h, dpr, aspect = [float(v) for v in sys.argv[3:9]]',
    'im = Image.open(src).convert("RGB")',
    'W, H = im.size',
    '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)',
    'cw, ch = im2.size',
    'want = aspect',
    'if cw / ch > want:',
    '    nw = round(ch * want); x0 = (cw - nw) // 2; im2 = im2.crop((x0, 0, x0 + nw, ch))',
    'elif cw / ch < want:',
    '    nh = round(cw / want); y0 = (ch - nh) // 2; im2 = im2.crop((0, y0, cw, y0 + nh))',
    'im2.save(out)',
    'print("%dx%d" % im2.size)',
  ].join('\n');
  const crop = await new Promise((res) => {
    execFile('python3', ['-c', py, srcPng, outFile, String(gx), String(gy), String(gw), String(gh), String(dpr), String(aspect)],
      { timeout: 30000 }, (err, stdout, stderr) => res({ code: err ? (err.code || 1) : 0, out: String(stdout || ''), err: String(stderr || '') }));
  });
  if (srcPng === outFile + '.full.png') { try { fs.unlinkSync(srcPng); } catch {} }
  if (crop.code === 3) return bad('the screenshot does not contain the capture window (sentinel missing): wrong-window class, shot discarded');
  if (crop.code !== 0 || !fs.existsSync(outFile)) return bad('crop failed: ' + crop.err.trim().split('\n').pop());
  const [cw, ch] = crop.out.trim().split('x').map(Number);
  return { ok: true, file: outFile, width: cw, height: ch };
}