venue_night 121 draws · 51,899 tris · queue 2 · 14 postersdistrict_posters 186 draws · 134,068 tris · queue 2 · 14 postersqueue_night 121 draws · 61,738 tris · queue 2 · 14 postersgig_interior The Exchange Hotel · Screaming Utes · 4-piece · crowd 8 (2 from roster) · 56 draws · 35,216→182,657 tris (pre-load→settled)rain_verandah 116 draws · 51,643 tristram_stop 105 draws · 172,187 tris
\ No newline at end of file
diff --git a/docs/shots/release_v3/district_posters.png b/docs/shots/release_v3/district_posters.png
new file mode 100644
index 0000000..26137d6
Binary files /dev/null and b/docs/shots/release_v3/district_posters.png differ
diff --git a/docs/shots/release_v3/gig_interior.png b/docs/shots/release_v3/gig_interior.png
new file mode 100644
index 0000000..48bb271
Binary files /dev/null and b/docs/shots/release_v3/gig_interior.png differ
diff --git a/docs/shots/release_v3/queue_night.png b/docs/shots/release_v3/queue_night.png
new file mode 100644
index 0000000..fea0ed5
Binary files /dev/null and b/docs/shots/release_v3/queue_night.png differ
diff --git a/docs/shots/release_v3/rain_verandah.png b/docs/shots/release_v3/rain_verandah.png
new file mode 100644
index 0000000..7828775
Binary files /dev/null and b/docs/shots/release_v3/rain_verandah.png differ
diff --git a/docs/shots/release_v3/tram_stop.png b/docs/shots/release_v3/tram_stop.png
new file mode 100644
index 0000000..078ba6d
Binary files /dev/null and b/docs/shots/release_v3/tram_stop.png differ
diff --git a/docs/shots/release_v3/venue_night.png b/docs/shots/release_v3/venue_night.png
new file mode 100644
index 0000000..f88e40f
Binary files /dev/null and b/docs/shots/release_v3/venue_night.png differ
diff --git a/tools/qa/tour_shots.py b/tools/qa/tour_shots.py
new file mode 100644
index 0000000..2b8b8d1
--- /dev/null
+++ b/tools/qa/tour_shots.py
@@ -0,0 +1,176 @@
+#!/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'{s["name"]} {cap}')
+ return (
+ 'PROCITY v3.0 — release tour'
+ ''
+ f'