""" UX Crime Scene — Gradio frontend (real 3-step wizard). Local dev: export MODAL_ENDPOINT_URL="https://--ux-crime-scene-qwen-web.modal.run" python app.py """ from __future__ import annotations import base64 import html import io import os import time import traceback import urllib.parse from pathlib import Path import gradio as gr from PIL import Image from annotate import annotate from detective import (CaseFile, investigate_agentic, save_case, fetch_case, publish_case, fetch_board) from extras import interrogate, prosecute, reconstruct_all, synthesize_voice, voice_script # Optional power-ups: shown only when their backend is configured (graceful). HAS_VOICE = bool(os.environ.get("TTS_ENDPOINT_URL", "").strip()) HAS_FLUX = bool(os.environ.get("FLUX_ENDPOINT_URL", "").strip()) HERE = Path(__file__).parent ASSETS = HERE / "assets" # Public URL of the tool (set this to the Space URL in production). TOOL_URL = os.environ.get("PUBLIC_URL", "").strip() or \ "https://huggingface.co/spaces/build-small-hackathon/ux-crime-scene" ASSET_VARS = { "paper.jpg": "--asset-paper", "emblem.png": "--asset-emblem", "magnifier.png": "--asset-magnifier", "grade_seal.png": "--asset-grade", "stamp_confidential.png": "--asset-stamp-confidential", "stamp_guilty.png": "--asset-stamp-guilty", "desk_topdown.jpg": "--asset-desk", } _MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"} STATIC_CSS = (HERE / "styles.css").read_text(encoding="utf-8") def esc(v) -> str: return html.escape(str(v)) def _build_asset_style() -> tuple[str, dict[str, bool]]: overrides, present = [], {} for fname, var in ASSET_VARS.items(): path = ASSETS / fname ok = path.exists() present[fname] = ok if ok: raw = path.read_bytes() mime = _MIME.get(path.suffix.lower(), "image/png") b64 = base64.b64encode(raw).decode("ascii") overrides.append(f"{var}: url('data:{mime};base64,{b64}');") if present.get("emblem.png"): overrides.append("--emblem-display: block;") style = "" if overrides else "" return style, present ASSET_STYLE, ASSET_PRESENT = _build_asset_style() def _asset_data_uri(fname: str) -> str: """Base64 data-URI for a finished asset, or '' if it isn't there.""" path = ASSETS / fname if not path.exists(): return "" mime = _MIME.get(path.suffix.lower(), "image/png") return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}" # The Inspector character (transparent PNGs). `point` accuses the evidence on the # verdict screen; `lean` examines the scene during the sweep. Embedded once. INSPECTOR_POINT = _asset_data_uri("inspector_point.png") INSPECTOR_LEAN = _asset_data_uri("inspector_lean.png") INSPECTOR_NOTES = _asset_data_uri("inspector_notes.png") INSPECTOR_EXAMINE = _asset_data_uri("inspector_examine.png") INSPECTOR_FINGER = _asset_data_uri("inspector_finger.png") # Most Wanted assets for the board screen (the home entry is now a plain button). MW_SIGN_URI = _asset_data_uri("mostwanted/sign.png") MW_INSPECTOR_URI = INSPECTOR_LEAN or INSPECTOR_EXAMINE # decorative, fills the board's side column def _inspector_rotator_html() -> str: """During the sweep step, the Inspector rotates through the 4 corners of the viewport with different poses (lean=examines, notes=writing, examine=thinking, finger=pointing). Each slide gets its own animation timing so corners + poses cycle in lockstep.""" # Corners chosen so each figure faces/points TOWARD the laptop in the centre: # finger points down-right -> top-left # notes faces left -> top-right # examine faces right -> bottom-left (looks toward the laptop) # lean peers down-right -> bottom-right poses = [ ("finger", INSPECTOR_FINGER, "tl"), ("notes", INSPECTOR_NOTES, "tr"), ("examine", INSPECTOR_EXAMINE, "bl"), ("lean", INSPECTOR_LEAN, "br"), ] items = [] for i, (pose, uri, corner) in enumerate(poses): if not uri: continue items.append( f'
' f'
' ) if not items: return "" return f'' # --------------------------------------------------------------------------- # Audio: noir soundtrack + SFX, embedded and driven client-side. A head " AUDIO_HEAD = _build_audio_head() _VOICE_JS = """ (function(){ if (window.__uxcVoice) return; window.__uxcVoice = true; var V = VOICE_LINES || []; if (!V.length) return; var bag = [], lastIdx = -1, current = null, timer = null; function refill(){ bag = V.map(function(_,i){ return i; }); for (var i=bag.length-1;i>0;i--){ var j=Math.floor(Math.random()*(i+1)); var t=bag[i];bag[i]=bag[j];bag[j]=t; } if (bag.length>1 && bag[0]===lastIdx){ var t=bag[0];bag[0]=bag[1];bag[1]=t; } // no immediate repeat } function sweepActive(){ // Active the moment the laptop (sweep) is in the DOM — voices start right // away, even under the brief intro video (which has no audio of its own). var laptop = document.querySelector('.laptop-stage'); if (!laptop) return false; var verdict = document.querySelector('.screen-verdict'); if (verdict && verdict.getBoundingClientRect().height > 100) return false; return true; } function stop(){ if(timer){clearTimeout(timer);timer=null;} if(current){try{current.pause();}catch(e){} current=null;} } function schedule(ms){ clearTimeout(timer); timer = setTimeout(playOne, ms); } function playOne(){ if (!sweepActive()){ stop(); return; } if (!window.UXC || !window.UXC.isSoundOn()){ schedule(2500); return; } // respect master sound switch if (current){ schedule(1500); return; } if (bag.length===0) refill(); var idx = bag.shift(); lastIdx = idx; try { var a = new Audio(V[idx]); a.volume = 0.46; current = a; /* ~50% */ a.onended = function(){ current=null; schedule(2600 + Math.random()*3200); }; a.onerror = function(){ current=null; schedule(1800); }; a.play().catch(function(){ current=null; schedule(2200); }); } catch(e){ schedule(2200); } } new MutationObserver(function(){ if (sweepActive()){ if (!timer && !current) schedule(1000); } else { stop(); } }).observe(document.body, {childList:true, subtree:true, attributes:true, attributeFilter:['class','style']}); })(); """ def _build_voice_head() -> str: import json folder = ASSETS / "audio" uris = [] for i in range(1, 13): p = folder / f"voice_{i:02d}.mp3" if p.exists(): b64 = base64.b64encode(p.read_bytes()).decode("ascii") uris.append(f"data:audio/mpeg;base64,{b64}") if not uris: return "" return "" VOICE_HEAD = _build_voice_head() def _bg_video_uri() -> str: p = ASSETS / "video" / "bg.mp4" if not p.exists(): return "" return "data:video/mp4;base64," + base64.b64encode(p.read_bytes()).decode("ascii") BG_VIDEO_URI = _bg_video_uri() def _build_bg_video() -> str: """Fixed, dimmed, looping noir video behind everything (if present).""" if not BG_VIDEO_URI: return "" return ( '
' ) def _sound_gate_html() -> str: """A consent screen shown FIRST: noir smoke background + a question + two buttons. YES grants audio (music + all SFX + intro sound); NO runs everything silent but never nags. The floating bubble can flip the choice any time.""" smoke = ( f'' if BG_VIDEO_URI else "" ) return f"""
{smoke}
PRECINCT 7 · UX DIVISION

