#!/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''
f'
{cap}