master
John Lauer 2.2.0: dash-screenshot - capture ONLY your dashboard's webview pane in ah, not the whole window, with the tool 6a6d5d3 1mo ago
#!/usr/bin/env python3
"""Capture ONLY a dock dashboard's webview pane inside ah.

A full-window hd_shot spends ~80% of its pixels on ah's chrome. This activates your tab,
reads your iframe's rect from the shell target, takes the shot, derives the scale from
hd_shot's OWN windowOuter metadata (never hardcoded), and crops to your pane.

  python3 shot-webview.py --tab "Parts Search" --slug dock-parts-search \
      --thread "my ai thread" --out /tmp/frame.png
  ... --box 33,160,216,240,red --box 249,160,424,240,lime      # your page's coords
"""
import argparse, json, subprocess, sys

def bridge(verb, payload, thread, reason):
    cmd = ['adom-bridge', '--target', 'AdomLapper', verb, json.dumps(payload),
           '--ai-thread', thread, '--reason', reason]
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
    try:
        return json.loads(p.stdout)
    except Exception:
        sys.exit('bridge call failed: ' + (p.stdout or p.stderr)[:400])

def cdp(js, target, thread, reason):
    r = bridge('hd_api', {"method": "POST", "path": "/cdp-eval",
                          "body": {"target": target, "js": js}}, thread, reason)
    return (r.get('body') or {}).get('value')

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--tab', required=True, help='tab name exactly as the tab strip shows it')
    ap.add_argument('--slug', required=True, help='a unique substring of your iframe src, e.g. your dock slug')
    ap.add_argument('--thread', required=True, help='your ai thread name, for the activity log')
    ap.add_argument('--out', default='/tmp/webview.png')
    ap.add_argument('--pad', type=int, default=0, help='extra px of ah chrome to keep around the pane')
    ap.add_argument('--box', action='append', default=[],
                    help='x,y,w,h,colour in YOUR page coordinates; repeatable')
    a = ap.parse_args()

    # 1. ACTIVATE. An unforegrounded tab reports zero-height rects and SPECIFIED css.
    act = cdp("""(() => {
      const t = [...document.querySelectorAll('.tab-bar .tab')]
        .find(x => ((x.querySelector('span.tab-name')||{}).textContent||'').trim() === %s);
      if (!t) return 'NOTAB';
      if (!t.classList.contains('active')) t.click();
      return 'active';
    })()""" % json.dumps(a.tab), 'shell', a.thread,
        f'activate {a.tab} before measuring, an unforegrounded tab measures wrong')
    if act != 'active':
        sys.exit(f'could not activate tab {a.tab!r}: {act}')

    # 2. RECT, from the shell. Your own page cannot see where it sits in the window.
    raw = cdp("""(() => {
      const f = [...document.querySelectorAll('iframe')]
        .find(x => (x.getAttribute('src')||'').includes(%s));
      if (!f) return 'NOFRAME';
      const r = f.getBoundingClientRect();
      return JSON.stringify({x:Math.round(r.x), y:Math.round(r.y),
                             w:Math.round(r.width), h:Math.round(r.height),
                             shellW:innerWidth, dpr:devicePixelRatio});
    })()""" % json.dumps(a.slug), 'shell', a.thread,
        f'read the {a.slug} pane rect for a webview-only capture')
    if not raw or raw == 'NOFRAME':
        sys.exit(f'no iframe matching {a.slug!r}')
    rect = json.loads(raw)
    if rect['w'] == 0 or rect['h'] == 0:
        sys.exit('pane rect is zero: the tab did not actually foreground')

    # 3. SHOT, then scale from the RETURNED metadata. Never hardcode a scale or an origin.
    shot = bridge('hd_shot', {"region": "full"}, a.thread, f'capture the {a.tab} pane')
    safe = shot.get('localSafePath') or shot.get('safePath')
    if not safe:
        sys.exit('hd_shot did not return localSafePath: ' + json.dumps(shot)[:300])

    from PIL import Image, ImageDraw
    im = Image.open(safe).convert('RGB')
    # Scale from the SHELL'S CSS VIEWPORT WIDTH, because the rect above is in CSS px.
    # NOT from hd_shot's windowOuter: that is the OS window in PHYSICAL px (2582 where the
    # CSS viewport is 1706 at dpr 1.5), and using it produces a crop about 34% too small
    # that still looks like a plausible picture of a dashboard. Measured, not assumed.
    scale = im.width / float(rect['shellW'])

    if a.box:
        d = ImageDraw.Draw(im)
        for spec in a.box:
            x, y, w, h, colour = spec.split(',')
            x, y, w, h = (int(v) for v in (x, y, w, h))
            d.rectangle([(rect['x']+x)*scale, (rect['y']+y)*scale,
                         (rect['x']+x+w)*scale, (rect['y']+y+h)*scale],
                        outline=colour.strip(), width=3)

    p = a.pad
    im.crop((max(0, int((rect['x']-p)*scale)), max(0, int((rect['y']-p)*scale)),
             min(im.width,  int((rect['x']+rect['w']+p)*scale)),
             min(im.height, int((rect['y']+rect['h']+p)*scale)))).save(a.out)

    print(json.dumps({"out": a.out, "paneRect": rect,
                      "scale": round(scale, 4), "fullForShotlog": shot.get('localFullPath')}, indent=1))

if __name__ == '__main__':
    main()