This case has a soundtrack.

The Inspector works best with the blinds drawn and the volume up — rain, jazz, the click of the typewriter.
Roll the audio?

You can flip the sound any time with the ♪ button.
""" SOUND_GATE_HTML = _sound_gate_html() # Controller for the consent gate — lives in so it runs natively. SOUND_GATE_HEAD = """ """ def _intro_video_uri() -> str: """Base64 data-URI for the cinematic intro clip (detective opens laptop, camera pushes into the glowing screen). Used at the top of the sweep step, crossfading into the real scan of the user's screenshot.""" p = ASSETS / "intro_detective.mp4" if not p.exists(): return "" return "data:video/mp4;base64," + base64.b64encode(p.read_bytes()).decode("ascii") def _laptop_frame() -> tuple[str, dict]: """Return (data URI, screen-rect spec dict) for the laptop overlay if both files are present; else ('', {}).""" png = ASSETS / "laptop_frame.png" spec = ASSETS / "laptop_frame.json" if not (png.exists() and spec.exists()): return "", {} import json uri = "data:image/png;base64," + base64.b64encode(png.read_bytes()).decode("ascii") return uri, json.load(open(spec)) BG_VIDEO_HTML = _build_bg_video() INTRO_VIDEO_URI = _intro_video_uri() LAPTOP_URI, LAPTOP_SPEC = _laptop_frame() def _intro_alley_uri() -> str: p = ASSETS / "intro_alley.mp4" if not p.exists(): return "" return "data:video/mp4;base64," + base64.b64encode(p.read_bytes()).decode("ascii") INTRO_ALLEY_URI = _intro_alley_uri() def _intro_alley_html() -> str: """First-load cinematic intro (alley walk to PRECINCT 7). Held paused by the sound gate, then played (with/without sound) once the user chooses. Sound is governed entirely by the consent gate + the floating bubble.""" if not INTRO_ALLEY_URI: return "" return f"""
""" INTRO_ALLEY_HTML = _intro_alley_html() def _laptop_overlay_html() -> str: """Single fixed laptop image at the bottom of the viewport (its empty 'screen' lines up with the wizard above it). On phones/tablets it folds away to avoid the floating laptop look.""" if not LAPTOP_URI: return "" return f'' LAPTOP_OVERLAY_HTML = _laptop_overlay_html() HAS_PAPER = ASSET_PRESENT.get("paper.jpg", False) HAS_GRADE = ASSET_PRESENT.get("grade_seal.png", False) HAS_STAMP = ASSET_PRESENT.get("stamp_confidential.png", False) HAS_MAGNIFIER = ASSET_PRESENT.get("magnifier.png", False) SEV_COLORS = {"capital": "#c0392b", "high": "#e74c3c", "medium": "#e67e22", "low": "#f1c40f"} VALID_SEV = set(SEV_COLORS) def _sev_class(sev: str) -> str: return sev if sev in VALID_SEV else "medium" def _img_data_uri(image: Image.Image, max_side: int | None = None, jpeg: bool = False) -> str: """Encode a PIL image as a data URI. Optionally downscale (keeps the DOM light on big screenshots) and use JPEG (much smaller than PNG for photos).""" img = image.convert("RGB") if max_side and max(img.size) > max_side: img = img.copy() img.thumbnail((max_side, max_side), Image.LANCZOS) buf = io.BytesIO() if jpeg: img.save(buf, format="JPEG", quality=82) mime = "image/jpeg" else: img.save(buf, format="PNG") mime = "image/png" return f"data:{mime};base64," + base64.b64encode(buf.getvalue()).decode("ascii") # --------------------------------------------------------------------------- # Loading scene # --------------------------------------------------------------------------- def _loading_html(image: Image.Image) -> str: uri = _img_data_uri(image, max_side=1280, jpeg=True) # light: it's just the scan backdrop mag_class = "magnifier has-asset" if HAS_MAGNIFIER else "magnifier" # FULL-SCREEN cinematic intro video: covers the entire viewport while it # plays. When it ends, fade it out and reveal the laptop + scan beneath. # During the video, the rest of the sweep UI (the laptop, meta bar, inspector) # is hidden by .has-intro on .sweep-viewer. intro = "" intro_class = "" if INTRO_VIDEO_URI: intro = ( '
' '
' ) intro_class = "has-intro" # Position the scan inside the laptop screen rect (json from process_laptop.py). # If the laptop asset isn't present, fall back to no frame. s = LAPTOP_SPEC if s: screen_style = ( f'left:{s["screen_left_pct"]:.3f}%;top:{s["screen_top_pct"]:.3f}%;' f'width:{s["screen_width_pct"]:.3f}%;height:{s["screen_height_pct"]:.3f}%' ) laptop_img = f'' if LAPTOP_URI else '' body = f"""
{laptop_img}
scanning
{_inspector_rotator_html()}
""" else: body = f"""
scanning
""" return f""" {intro}
{body}
REC CAM·07 · 00:00 Sweeping the scene for suspects… Marking the evidence… Examining each exhibit up close… Confirming the charges… Filing the report…
A thorough investigation takes 1–3 minutes — the Inspector doesn't rush. First case of the night can take a little longer while the lab warms up.
""" # Live timer for the .scan-time element. Lives in so it runs natively # (gr.HTML innerHTML scripts get sanitized). It polls every second; cheap. SCAN_TIMER_HEAD = """ """ # --------------------------------------------------------------------------- # Interactive evidence board (full-width, large) # --------------------------------------------------------------------------- def _board_html(image: Image.Image, case: CaseFile) -> str: uri = _img_data_uri(image) W, H = image.size pins, rects = [], [] for i, ev in enumerate(case.evidence): x1, y1, x2, y2 = ev.bbox color = SEV_COLORS.get(ev.severity, "#e74c3c") lpct, tpct = x1 / W * 100, y1 / H * 100 wpct = max(0.0, (x2 - x1)) / W * 100 hpct = max(0.0, (y2 - y1)) / H * 100 cxpct = (x1 + x2) / 2 / W * 100 cypct = (y1 + y2) / 2 / H * 100 flip = "flip" if cxpct > 62 else "" rects.append( f'
' ) # Pin is centered on the bbox so it always sits on the marked region. pins.append( f'' f'' f'{esc(ev.id)}' f'{esc(ev.crime)}' f'{esc(ev.severity)}' f"" ) return f"""
Annotated screenshot with numbered evidence markers {''.join(rects)} {''.join(pins)}
▸ Tap a numbered marker to jump to that charge in the file
""" # --------------------------------------------------------------------------- # Error dossier # --------------------------------------------------------------------------- def _render_error_html(message: str) -> str: return f"""
UNSOLVED
CASE FILE  Nº XXXX
STATUS  UNRESOLVED
Line Went Dead Mid-Interrogation
The wire to the crime lab went dead. Start a new case and try again.

