#!/usr/bin/env python3 """Promo clip factory — one command, a folder of postable phone-shaped mp4s. python3 tools/capture.py # every scene in tools/scenes.json python3 tools/capture.py horde boss # only these python3 tools/capture.py my-shots.json # a different shot list python3 tools/capture.py --seed 77 --keep # re-roll the randomness / keep the raw webm THE SHOT LIST IS DATA (tools/scenes.json — its "_" block documents every knob). This file serves the repo with tools/clips.js injected, opens Chrome on a throwaway profile, and lets the page stage each scene through window.T, record canvas + audio, and POST the webm back; ffmpeg writes h264/aac into promo/clips/. The game's own files are untouched, and tools/ is in neither the image nor the Dockerfile COPY, so none of this ships. Why a real browser window and not headless: the recording is real-time off requestAnimationFrame, and headless is exactly where Chrome throttles rAF and swiftshader drops frames. Two minutes of a window in the way is cheaper than debugging dropped frames. """ import http.server, json, os, shutil, subprocess, sys, threading, urllib.parse HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) OUT = os.path.join(ROOT, 'promo', 'clips') PORT = 8642 CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' INJECT = b'\n' COURTS = ('grass', 'clay', 'hard', 'gloom') WEAPONS = ('bomb', 'triple', 'fire', 'laser', 'gun', 'beacon', 'flamer', 'rocket') # config.WEAPONS, all of it BEATS = ('transition', 'horde', 'boss', 'death', 'mopboy') SCREENS = ('phone', 'tall', '9x16') DIALS = ('movement', 'density', 'diversity', 'gore') def check(sc, i): """Validate one scene. The config is hand-written, so a typo must name itself here rather than come back as fifteen seconds of an empty court.""" def bad(msg): sys.exit('scenes[%d] (%s): %s' % (i, sc.get('name', '?'), msg)) name = sc.get('name') if not name or not all(c.isalnum() or c in '-_' for c in name): bad('needs a name of letters, digits, - and _') if not 3 <= sc.get('seconds', 0) <= 60: bad('seconds must be 3..60') if sc.get('setting') not in ('gore', 'zombie'): bad('setting must be gore or zombie') if sc.get('beat') and sc['beat'] not in BEATS: bad('beat must be one of ' + ', '.join(BEATS)) if sc.get('court') and sc['court'] not in COURTS: bad('court must be one of ' + ', '.join(COURTS)) if sc.get('screen') and sc['screen'] not in SCREENS: bad('screen must be one of ' + ', '.join(SCREENS)) for w in sc.get('weapons', []): if w not in WEAPONS: bad('unknown weapon %r — have %s' % (w, ', '.join(WEAPONS))) for d in DIALS: if d in sc and not 0 <= sc[d] <= 1: bad('%s must be 0..1' % d) if sc.get('boss') is not None: if not 1 <= sc['boss'] <= 10: bad('boss must be chapter 1..10') # ONE MODE OWNS THE COURT (beats.js): a boss fight refuses to start inside a match, so the # config cannot ask for both and quietly get neither. if sc.get('setting') != 'gore': bad('a boss fight is a gore-run beat — set setting to gore') return sc def to_mp4(webm, mp4): # No scaling: at RES 4 the canvas backing store IS the output (a phone box records 1080x2340), # and pixel art must never be resampled. The filter only guarantees even dimensions for h264. # -r 60 on the OUTPUT: MediaRecorder writes a variable-rate stream and the platforms want CFR. subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', webm, '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-r', '60', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', mp4], check=True) # ---- the command line: a .json is the shot list, a --flag is a flag, anything else is a scene name argv = sys.argv[1:] keep = '--keep' in argv seed = '' if '--seed' in argv: seed = argv[argv.index('--seed') + 1] argv = [a for a in argv if a != seed] cfg_path = next((a for a in argv if a.endswith('.json')), os.path.join(HERE, 'scenes.json')) wanted = [a for a in argv if not a.startswith('-') and not a.endswith('.json')] cfg = json.load(open(cfg_path)) scenes = {} for i, sc in enumerate(cfg['scenes']): check(sc, i) scenes[sc['name']] = sc order = wanted or list(scenes) bad = [s for s in order if s not in scenes] if bad: sys.exit('unknown scene(s): %s\nin %s: %s' % (', '.join(bad), os.path.relpath(cfg_path, ROOT), ', '.join(scenes))) done = threading.Event() made = [] class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=ROOT, **kw) def log_message(self, *a): pass def send_bytes(self, body, ctype): self.send_response(200) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') self.end_headers() self.wfile.write(body) def do_GET(self): if self.path.startswith('/log?'): # the page's console, since the browser is throwaway print(' .. ' + urllib.parse.unquote(self.path[5:]), flush=True) self.send_response(204); self.end_headers(); return if self.path == '/scenes': return self.send_bytes(json.dumps(scenes).encode(), 'application/json') if self.path.split('?')[0] in ('/', '/index.html'): html = open(os.path.join(ROOT, 'index.html'), 'rb').read().replace(b'', INJECT, 1) return self.send_bytes(html, 'text/html') return super().do_GET() def do_POST(self): body = self.rfile.read(int(self.headers.get('Content-Length') or 0)) if self.path == '/done': done.set() elif self.path.startswith('/clip/'): name = os.path.basename(self.path) # basename: the path is never trusted webm = os.path.join(OUT, name) open(webm, 'wb').write(body) mp4 = webm[:-5] + '.mp4' try: to_mp4(webm, mp4) except subprocess.CalledProcessError: print(' %-12s FAILED to convert (%d B of webm kept)' % (name[:-5], len(body)), flush=True) else: if not keep: os.remove(webm) made.append(mp4) print(' %-12s %5.1f MB -> %s' % (name[:-5], os.path.getsize(mp4) / 1e6, os.path.relpath(mp4, ROOT)), flush=True) self.send_response(204) self.end_headers() os.makedirs(OUT, exist_ok=True) srv = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler) threading.Thread(target=srv.serve_forever, daemon=True).start() print('shooting %d scene(s) from %s: %s' % (len(order), os.path.relpath(cfg_path, ROOT), ', '.join(order)), flush=True) url = 'http://127.0.0.1:%d/?clips=%s' % (PORT, ','.join(order)) if seed: url += '&seed=' + seed if scenes[order[0]].get('court'): # the court must be stored before the game module boots url += '&court=' + scenes[order[0]]['court'] profile = '/tmp/splat-capture-profile' shutil.rmtree(profile, ignore_errors=True) chrome = subprocess.Popen([ CHROME, '--user-data-dir=' + profile, '--no-first-run', '--no-default-browser-check', '--autoplay-policy=no-user-gesture-required', # the umpire is the hook; he must be in the file '--force-device-scale-factor=3', # dpr 3 -> render.js picks RES 4, the sharp tier '--window-size=700,1000', '--window-position=0,0', '--app=' + url, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) budget = 60 + sum(scenes[s]['seconds'] + 14 for s in order) ok = done.wait(timeout=budget) chrome.terminate() srv.shutdown() print('%d clip(s) in promo/clips/' % len(made) if ok else 'TIMED OUT after %d clip(s)' % len(made)) sys.exit(0 if ok else 1)