diff --git a/.gitignore b/.gitignore index aa6c02b9..bd54df72 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,8 @@ test-results-*.xml art_generated/ generated_icons/ asset_gallery.html +# Local art-review app state (per-reviewer verdicts/notes; written by serve_review.py) +tools/art_review/review_state.json test_output.png test_openai_api.py generate_icons.py diff --git a/tools/art_review/serve_review.py b/tools/art_review/serve_review.py new file mode 100644 index 00000000..03ed6c1e --- /dev/null +++ b/tools/art_review/serve_review.py @@ -0,0 +1,658 @@ +#!/usr/bin/env python3 +"""Local art-review app for P(Doom)1 -- ONE place to review ALL the art. + +It serves both art tracks in a single gallery: + * pixellab sprites/office-sim under art_source/** (committed) + * gpt-image icons/banners/bg/textures under art_generated/** (gitignored, ~1.1 GB) + +Per asset you get a clickable keep / maybe / reroll verdict, a free-text NOTE +field and optional comma-separated TAGS. Every edit AUTO-PERSISTS to a real file +on disk -- tools/art_review/review_state.json -- via a POST endpoint, so a review +survives across sittings, can be revised over many sessions, and is a clean input +for promote/reroll tooling. (No browser localStorage: too fragile for multi-session.) + +Nothing is embedded or copied: PNGs stream live from disk through /img?p=, +so the big gitignored art_generated/ tree is never duplicated or committed. + +Run (stdlib only -- no Flask/deps): + python tools/art_review/serve_review.py # http://127.0.0.1:8777 + python tools/art_review/serve_review.py --port 9000 + python tools/art_review/serve_review.py --art-root # when the art lives elsewhere + # e.g. running from a git worktree while art_generated/ is in the main checkout: + # python tools/art_review/serve_review.py --art-root /path/to/main/checkout + +State file shape (the pipeline contract): + { + "": { + "verdict": "keep" | "maybe" | "reroll" | null, + "note": "free text", + "tags": ["tag", ...], + "updated_at": "2026-07-19T10:11:12.345678+00:00" + }, + ... + } + +asset_id conventions (stable + pipeline-friendly): + gen::: e.g. gen:game_icons:icon_doom:v2 + px: e.g. px:pixellab_2026-07-16/style_matrix/baseline__desk +""" +import argparse +import html +import json +import os +import pathlib +import re +import threading +import webbrowser +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, quote, urlparse + +HERE = pathlib.Path(__file__).resolve().parent +REPO = HERE.parents[1] +STATE_PATH = HERE / "review_state.json" + +# generated file names: "[_vN]_.png"; strip _, then optional _vN +_SIZE_RE = re.compile(r"^(.+?)_(\d+)\.png$") +_VAR_RE = re.compile(r"^(.+)_v(\d+)$") +# pixellab rotation sheets (one direction each) are not review images +_ROT_RE = re.compile(r"_(north|south|east|west|north-east|north-west|south-east|south-west)$") +# prefer a mid size to show; fall back to whatever exists +_SIZE_PREF = ["512", "256", "1024", "128", "64"] + +VALID_VERDICTS = {"keep", "maybe", "reroll"} + +# ordered generated categories (label); any other dirs found are appended +_GEN_CATS = [ + ("game_icons", "Game icons"), + ("ui_icons", "UI icons"), + ("hero_banners", "Hero banners"), + ("screen_backgrounds", "Screen backgrounds"), + ("terminal_textures", "Terminal textures"), +] + +GROUP_GEN = "Generated (gpt-image)" +GROUP_PX = "Pixellab (art_source)" + + +def esc(s): + return html.escape(str(s or ""), quote=True) + + +def slug(s): + return re.sub(r"[^a-z0-9]+", "-", str(s).lower()).strip("-") + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def pick_size(sizes): + """sizes: {size_str: filename}. Return the preferred representative size key.""" + for p in _SIZE_PREF: + if p in sizes: + return p + return max(sizes, key=lambda z: int(z)) + + +# ------------------------------------------------------------------ scanning +def scan_generated(art_root): + """Group art_generated//v1/*.png into (base_id, variant) units, one + representative size each. Returns a list of section dicts.""" + base = art_root / "art_generated" + if not base.is_dir(): + return [] + cats = list(_GEN_CATS) + known = {c for c, _ in cats} + for extra in sorted( + p.name for p in base.iterdir() if p.is_dir() and p.name not in known and p.name != "logs" + ): + cats.append((extra, extra.replace("_", " ").title())) + + sections = [] + for cat, title in cats: + d = base / cat / "v1" + if not d.is_dir(): + continue + units = {} # (base_id, variant) -> {size: filename} + for f in sorted(os.listdir(d)): + if not f.endswith(".png"): + continue + m = _SIZE_RE.match(f) + if not m: + continue + stem, size = m.group(1), m.group(2) + vm = _VAR_RE.match(stem) + base_id, var = (vm.group(1), "v" + vm.group(2)) if vm else (stem, "v1") + units.setdefault((base_id, var), {})[size] = f + if not units: + continue + cells = [] + for base_id, var in sorted(units): + sizes = units[(base_id, var)] + size = pick_size(sizes) + rel = f"art_generated/{cat}/v1/{sizes[size]}" + cells.append( + { + "asset_id": f"gen:{cat}:{base_id}:{var}", + "label": f"{base_id} {var}", + "img": rel, + "meta": f"{size}px", + } + ) + sections.append( + { + "id": "gen-" + slug(cat), + "group": GROUP_GEN, + "title": title, + "cells": cells, + } + ) + return sections + + +def scan_pixellab(art_root): + """Walk art_source/**; every leaf dir holding review PNGs becomes a section.""" + base = art_root / "art_source" + if not base.is_dir(): + return [] + sections = [] + for dp, _dn, fn in os.walk(base): + pngs = [f for f in sorted(fn) if f.endswith(".png") and not _ROT_RE.search(f[:-4])] + if not pngs: + continue + dpath = pathlib.Path(dp) + rel_under_src = dpath.relative_to(base).as_posix() # e.g. pixellab_2026-07-16/style_matrix + cells = [] + for f in pngs: + stem = f[:-4] + rel = f"art_source/{rel_under_src}/{f}" + cells.append( + { + "asset_id": f"px:{rel_under_src}/{stem}", + "label": stem, + "img": rel, + "meta": "", + } + ) + parts = rel_under_src.split("/") + title = " / ".join(parts[-2:]) if len(parts) > 1 else rel_under_src + sections.append( + { + "id": "px-" + slug(rel_under_src), + "group": GROUP_PX, + "title": title, + "cells": cells, + } + ) + sections.sort(key=lambda s: s["id"]) + return sections + + +def scan_all(art_root): + return scan_generated(art_root) + scan_pixellab(art_root) + + +# ------------------------------------------------------------------ state I/O +_LOCK = threading.Lock() + + +def load_state(): + if STATE_PATH.is_file(): + try: + return json.loads(STATE_PATH.read_text(encoding="utf-8")) + except (ValueError, OSError): + return {} + return {} + + +def save_state(state): + tmp = STATE_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(STATE_PATH) + + +def normalize_tags(raw): + if raw is None: + return None + if isinstance(raw, str): + raw = raw.split(",") + if not isinstance(raw, list): + return [] + out = [] + for t in raw: + t = str(t).strip() + if t and t not in out: + out.append(t) + return out + + +def apply_patch(patch): + """Merge one {asset_id, verdict?, note?, tags?} patch into the state file. + Returns (status, response_dict).""" + asset_id = patch.get("asset_id") + if not asset_id or not isinstance(asset_id, str): + return 400, {"ok": False, "error": "missing asset_id"} + with _LOCK: + state = load_state() + entry = state.get(asset_id, {}) + entry.setdefault("verdict", None) + entry.setdefault("note", "") + entry.setdefault("tags", []) + if "verdict" in patch: + v = patch["verdict"] + entry["verdict"] = v if v in VALID_VERDICTS else None + if "note" in patch: + entry["note"] = "" if patch["note"] is None else str(patch["note"]) + if "tags" in patch: + entry["tags"] = normalize_tags(patch["tags"]) or [] + entry["updated_at"] = now_iso() + # drop an entry that carries no signal, to keep the file clean + if not entry["verdict"] and not entry["note"].strip() and not entry["tags"]: + state.pop(asset_id, None) + saved = None + else: + state[asset_id] = entry + saved = entry + save_state(state) + return 200, {"ok": True, "asset_id": asset_id, "entry": saved} + + +# ------------------------------------------------------------------ rendering +def render_cell(c): + aid = esc(c["asset_id"]) + src = "/img?p=" + quote(c["img"], safe="/") + meta = f'{esc(c["meta"])}' if c["meta"] else "" + return f""" +
+
{esc(c['label'])}
+
{esc(c['label'])}{meta}
+
{aid}
+
+ + + +
+ + +
""" + + +def render_section(s): + cells = "".join(render_cell(c) for c in s["cells"]) + return f""" +
+