Field Notes

!
Investigation Interrupted
{esc(message)}
RETRY
""" _FONT_DIR = ASSETS / "fonts" _FONT_FILES = { "display": _FONT_DIR / "Anton-Regular.ttf", # big condensed titles "type": _FONT_DIR / "SpecialElite-Regular.ttf", # typewriter body "label": _FONT_DIR / "Oswald.ttf", # uppercase labels } def _card_font(size: int, role: str = "display"): from PIL import ImageFont p = _FONT_FILES.get(role) if p and p.exists(): try: return ImageFont.truetype(str(p), size=size) except (OSError, IOError): pass for n in ("DejaVuSans-Bold.ttf", "arialbd.ttf"): try: return ImageFont.truetype(n, size=size) except (OSError, IOError): continue return ImageFont.load_default() def _make_share_card(annotated: Image.Image, case: CaseFile) -> Image.Image: """Compose a branded share image in the app's noir style: charcoal header (UX CRIME SCENE + grade seal) + the annotated screenshot + a cream case-file footer with the title, the top charges, and the verdict.""" from PIL import ImageDraw src = annotated.convert("RGB") if src.width > 1200: src = src.copy(); src.thumbnail((1200, 100000), Image.LANCZOS) W = src.width # Match the on-screen language: cleared (A/B) = green, everything else = blood. sev_color = {"A": (46, 158, 91), "B": (46, 158, 91)}.get(case.grade, (192, 57, 43)) INK = (26, 24, 22); CREAM = (237, 228, 207); BLOOD = (192, 57, 43); GOLD = (241, 196, 15) head_h = 104 # footer height scales with how many charges we list (up to 3) charges = case.evidence[:3] foot_h = 150 + len(charges) * 34 card = Image.new("RGB", (W, head_h + src.height + foot_h), INK) card.paste(src, (0, head_h)) d = ImageDraw.Draw(card) # ---- header ---- d.rectangle([0, 0, W, head_h], fill=(24, 22, 20)) d.rectangle([0, head_h - 5, W, head_h], fill=BLOOD) d.text((28, 20), "UX CRIME SCENE", font=_card_font(46, "display"), fill=CREAM) d.text((30, 74), "PRECINCT 7 · UX DIVISION · THE NIGHT INSPECTOR", font=_card_font(15, "label"), fill=GOLD) # grade seal (right) bs = 72; bx, by = W - bs - 28, (head_h - bs) // 2 d.ellipse([bx - 4, by - 4, bx + bs + 4, by + bs + 4], outline=GOLD, width=2) d.ellipse([bx, by, bx + bs, by + bs], fill=sev_color, outline=(0, 0, 0), width=3) gf = _card_font(48, "display") gb = d.textbbox((0, 0), case.grade, font=gf) d.text((bx + (bs - (gb[2] - gb[0])) / 2 - gb[0], by + (bs - (gb[3] - gb[1])) / 2 - gb[1]), case.grade, font=gf, fill=(255, 255, 255)) # ---- footer (case file) ---- fy = head_h + src.height d.rectangle([0, fy, W, card.height], fill=CREAM) d.rectangle([0, fy, W, fy + 5], fill=BLOOD) y = fy + 20 title = case.case_title if len(case.case_title) <= 46 else case.case_title[:45] + "…" d.text((28, y), title.upper(), font=_card_font(34, "display"), fill=INK); y += 48 # top charges for i, ev in enumerate(charges, 1): cc = {"capital": (192, 57, 43), "high": (231, 76, 60), "medium": (230, 126, 34), "low": (200, 160, 20)}.get(ev.severity, (120, 40, 30)) d.ellipse([28, y + 2, 50, y + 24], fill=cc) nb = d.textbbox((0, 0), str(i), font=_card_font(15, "display")) d.text((28 + (22 - (nb[2]-nb[0]))/2 - nb[0], y + 3), str(i), font=_card_font(15, "display"), fill=(255,255,255)) ct = ev.crime if len(ev.crime) <= 58 else ev.crime[:57] + "…" d.text((62, y + 2), ct, font=_card_font(19, "type"), fill=(50, 40, 30)); y += 34 y += 8 _cleared = str(case.grade).upper() in {"A", "B"} _vtail = ("scene came back clean" if _cleared else f"{len(case.evidence)} crimes against the user") d.text((28, y), f"VERDICT: {case.verdict} · {_vtail}", font=_card_font(22, "display"), fill=(46, 158, 91) if _cleared else BLOOD); y += 36 d.text((28, y), "Put your own UI on trial → build-small-hackathon/ux-crime-scene", font=_card_font(16, "type"), fill=(110, 92, 66)) return card def _share_card_uri(annotated: Image.Image | None, case: CaseFile) -> str: """Data URI of the branded share card (falls back to the bare annotated shot).""" if annotated is None: return "" try: return _img_data_uri(_make_share_card(annotated, case), jpeg=True) except Exception: return _img_data_uri(annotated, max_side=1700, jpeg=True) def _hero_html(clean: Image.Image, case: CaseFile) -> str: """The verdict hero: the CLEAN screenshot with the Inspector's evidence circles DRAWN ON live (animated grease-pencil ellipses via SVG, stamped numbers, revealed one by one). Hover a circle for the charge; click to jump to the file. The download button serves the baked PIL version (same circles, static).""" disp = _img_data_uri(clean, max_side=1700, jpeg=True) W, H = clean.size circles, pins = [], [] for i, ev in enumerate(case.evidence): x1, y1, x2, y2 = ev.bbox color = SEV_COLORS.get(ev.severity, "#e74c3c") # pad the ellipse out so it ENCIRCLES the element px = max(6, (x2 - x1) * 0.07) py = max(6, (y2 - y1) * 0.07) cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 rx, ry = (x2 - x1) / 2 + px, (y2 - y1) / 2 + py circles.append( f'' ) # numbered placard pinned to the top-left of the circle. Clamp so the # badge never hangs off the image edge (it's translated -50%,-50%), which # would otherwise force a horizontal scrollbar on narrow screens. lpct = min(96.0, max(3.0, (cx - rx) / W * 100)) tpct = min(96.0, max(4.0, (cy - ry) / H * 100)) cxpct = cx / W * 100 flip = "flip" if cxpct > 62 else "" pins.append( f'' f'{esc(ev.id)}' f'#{esc(ev.id)} · {esc(ev.crime)}{esc(ev.testimony)}' f"" ) # Severity legend — decode the marker colours (only the ones actually present). _sev_order = ["capital", "high", "medium", "low"] _present = [s for s in _sev_order if any(ev.severity == s for ev in case.evidence)] legend_items = "".join( f'{s}' for s in _present ) legend_html = ( f'
{legend_items}
' if legend_items else "" ) # The verdict masthead lives in the topbar and the download in the share panel # now (broadcast layout) — the hero is the pure evidence feed. insp = (f'' if INSPECTOR_NOTES else "") return f"""
EXHIBIT A — CHARGES CIRCLED ON THE REAL PIXELS · hover a marker · click to jump to the file
{legend_html}
Screenshot under investigation {''.join(pins)} {insp}
""" def _archive_html() -> str: """The Precinct Archive: a records-room drawer of famous interfaces the Inspector has already booked. Folders link to REAL stored cases (?case=ID). Renders nothing if the manifest is missing (graceful).""" import json as _json manifest_path = ASSETS / "archive" / "manifest.json" if not manifest_path.exists(): return "" try: cases = _json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: return "" folders = [] for i, c in enumerate(cases): if not c.get("case_id"): continue try: thumb = _img_data_uri( Image.open(ASSETS / "archive" / c["thumb"]).convert("RGB"), max_side=420, jpeg=True, ) except Exception: continue cleared = str(c.get("grade", "")).upper() in {"A", "B"} seal_cls = "arch-seal good" if cleared else "arch-seal" stamp = "" if cleared else '
GUILTY
' n = c.get("n_crimes", 0) line2 = "case dismissed" if cleared else f"{n} charge{'s' if n != 1 else ''} on file" folders.append(f""" CASE {esc(str(i + 1).zfill(2))}{esc(c['case_id'][:2].upper())} {esc(c['site'])} {stamp} {esc(c['site'])}
{esc(c['title'])}
{esc(line2)}
{esc(c.get('grade', '?'))}
""") if not folders: return "" return f"""
— or browse the precinct archive: famous scenes, already booked —
DRAWER C-7 · CLOSED CASES
{''.join(folders)}
— every folder reopens its full case file —
""" # Brand icons (inline SVG, white fill) for the share buttons. _SVG_X = ('') _SVG_WA = ('') _SVG_FB = ('') _SVG_LI = ('') def _case_url(case_id: str | None) -> str: """The shareable URL: the unique result link if we stored it, else the tool.""" if case_id: sep = "&" if "?" in TOOL_URL else "?" return f"{TOOL_URL}{sep}case={urllib.parse.quote(case_id)}" return TOOL_URL # --------------------------------------------------------------------------- # MOST WANTED — a living, public board: the worst interfaces the precinct has # booked, ranked by crimes. Seeded with the real archive cases (always full), # styled like a noir city notice-wall. Uses custom assets when present, else a # bespoke CSS treatment. Every poster reopens its real case (?case=ID). # --------------------------------------------------------------------------- _GRADE_GROUP = {"A": "g-clear", "B": "g-clear", "C": "g-c", "D": "g-d", "F": "g-f"} def _most_wanted_html(published: list[dict] | None = None) -> str: import json as _json mpath = ASSETS / "archive" / "manifest.json" seeds = [] if mpath.exists(): try: seeds = [c for c in _json.loads(mpath.read_text(encoding="utf-8")) if c.get("case_id")] except Exception: seeds = [] # the founding cases (real famous sites) carry a local thumbnail; user- # published cases carry an inline thumb_b64. Unify into one entry shape. seed_ids = {c["case_id"] for c in seeds} entries = [] for c in seeds: try: thumb = _img_data_uri( Image.open(ASSETS / "archive" / c["thumb"]).convert("RGB"), max_side=380, jpeg=True) except Exception: continue entries.append({"id": c["case_id"], "name": c.get("site") or c.get("title", "a case"), "title": c.get("title", ""), "grade": c.get("grade", "?"), "n": int(c.get("n_crimes", 0)), "thumb": thumb}) n_user = 0 for c in (published or []): cid = str(c.get("id", "")) if not cid or cid in seed_ids: # never duplicate a founding case continue seed_ids.add(cid) tb = c.get("thumb_b64", "") thumb = f"data:image/jpeg;base64,{tb}" if tb else "" entries.append({"id": cid, "name": c.get("site") or c.get("title") or "a booked case", "title": c.get("title", ""), "grade": c.get("grade", "?"), "n": int(c.get("n_crimes", 0) or 0), "thumb": thumb, "user": True}) n_user += 1 if not entries: return "" entries.sort(key=lambda e: (-e["n"], str(e["grade"]))) # aggregate stats (real, from the booked cases) — grows with user publishes stats = {} spath = ASSETS / "mostwanted" / "city_stats.json" if spath.exists(): try: stats = _json.loads(spath.read_text(encoding="utf-8")) except Exception: stats = {} n_sites = len(entries) n_crimes = stats.get("crimes_on_file", 0) + sum(e["n"] for e in entries if e.get("user")) if not stats.get("crimes_on_file"): n_crimes = sum(e["n"] for e in entries) top = stats.get("top_crimes", []) top1 = top[0]["name"] if top else "Buried CTA" sign = _asset_data_uri("mostwanted/sign.png") wall = _asset_data_uri("mostwanted/wall.jpg") title = (f'MOST WANTED' if sign else '

MOST WANTED

') # the wall is painted as a FIXED, full-viewport body background (set via a CSS var) # so it covers the whole screen — no black strip behind the back button / margins. wall_var = (f"" if wall else "") rows = [] for i, e in enumerate(entries, 1): gcls = _GRADE_GROUP.get(str(e["grade"]).upper(), "g-c") n = e["n"] rankcls = f"mw-rank r{i}" if i <= 3 else "mw-rank" flag = 'NEW' if e.get("user") else "" charge = esc(e["title"]) if e.get("title") else "multiple counts on file" rows.append(f""" {i} {esc(e['name'])}{flag} {esc(e['name'])} TOP CHARGE {charge} {n}crime{'s' if n != 1 else ''} {esc(e['grade'])} VIEW ▸ """) bars = "" if top: mx = max(t["count"] for t in top) or 1 bars = "".join( f'
{esc(t["name"])}' f'' f'{t["count"]}
' for t in top) report = (f'"Another night in Precinct 7. {n_sites} interfaces booked, {n_crimes} crimes ' f'on file, and not one came back clean. The {esc(top1)} is the city\'s ' f'favourite crime — an epidemic, not a habit. Drag your own corner of the web in. ' f'Nobody\'s innocent."') return f"""{wall_var}
{title}
PRECINCT 7 · THE CITY'S WORST INTERFACES — BOOKED BY THE PUBLIC
{n_sites}SITES BOOKED
{n_crimes}CRIMES ON FILE
{esc(top1)}#1 CITY-WIDE CRIME
▸ RANKED BY CRIMES ON FILE — the worst the precinct has booked
{''.join(rows)}
THE INSPECTOR'S CITY REPORT
{report}
THE CITY'S TOP CRIMES
{bars}
{(f'
The Inspector' 'THE INSPECTOR · STILL ON THE BEAT
') if MW_INSPECTOR_URI else ''}
""" # --------------------------------------------------------------------------- # Step wizard bar # --------------------------------------------------------------------------- _STEP_LABELS = ["Evidence", "Investigating", "Verdict"] def _steps_html(active: int, error: bool = False) -> str: parts = [] for i, lbl in enumerate(_STEP_LABELS): cls = "active" if i == active else ("done" if i < active else "") if error and i == 2: cls, lbl = "error", "Case Cold" parts.append(f'
{i+1}
{lbl}
') if i < len(_STEP_LABELS) - 1: parts.append('
') return f'
{"".join(parts)}
' # --------------------------------------------------------------------------- # Wizard handlers (toggle screen visibility) # --------------------------------------------------------------------------- def _start(image: Image.Image | None): """Click → go to the Investigating screen with the scan running. Instant.""" if image is None: return gr.update(), gr.update(), gr.update(), _steps_html(0) return ( gr.update(visible=False), # hide upload screen gr.update(visible=True), # show sweep screen _loading_html(image), # scan viewer _steps_html(1), ) def run_investigation(image: Image.Image | None): """Do the work, then reveal the Verdict screen. The hero image is the PIL-annotated screenshot (markers baked at exact pixel coords) — the single source of truth for marker placement. We dropped the HTML %-overlay board because its percentage mapping mis-placed markers while the PIL render was always correct; this also removes the duplicate image.""" # Reset the power-up panels for the fresh case (cleared chat, hidden voice/reco). _reset = (gr.update(value="", visible=False), # reco_out gr.update(value="", visible=False), # voice_out [], _chat_html([])) # st_chat, chat_html if image is None: return (gr.update(visible=False), gr.update(visible=True), "", "", _render_error_html("No screenshot in the evidence bag."), "", _steps_html(2, error=True), None, None, None, *_reset) try: case = investigate_agentic(image) except Exception as e: traceback.print_exc() return (gr.update(visible=False), gr.update(visible=True), "", "", _render_error_html(f"{type(e).__name__}: {e}"), "", _steps_html(2, error=True), None, None, None, *_reset) try: annotated = annotate(image, case) except Exception as e: traceback.print_exc() annotated = None # Persist the result so it gets a unique shareable link (graceful: None if it # fails -> sharing falls back to the tool link). try: case_id = save_case(image, case) except Exception: case_id = None return ( gr.update(visible=False), # hide sweep gr.update(visible=True), # show verdict _topbar_html(case), # verdict masthead _hero_html(image, case), # the evidence feed _file_panel_html(case), # case file (right rail) _share_panel_html(case, case_id, _share_card_uri(annotated, case)), _steps_html(2), image, # st_image (for power-ups) case, # st_case case_id, # st_case_id (for publish) gr.update(value=RECO_DEVELOPING_HTML, visible=True), # reco: developing… gr.update(value=VOICE_DEVELOPING_HTML, visible=True), # voice: developing… [], # st_chat _chat_html([]), # chat_html ) # --------------------------------------------------------------------------- # Verdict broadcast layout: topbar, file panel, reconstruction grid, share panel # --------------------------------------------------------------------------- def _topbar_html(case: CaseFile) -> str: """The verdict masthead: emblem logo + grade seal + typed case title.""" cleared = str(case.grade).upper() in {"A", "B"} n = len(case.evidence) tail = ("SCENE CAME BACK CLEAN" if cleared else f"{n} CHARGE{'S' if n != 1 else ''} ON FILE") emblem = _asset_data_uri("emblem.png") logo = (f'' if emblem else "") gcls = "vt-grade cleared" if cleared else "vt-grade" return f"""
{logo} UX CRIME SCENEPRECINCT 7 · UX DIVISION {esc(case.grade)} {esc(case.case_title)} VERDICT: {esc(case.verdict)} · {tail} · CASE Nº {esc(case.case_number)}
""" def _file_panel_html(case: CaseFile) -> str: """The case file as a dark broadcast panel (right rail).""" cleared = str(case.grade).upper() in {"A", "B"} stamp = "CLEARED" if cleared else "GUILTY" rows = "".join( f"""
{esc(ev.id)}
{esc(ev.crime)} · {esc(ev.severity)}
"{esc(ev.testimony)}"
""" for i, ev in enumerate(case.evidence) ) return f"""
CASE FILE Nº {esc(case.case_number)}{stamp}
{esc(case.scene_summary)}
{rows}
"{esc(case.closing_statement)}"
""" def _reco_grid_html(items, total_charges: int) -> str: """Per-charge before/after comparators: thumbnail strip + :target modals with a draggable split slider (the range input is wired by COMPARE_HEAD JS).""" if not items: return ('
The lab could not develop any reconstruction — ' 'the wire may be cold. Try again in a moment.
') thumbs, modals = [], [] for ev, before, after in items: b = _img_data_uri(before, max_side=720, jpeg=True) a = _img_data_uri(after, max_side=720, jpeg=True) fix = esc(getattr(ev, "fix", "") or "a cleaner, higher-contrast treatment") thumbs.append(f""" {esc(ev.id)} Reconstruction of exhibit {esc(ev.id)} ⤢ COMPARE BEFORE / AFTER REBUILT BY FLUX ✚ """) modals.append(f"""
▣ THE RECONSTRUCTION — EXHIBIT #{esc(ev.id)}
"{esc(ev.crime)}" · remedy: {fix} · rebuilt live by FLUX.2 Klein
before after
AS FOUNDREBUILT ✚
""") note = "" if len(items) < total_charges: note = (f'
{len(items)} of {total_charges} exhibits developed — ' f'the rest resisted the lab.
') return f'
{"".join(thumbs)}
{"".join(modals)}{note}' def _share_panel_html(case: CaseFile, case_id: str | None, dl_uri: str = "") -> str: url = _case_url(case_id) cleared = str(case.grade).upper() in {"A", "B"} tail = ("The scene came back clean." if cleared else f"{len(case.evidence)} crimes against the user.") text = (f"THE INSPECTOR'S VERDICT: {case.grade} — \"{case.case_title}\". " f"{tail} See the full case file:") enc_text = urllib.parse.quote(text) enc_url = urllib.parse.quote(url) tweet = f"https://twitter.com/intent/tweet?text={enc_text}&url={enc_url}" whatsapp = f"https://wa.me/?text={urllib.parse.quote(text + ' ' + url)}" facebook = f"https://www.facebook.com/sharer/sharer.php?u={enc_url}"e={enc_text}" linkedin = f"https://www.linkedin.com/sharing/share-offsite/?url={enc_url}" dl = (f'' f'↓ DOWNLOAD THE SHARE CARD' if dl_uri else "") return f""" """ VOICE_DEVELOPING_HTML = ("""
THE INSPECTOR IS CLEARING HIS THROAT recording the narration on reel Nº 7…
""") _AVATAR_URI = _asset_data_uri("inspector_avatar.png") def _voice_player_html(wav_b64: str) -> str: """The verdict tape player: red round ▶, a waveform that FILLS with gold as it plays (click anywhere on it to seek), and a reel-style time readout. The