#!/usr/bin/env python3 """PROCITY Lane F — the v3.0 release money-shot tour (round 15 · ?gigs=1). One deterministic run. Captures the release set into docs/shots/release_v3/ (+ a contact.html), grouped by the flags each shot needs, all from seed 20261990 (deterministic, reproducible — never hand-cropped): • venue_night — lit pub frontage + streetlamp pools + posters at night (?gigs=1) • district_posters — the spine at night: poster poles + lit frontages receding (?gigs=1) • queue_night — JOHN'S HERO: close on the pub door at 'on', queue + marquee + A's now-visible poster • gig_interior — the 4-piece with real instruments + a roster crowd (continuity) (?gigs=1) • rain_verandah — a wet night outside the lit video-shop window (?weather=rain&winmap=1) • tram_stop — the bus shelter the tram pauses at (?tram=1) Discipline (ROUND15 ledger #5 + B's note): every shot is measured SETTLED — after the async instrument GLBs / poster textures / chunk stream land (shoot-twice), from a clean per-group load (no orphan-chunk inflation); the queue is driven LIVE (rAF is throttled in automation, so we run the animate loop + admit explicitly rather than trust auto-drain). Run: tools/.venv/bin/python tools/qa/tour_shots.py [--seed N] Needs the Playwright venv + Lane B's ?dbg=1 hook (incl. queue_night). Writes full-frame 1280x720 PNGs. """ import sys, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = 8130 HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3' if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) # shot groups — each is one clean page load with the flags that shot needs (deterministic, same seed). GROUPS = [ {'flags': 'gigs=1', 'street': ['venue_night', 'district_posters', 'queue_night'], 'interior': True}, {'flags': 'weather=rain&tram=1&winmap=1', 'street': ['rain_verandah', 'tram_stop'], 'interior': False}, ] # settle + re-pose a street bookmark once its live/async content (queue, instrument GLBs, poster textures, # streamed chunks) has landed, then report SETTLED stats. STREET_JS = r""" async (name) => { const P = window.PROCITY, D = window.DBG; D.shot(name); // initial pose + segment (2 renders) for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r)); // animate loop: queue spawns const q = (P.gigs && P.venueQueues) ? P.venueQueues.get(P.gigs.venueShopIds[0]) : null; await new Promise(r => setTimeout(r, 1400)); // async GLB / poster / chunk settle const stat = D.shot(name); // RE-POSE now that everything is loaded → the settled frame const queue = (P.gigs && P.queueCountOf) ? P.queueCountOf(P.gigs.venueShopIds[0]) : null; const posters = (P.plan.posters || []).length; return { name, draws: stat.drawCalls, tris: stat.tris, queue, posters }; } """ INTERIOR_JS = r""" async () => { const P = window.PROCITY, D = window.DBG, THREE = P.THREE; const id = P.gigs.venueShopIds[0]; D.setSegment(5); // drive the queue live + admit a couple so the interior crowd carries roster identities (continuity) for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r)); const q = P.venueQueues && P.venueQueues.get(id); if (q) for (let i = 0; i < 3 && q.count() > 0; i++) q.admitOne(); D.enterShop(id); await new Promise(r => setTimeout(r, 900)); const room = P.interiorMode.current; if (!room) return { ok: false }; const st = room.stage; P.camera.position.set(st.x + 0.2, 1.6, room.dims.D / 2 - 1.2); P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z)); // shoot-twice / settled discipline (ledger #5): immediate vs after the instrument GLBs land P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera); const trisImmediate = P.renderer.info.render.triangles; await new Promise(r => setTimeout(r, 1600)); P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera); const info = P.interiorMode.crewInfo; return { ok: true, draws: P.renderer.info.render.calls, trisImmediate, trisSettled: P.renderer.info.render.triangles, band: info ? info.band : 0, crowd: info ? info.crowd : 0, fromRoster: info ? info.fromRoster : 0, venue: (P.plan.shops.find(s => s.id === id) || {}).name, bandName: P.gigs.bandNameOf(id) }; } """ def port_up(port): with socket.socket() as s: s.settimeout(0.4) return s.connect_ex(('127.0.0.1', port)) == 0 def ensure_server(): if port_up(PORT): return None proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(50): if port_up(PORT): return proc time.sleep(0.1) proc.terminate(); sys.exit(f'could not start http.server on :{PORT}') def boot(pg, flags): pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&{flags}') pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") try: pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)', timeout=15000) except Exception: print(' ! fleet never readied — shooting placeholders') def contact_html(shots): cards = [] for s in shots: cap = s['caption'] cards.append( f'
{s[' f'
{s["name"]}
{cap}
') return ( 'PROCITY v3.0 — release tour' '' f'

PROCITY v3.0 — release money-shot tour · seed {SEED} · settled measurements

' f'
{"".join(cards)}
') def main(): try: from playwright.sync_api import sync_playwright except ImportError: sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') print(f"\033[1mv3.0 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3/)") OUTDIR.mkdir(parents=True, exist_ok=True) srv = ensure_server() shots, errs_total = [], 0 try: with sync_playwright() as p: b = p.chromium.launch() for grp in GROUPS: pg = b.new_page(viewport={'width': 1280, 'height': 720}) errs = [] pg.on('pageerror', lambda e: errs.append(str(e))) boot(pg, grp['flags']) for name in grp['street']: stat = pg.evaluate(STREET_JS, name) out = OUTDIR / f'{name}.png' pg.screenshot(path=str(out)) cap = f"{stat['draws']} draws · {stat['tris']:,} tris" if stat.get('queue') is not None: cap += f" · queue {stat['queue']}" if name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters" shots.append({'name': name, 'file': f'{name}.png', 'caption': cap}) print(f" \033[32m●\033[0m {name}: {cap}") if grp['interior']: info = pg.evaluate(INTERIOR_JS) if info.get('ok'): out = OUTDIR / 'gig_interior.png' pg.screenshot(path=str(out)) cap = (f"{info['venue']} · {info['bandName']} · {info['band']}-piece · crowd {info['crowd']} " f"({info['fromRoster']} from roster) · {info['draws']} draws · " f"{info['trisImmediate']:,}→{info['trisSettled']:,} tris (pre-load→settled)") shots.append({'name': 'gig_interior', 'file': 'gig_interior.png', 'caption': cap}) print(f" \033[32m●\033[0m gig_interior: {cap}") else: print(" \033[31m✗\033[0m gig_interior: could not stage") errs_total += len(errs) if errs: print(f" ! {len(errs)} page error(s) in [{grp['flags']}]: {errs[0][:100]}") pg.close() b.close() finally: if srv: srv.terminate() (OUTDIR / 'contact.html').write_text(contact_html(shots)) print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3/ (+ contact.html) · {errs_total} page errors") sys.exit(1 if errs_total or len(shots) < 6 else 0) if __name__ == '__main__': main()