#!/usr/bin/env python3 """Lane D shot sink — write canvas frames from a dev page straight into docs/shots/laneD/. Why: the eyeball law wants in-engine PNGs committed as evidence every round, but `DBG.shot()` relies on an click, and in a headless/embedded browser pane those land as unnamed half-written temp blobs in ~/Downloads. Naming evidence by guessing at file sizes is not evidence. So: the page POSTs the canvas here and the file lands with the right name, first try. python3 pipeline/shot_sink.py & # listens on :8141, writes docs/shots/laneD/ python3 pipeline/shot_sink.py --dir docs/shots/laneD --port 8141 From the page (any origin — CORS is wide open, it's a localhost dev tool): canvas.toBlob(b => fetch('http://localhost:8141/shot/my_name.png', {method:'POST', body:b})); Stdlib only. Refuses anything that isn't a PNG, and sanitises the name to a bare basename so a page can't post its way out of the shots directory. """ import argparse, os, re, sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PNG_MAGIC = b"\x89PNG\r\n\x1a\n" OUTDIR = os.path.join(ROOT, "docs", "shots", "laneD") class Sink(BaseHTTPRequestHandler): def _cors(self): self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "*") def do_OPTIONS(self): self.send_response(204) self._cors() self.end_headers() def _read_body(self): """Content-Length when present, else chunked. fetch(body: Blob) normally sends a Content-Length, but some embedded browsers stream it chunked instead — and read(Content-Length or 0) silently returns b"" for those, which looks exactly like "not a PNG". Handle both rather than rejecting a perfectly good frame.""" cl = self.headers.get("Content-Length") if cl is not None: return self.rfile.read(int(cl)) if (self.headers.get("Transfer-Encoding") or "").lower() != "chunked": return b"" buf = bytearray() while True: n = int(self.rfile.readline().split(b";")[0] or b"0", 16) if n == 0: self.rfile.readline() # trailing CRLF return bytes(buf) buf += self.rfile.read(n) self.rfile.readline() # CRLF after each chunk def do_POST(self): m = re.match(r"^/shot/([\w.-]+)$", self.path) if not m: self.send_response(404); self._cors(); self.end_headers(); return name = os.path.basename(m.group(1)) if not name.endswith(".png"): name += ".png" body = self._read_body() if not body.startswith(PNG_MAGIC): # only ever write real PNGs print(f" reject {name}: {len(body)}B, head={body[:8]!r}, " f"cl={self.headers.get('Content-Length')!r}, " f"te={self.headers.get('Transfer-Encoding')!r}", flush=True) self.send_response(415); self._cors(); self.end_headers() self.wfile.write(b"not a png"); return path = os.path.join(OUTDIR, name) with open(path, "wb") as f: f.write(body) print(f" shot {name} {len(body)//1024}KB -> {os.path.relpath(path, ROOT)}", flush=True) self.send_response(200); self._cors(); self.end_headers() self.wfile.write(b"ok") def log_message(self, *a): # the prints above are the only log we want pass if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=8141) ap.add_argument("--dir", default=OUTDIR) a = ap.parse_args() OUTDIR = os.path.abspath(a.dir) os.makedirs(OUTDIR, exist_ok=True) print(f"shot sink: POST http://localhost:{a.port}/shot/.png -> {OUTDIR}", flush=True) ThreadingHTTPServer(("127.0.0.1", a.port), Sink).serve_forever()