{esc(s['title'])} {len(s['cells'])}

+
{cells}
+
""" + + +def render_nav(sections): + out = [] + last_group = None + for s in sections: + if s["group"] != last_group: + out.append(f'{esc(s["group"])}') + last_group = s["group"] + out.append( + f'{esc(s["title"])}' + f'{len(s["cells"])}' + ) + return "".join(out) + + +def render_page(art_root): + sections = scan_all(art_root) + total = sum(len(s["cells"]) for s in sections) + gen_total = sum(len(s["cells"]) for s in sections if s["group"] == GROUP_GEN) + body = "".join(render_section(s) for s in sections) + nav = render_nav(sections) + state = load_state() + subtitle = ( + f"{total} assets across {len(sections)} sections " + f"({gen_total} generated + {total - gen_total} pixellab). " + f"Verdicts, notes and tags auto-save to review_state.json on every edit." + ) + return ( + _TEMPLATE.replace("{{SUBTITLE}}", esc(subtitle)) + .replace("{{NAV}}", nav) + .replace("{{BODY}}", body) + .replace("{{SEED}}", json.dumps(state)) + ) + + +# ------------------------------------------------------------------ HTTP +class ReviewServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, addr, art_root): + self.art_root = art_root.resolve() + super().__init__(addr, ReviewHandler) + + +class ReviewHandler(BaseHTTPRequestHandler): + server_version = "PdoomArtReview/1.0" + + def log_message(self, fmt, *args): # quieter console + pass + + def _send(self, code, body, ctype="application/json; charset=utf-8", extra=None): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def do_GET(self): + u = urlparse(self.path) + if u.path == "/": + self._send(200, render_page(self.server.art_root), "text/html; charset=utf-8") + elif u.path == "/img": + self._serve_img(parse_qs(u.query).get("p", [""])[0]) + elif u.path == "/api/state": + self._send(200, json.dumps(load_state(), indent=2, sort_keys=True)) + elif u.path == "/favicon.ico": + self._send(204, b"", "image/x-icon") + else: + self._send(404, json.dumps({"ok": False, "error": "not found"})) + + def do_POST(self): + u = urlparse(self.path) + if u.path != "/api/state": + self._send(404, json.dumps({"ok": False, "error": "not found"})) + return + try: + n = int(self.headers.get("Content-Length", 0)) + patch = json.loads(self.rfile.read(n) or b"{}") + except (ValueError, TypeError): + self._send(400, json.dumps({"ok": False, "error": "bad json"})) + return + code, resp = apply_patch(patch) + self._send(code, json.dumps(resp)) + + def _serve_img(self, rel): + # sandbox: resolve under art_root, must stay inside it, must be a .png + root = self.server.art_root + rel = rel.lstrip("/") + target = (root / rel).resolve() + try: + target.relative_to(root) + except ValueError: + self._send(403, json.dumps({"ok": False, "error": "forbidden"})) + return + if target.suffix.lower() != ".png" or not target.is_file(): + self._send(404, json.dumps({"ok": False, "error": "no image"})) + return + self._send(200, target.read_bytes(), "image/png") + + +# ------------------------------------------------------------------ template +_TEMPLATE = r""" + +P(Doom)1 -- Art Review + + +
+

