#!/usr/bin/env python3 """PROCITY Lane D — R41 §41.3 acceptance shots: THE STREET STOPS BEING ONE PERSON COPY-PASTED. tools/.venv/bin/python tools/qa/r41_shots.py [--seed N] [--outdir DIR] [--soak MS] Two reproducible frames, both chosen by MEASUREMENT rather than by hand: 1. r41_street_postures.jpg the camera pose comes from geometry, not taste: the bench station with the most patronage door points around it (the one block where BOTH R41 street states can fire in a single frame), stood off on the carriageway and occlusion-raycast before it is accepted. Then it HOLDS and waits — postures run on 6-20 s dwells, so the honest way to shoot the busy moment is to stand still until it happens. Every candidate figure is ray-tested from the lens too: "inside the frustum" is not "in the picture", and three earlier runs reported 3-6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. 2. r41_browse_interior.jpg the shop with the most patronage occupants that actually has browse points, entered through the shell's own enterShop and framed from Lane C's room.spawn — the view the game gives you walking in. The browser rigs are the peds who ducked in off the street, now playing browse.glb instead of the shopkeeper's idle. [R42 §42.4] This arm used to scan the candidates, exit, and re-enter the winner — ~25 s later, by which time its patronage occupants had walked back out and the room rebuilt empty. It is now shot IN PLACE, inside the winning room, the moment a candidate beats the incumbent; the occupancy decay it used to lose to is printed in the sidecar so the old failure stays legible. Every figure in each frame is measured for STATURE (feet->crown span off the posed skeleton) and reported as the R10 no-giants line, written into a sidecar .txt beside each shot. The check is height-RELATIVE on purpose (see human_line): fails on a giant (>2.0 m) or a fold (<55% of that citizen's own nominal height), not on an absolute seated band that a wall lean legitimately breaks. Fresh headless context, own no-store server (this project's documented ES-module cache burn). """ import sys, os, time, json, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = int(os.environ.get('PROCITY_R41_SHOT_PORT', '8983')) HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 SOAK = 60000 OUTDIR = ROOT / 'docs' / 'shots' / 'laneD' if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) if '--soak' in sys.argv: SOAK = int(sys.argv[sys.argv.index('--soak') + 1]) if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1]) LO, HI = 1.4, 2.0 # R10 no-giants standing band SEAT_LO, SEAT_HI = 0.9, 1.5 # R16 seated band NOSTORE = r''' import sys, http.server, functools class H(http.server.SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') super().end_headers() def log_message(self, *a): pass http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever() ''' # ── PLACE: stand on the road opposite the bench nearest the retail heart ────────────────────────── # Deterministic and re-findable: the town anchor (DBG's densest-shop-cluster centroid) picks the # block, the sim's OWN benchStationsFor picks the bench, and the camera stands out on the carriageway # so the footpath reads unobstructed. Standing ON the footpath was tried first and framed a shopfront # wall — the peds are at the back of a 3.5 m verge, so there is no room to back off beside them. PLACE = r""" async () => { const P = window.PROCITY, C = P.citizens; const THREE = await import('three'); const mod = await import('./js/citizens/sim.js'); const V3 = P.scene.position.constructor; const ray = new THREE.Raycaster(); ray.far = 40; // Score every bench station by how many patronage door points sit within 14 m: that is the block // where BOTH R41 street states fire (bench sits at the station, shopfront leans off those doors), // so it is the one place a single frame can hold both. Camera 7 m out on the carriageway at a 45° // three-quarter, looking a little further down the footpath. Occlusion-raycast before accepting — // an unchecked pose put the camera inside a building twice, high score and a grey wall. const doors = []; if (C.shopsByChunk) for (const list of C.shopsByChunk.values()) for (const d of list) doors.push(d); const clear = (px, pz, tx, tz) => { const d = Math.hypot(tx - px, tz - pz); ray.set(new V3(px, 1.6, pz), new V3((tx - px) / d, 0, (tz - pz) / d)); let h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh && !x.object.isSprite); if (h.length && h[0].distance < d - 0.8) return false; for (const [ax, az] of [[1,0],[-1,0],[0,1],[0,-1]]) { ray.set(new V3(px, 1.6, pz), new V3(ax, 0, az)); h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh); if (h.length && h[0].distance < 1.3) return false; } return true; }; const out = []; for (let i = 0; i < C.edges.length; i++) { const e = C.edges[i]; if ((e.width || 4) < 10) continue; const L = e.len, ux = e.ux, uz = e.uz, nxE = -uz, nzE = ux; for (const st of mod.benchStationsFor(e)) { let nd = 0; for (const d of doors) if (Math.hypot(d.x - st.x, d.z - st.z) < 14) nd++; if (!nd) continue; // which side of the centreline the bench is on → put the camera on the road, same side const bd = Math.hypot(st.x - (e.A.x + ux * st.s), st.z - (e.A.z + uz * st.s)) || 1; const nx = (st.x - (e.A.x + ux * st.s)) / bd, nz = (st.z - (e.A.z + uz * st.s)) / bd; // ON THE CARRIAGEWAY at a quarter of the half-width from the centreline, 8 m back along the // street, aimed square at the bench. This is the geometry every readable PROCITY street shot // has: the footpath fills the mid-ground at 12-15 m, the verandah is above the heads, and // nothing can get between the lens and the subject. Every closer variant tried (4 m, 7 m off // the ped) put the camera under an awning or inside a shopfront window box. const back = Math.min(bd * 0.55, 9); // out toward the road, but stay on the deck for (const sgn of [1, -1]) { const px = st.x - nx * back - ux * 5 * sgn, pz = st.z - nz * back - uz * 5 * sgn; out.push({ nd, px, pz, yaw: Math.atan2(-(st.x - px), -(st.z - pz)), bx: st.x, bz: st.z }); } } } out.sort((p, q) => q.nd - p.nd); const keep = []; for (const c of out) { if (keep.length >= 6) break; if (clear(c.px, c.pz, c.bx, c.bz)) keep.push(c); } return { ok: keep.length > 0, cands: keep, stations: out.length / 2 | 0 }; } """ # ── FRAME: how good is the view from RIGHT HERE, right now — states and clips inside a tight box ─── FRAME = r""" async () => { const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor; const THREE = await import('three'); const cam = P.camera; cam.updateMatrixWorld(true); // Per-ped VISIBILITY raycast. "Inside the frustum" is not "in the picture": three frames running // measured 3-6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. A ped hidden // behind a verandah post, a tree or a shopfront is not evidence of anything, so each one is // ray-tested from the lens and only the ones that can actually be SEEN are counted. const ray = new THREE.Raycaster(); ray.far = 30; const visible = (c) => { const dx = c.x - cam.position.x, dz = c.z - cam.position.z, d = Math.hypot(dx, dz); ray.set(new V3(cam.position.x, 1.35, cam.position.z), new V3(dx / d, 0, dz / d)); const h = ray.intersectObject(P.scene, true) .filter(x => !x.object.isSkinnedMesh && !x.object.isSprite && !x.object.isPoints); return !(h.length && h[0].distance < d - 0.5); }; const v = new V3(); const rows = []; for (const c of C.activeCitizens()) { if (!c.actor || c.actorKind !== 'rig') continue; const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z); if (d < 3.0 || d > 20) continue; // close enough to READ v.set(c.x, 1.0, c.z).project(cam); if (v.z > 1 || Math.abs(v.x) > 0.62 || Math.abs(v.y) > 0.55) continue; // comfortably in shot if (!visible(c)) continue; // and not behind a post const posedNow = c.loiter > 0; rows.push({ id: c.id, d: +d.toFixed(1), state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit' : (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'), clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look' : (posedNow ? c.posture.idle : c.posture.walk) }); } const states = new Set(rows.map(r => r.state)), clips = new Set(rows.map(r => r.clip)); const posed = rows.filter(r => r.state === 'bench-sit' || r.state === 'lean').length; return { n: rows.length, states: [...states], clips: clips.size, posed, score: clips.size * 100 + rows.length * 25 + posed * 350 }; } """ # ── measure every figure in the frame (the human-sized line) ────────────────────────────────────── MEASURE = r""" () => { const P = window.PROCITY, C = P.citizens; const V3 = P.scene.position.constructor; const cam = P.camera; cam.updateMatrixWorld(true); const v = new V3(); const rows = []; for (const c of C.activeCitizens()) { if (!c.actor || c.actorKind !== 'rig') continue; const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z); if (d < 2.2 || d > 26) continue; v.set(c.x, 1.0, c.z).project(cam); if (v.z > 1 || Math.abs(v.x) > 0.95 || Math.abs(v.y) > 0.95) continue; let lo = 1e9, hi = -1e9; c.actor.inner.updateWorldMatrix(true, true); c.actor.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } }); // NB `c.sit` / `c.glance` are LATCHED flags (R17/R29 set them at a node and never clear them); // the ped is only actually posed while `c.loiter > 0`. Reading them raw mislabels a walker as // seated — which is exactly how this harness first reported a 1.63 m "seated" figure. const posedNow = c.loiter > 0; rows.push({ id: c.id, d: +d.toFixed(1), state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit' : (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'), clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look' : (posedNow ? c.posture.idle : c.posture.walk), seated: !!c.bench || !!(posedNow && c.sit), h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3) }); } rows.sort((a, b) => a.d - b.d); return { rows, info: window.DBG.info(), clips: C.clipStats() }; } """ # ── the browse arm. [Lane D R42 §42.4] SHOOT THE WINNER WHILE IT IS STILL THE WINNER. ───────────── # The R41 shape of this arm was scan-then-re-enter: walk up to 8 occupied shops at ~3.1 s each, keep # the id of the one with the most browsers, then `enterShop` it again and shoot. That is # deterministically red, and the reason is measured below and printed with every run — the browsers # are a function of LIVE patronage occupancy, and over the ~25 s the scan costs, the occupants of the # shop picked first (the fullest one, which is also the one whose visitors are furthest through their # dwell) have walked back out. The re-entry then builds the same room with nobody in it and the arm # reports "no browsers" about a game that was working. F wired it warn-level with that reason at the # call site; this is the fix. # # The fix is not a longer wait or a re-roll — it is to stop leaving. Python now drives the scan one # candidate at a time and takes the frame + the measurements + the JPEG INSIDE the room, the moment a # candidate beats the incumbent. Nothing is re-entered, so nothing can go stale between choosing and # shooting, and the scan keeps its original "best of several, not first that works" property. INT_CANDS = r""" () => { const P = window.PROCITY, C = P.citizens, D = window.DBG; D.setSegment(2); // Shops that currently hold patronage occupants — occupancy is what puts BROWSERS on the floor (F // stands one per browse point per occupant). Not every room type HAS browse points (a market stall // has one, an op-shop three), so several are tried and the BEST kept rather than the first that // works: taking the first gave a one-browser stall on one run and a three-browser op-shop on the next. return (P.plan.shops || []) .map((s) => ({ id: s.id, name: s.name, type: s.type, count: C.occupancyOf(s.id).count })) .filter((s) => s.count > 0).sort((a, b) => b.count - a.count); } """ INT_TRY = r""" async (id) => { const P = window.PROCITY, D = window.DBG, C = P.citizens; D.enterShop(id); await new Promise(r => setTimeout(r, 2600)); // room build + browser spawn + lazy browse.glb const room = P.interiorMode.current; return { id, inside: !!room, browsePoints: room ? (room.browsePoints || []).length : 0, browsers: ((P.interiorMode.keepers || {}).keepers || []).filter((k) => k.browse).length, occupants: C.occupancyOf(id).count }; } """ INT_LEAVE = "async () => { window.DBG.exitShop(); await new Promise(r => setTimeout(r, 500)); }" OCC_OF = "(id) => window.PROCITY.citizens.occupancyOf(id).count" INT_FRAME = r""" () => { const P = window.PROCITY, V3 = P.scene.position.constructor; const room = P.interiorMode.current; const km = P.interiorMode.keepers; const figs = (km ? km.keepers : []).map(k => k.actor.fig); if (!figs.length) return { ok: false, why: 'no keeper/browser figures' }; // Stand where the PLAYER stands walking in (Lane C's room.spawn) and look at the centroid of the // shop's people — so the frame is the view the game actually gives you, not a staged angle. Backed // off toward the spawn wall if that puts the camera on top of somebody. let mx = 0, mz = 0; for (const f of figs) { mx += f.position.x; mz += f.position.z; } mx /= figs.length; mz /= figs.length; const sp = room.spawn || { x: 0, z: 0 }; let px = sp.x, pz = sp.z; let d = Math.hypot(mx - px, mz - pz); if (d < 2.6) { const k = 2.6 / (d || 1); px = mx - (mx - px) * k; pz = mz - (mz - pz) * k; d = 2.6; } P.camera.position.set(px, 1.62, pz); P.camera.lookAt(new V3(mx, 1.15, mz)); P.camera.updateMatrixWorld(true); P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera); // who is actually inside the frame const v = new V3(); let seen = 0; for (const f of figs) { v.set(f.position.x, 1.0, f.position.z).project(P.camera); if (v.z <= 1 && Math.abs(v.x) < 0.9 && Math.abs(v.y) < 0.9) seen++; } return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+mx.toFixed(2), +mz.toFixed(2)], figures: figs.length, inFrame: seen, draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles }; } """ INT_MEASURE = r""" () => { const P = window.PROCITY, V3 = P.scene.position.constructor; const km = P.interiorMode.keepers; const rows = []; for (const k of (km ? km.keepers : [])) { const a = k.actor; if (!a.inner) { rows.push({ kind: k.browse ? 'browser' : 'keeper', placeholder: true }); continue; } let lo = 1e9, hi = -1e9; a.inner.updateWorldMatrix(true, true); a.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } }); rows.push({ kind: k.browse ? 'browser' : 'keeper', shopId: k.shopId, type: k.type, h: +(a.height || 0).toFixed(3), clip: (a.mixer && a.mixer._actions || []).filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name)[0] || null, pending: k.want || null, stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3), pos: [+a.fig.position.x.toFixed(2), +a.fig.position.z.toFixed(2)] }); } return { rows, info: window.DBG.info() }; } """ def port_up(port): with socket.socket() as s: s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0 def serve(): p = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(80): if port_up(PORT): return p time.sleep(0.1) p.terminate(); raise SystemExit(f'could not serve on :{PORT}') def human_line(rows): """The R10 no-giants line — measured, and measured against the law it is actually about. An absolute seated band was tried first and it was the WRONG instrument for R41: the library's four sits run from bolt-upright-in-a-chair to slumped, and its four wall leans take 15-25% off a standing crown by construction (you are leaning BACK). A 1.32 m leaner is not a defect, and a fixed [0.9,1.5] seated band called it one. What the R10 law forbids is a GIANT, and what R16 forbids is a FOLD (Hips.quaternion laying the body flat, head down at hip height). So: stature is reported for every figure with its ratio to that citizen's own nominal height, and the gate fails on stature > 2.0 m (giant), stature < 0.55 x height (folded), or a nominal height outside the seeded [1.4, 2.0] range.""" out, bad = [], [] for r in rows: if r.get('placeholder'): continue h = r.get('h') ratio = (r['stature'] / h) if h else None if r['stature'] > HI: bad.append(f"{r.get('id', r.get('kind'))} GIANT {r['stature']}m") elif h and r['stature'] < 0.55 * h: bad.append(f"{r.get('id', r.get('kind'))} FOLDED {r['stature']}m of {h}m") elif h and not (LO <= h <= HI): bad.append(f"{r.get('id', r.get('kind'))} nominal height {h}m out of [{LO},{HI}]") out.append(f"{r.get('state', r.get('kind'))} {r['stature']}m" + (f" ({ratio:.0%} of its {h}m nominal)" if ratio else "")) verdict = 'ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal)' if not bad else 'FAIL: ' + ', '.join(bad) return (f"HUMAN-SIZED LINE — {len(out)} figures, stature (feet\u2192crown) measured off the posed " f"skeleton: {' \u00b7 '.join(out)}. {verdict}."), not bad def main(): OUTDIR.mkdir(parents=True, exist_ok=True) from playwright.sync_api import sync_playwright srv = serve() rc = 0 try: with sync_playwright() as p: b = p.chromium.launch() pg = b.new_page(viewport={'width': 1280, 'height': 720}) errs = [] pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) pg.on('pageerror', lambda e: errs.append(str(e))) pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1') pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=40000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") pg.evaluate("() => { window.DBG.setSegment(2); window.DBG.shot('crossroads_busy'); }") print(f'soaking {SOAK} ms so the crowd reaches its posture steady state…') pg.wait_for_timeout(SOAK) # ── shot 1: the street ──────────────────────────────────────────────────────────────── # The camera pose is chosen ONCE, from geometry (the bench station with the most shop # doors around it — the one block where both R41 street states can fire in one frame) and # occlusion-raycast before it is accepted. Then it HOLDS and waits: postures come and go # on 6–20 s dwells, so the honest way to shoot the busy moment is to stand still until it # happens. Chasing subjects with a moving camera was tried first and lost them to # rig-reacquisition every time. place = pg.evaluate(PLACE) print(f"place: {place.get('stations', 0)} bench stations near shop doors; " f"{len(place.get('cands', []))} clear camera poses") best, m = None, None for cand in place.get('cands', [])[:4]: for _ in range(2): pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']]) pg.wait_for_timeout(2500) for _ in range(14): # ~35 s of holding this pose fr = pg.evaluate(FRAME) if fr['n'] and (not best or fr['score'] > best['score']): mm = pg.evaluate(MEASURE) pg.screenshot(path=str(OUTDIR / 'r41_street_postures.jpg'), type='jpeg', quality=90) best, m = dict(fr, **{k: cand[k] for k in ('px', 'pz', 'yaw')}), mm print(f" ({cand['px']:.0f},{cand['pz']:.0f}) score {fr['score']} n={fr['n']} " f"clips={fr['clips']} posed={fr['posed']} states={fr['states']}") if best and best['posed'] >= 2 and best['clips'] >= 4: break pg.wait_for_timeout(2500) pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']]) if best and best['posed'] >= 2 and best['clips'] >= 4: break if not best: print(' never framed anybody'); rc = 1 else: line, ok = human_line(m['rows']) name = 'r41_street_postures.jpg' states = {} for r in m['rows']: states[r['state']] = states.get(r['state'], 0) + 1 cap = (f"PROCITY R41 §41.3 — {name}\n" f"seed {SEED} · synthetic · MIDDAY · camera ({best['px']:.1f}, {best['pz']:.1f}) yaw {best['yaw']:.3f}\n" f"{len(m['rows'])} near-tier rigs in frame · states {states} · " f"{len(set(r['clip'] for r in m['rows']))} distinct clips: " f"{', '.join(sorted(set(r['clip'] for r in m['rows'])))}\n" f"draws {m['info']['drawCalls']} / budget {m['info']['budget']['draws']} · tris {m['info']['tris']}\n" f"clips resident: {m['clips']['groups']} groups / {m['clips']['clips']} clips / {m['clips']['bytes']} B\n" f"{line}\n") (OUTDIR / name.replace('.jpg', '.txt')).write_text(cap) print(cap) if not ok: rc = 1 # ── shot 2: a browsing interior (shot from inside — see INT_CANDS) ──────────────────── cands = pg.evaluate(INT_CANDS) print(f'interior: {len(cands)} occupied shops; scanning up to 8, shooting in place') best, tried = None, [] for s in cands[:8]: t = pg.evaluate(INT_TRY, s['id']) tried.append({**s, **{k: t[k] for k in ('browsers', 'browsePoints', 'occupants')}}) print(f" {s['name'][:28]:<28} ({s['type']:<8}) occ {t['occupants']}/{s['count']} · " f"{t['browsePoints']} browse points · {t['browsers']} browsers") if t['inside'] and t['browsePoints'] and t['browsers'] and (not best or t['browsers'] > best['browsers']): fr = pg.evaluate(INT_FRAME) if fr.get('ok'): im = pg.evaluate(INT_MEASURE) pg.screenshot(path=str(OUTDIR / 'r41_browse_interior.jpg'), type='jpeg', quality=90) best = {**t, 'shop': s, 'frame': fr, 'meas': im} print(f" → shot in place: {fr['figures']} figures, {fr['inFrame']} in frame, " f"{fr['draws']} draws") if best and best['browsers'] >= 3: break pg.evaluate(INT_LEAVE) if not best: print(f' no occupied shop had browse points with browsers; tried {tried[:6]}'); rc = 1 else: # The R41 bug, measured rather than asserted: what the OLD scan-then-re-enter path would # have walked back into. If this has decayed toward 0 the arm was red for exactly that # reason, and shooting in place is what fixed it. stale = pg.evaluate(OCC_OF, best['shop']['id']) fr, im = best['frame'], best['meas'] browsers = [r for r in im['rows'] if r['kind'] == 'browser'] line, ok = human_line(im['rows']) name = 'r41_browse_interior.jpg' cap = (f"PROCITY R41 §41.3 — {name}\n" f"seed {SEED} · {best['shop']['name']} ({best['shop']['type']}) · " f"{best['occupants']} patronage occupants at entry · {best['browsePoints']} browse points · " f"{len(browsers)} browsers\n" f"shot IN PLACE (R42 §42.4): the frame is taken inside the winning room during the " f"scan, never on a re-entry. Occupancy of that shop at entry {best['occupants']} → " f"{stale} by the end of the scan — the decay the old re-entry path walked into.\n" f"figures: {json.dumps(im['rows'])}\n" f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n" f"{line}\n") (OUTDIR / name.replace('.jpg', '.txt')).write_text(cap) print(cap) if not ok or not browsers: rc = 1 if errs: print('CONSOLE ERRORS:', errs[:6]); rc = 1 b.close() finally: srv.terminate() print('\033[32m● shots written\033[0m' if rc == 0 else '\033[31m● problems — see above\033[0m') return rc if __name__ == '__main__': sys.exit(main())