P(Doom)1 · art direction · local review app

+

Art review -- all tracks, one place

+

{{SUBTITLE}}

+ + {{BODY}} +
+
+
state file: tools/art_review/review_state.json
+
+ +
+
Every verdict / note / tag POSTs to the local server and is written to + review_state.json on disk -- reload any time, across sessions, and your review is still here.
+
+ + + + + + +""" + + +def main(): + ap = argparse.ArgumentParser(description="Local P(Doom)1 art-review app (stdlib only).") + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=8777) + ap.add_argument( + "--art-root", + default=str(REPO), + help="dir containing art_source/ and art_generated/ (default: repo root; " + "point at the main checkout when running from a worktree).", + ) + ap.add_argument("--no-browser", action="store_true", help="do not auto-open a browser") + args = ap.parse_args() + + art_root = pathlib.Path(args.art_root).resolve() + httpd = ReviewServer((args.host, args.port), art_root) + url = f"http://{args.host}:{args.port}/" + sections = scan_all(art_root) + total = sum(len(s["cells"]) for s in sections) + gen = sum(len(s["cells"]) for s in sections if s["group"] == GROUP_GEN) + print(f"art root : {art_root}") + print(f"state : {STATE_PATH}") + print( + f"assets : {total} cells in {len(sections)} sections ({gen} generated, {total - gen} pixellab)" + ) + print(f"serving : {url} (Ctrl-C to stop)") + if not args.no_browser: + try: + webbrowser.open(url) + except Exception: + pass + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nstopped.") + + +if __name__ == "__main__": + main()