#!/usr/bin/env python3 """Promo clip factory — one command, a folder of postable vertical mp4s. python3 tools/capture.py # every scene python3 tools/capture.py horde boss # only these python3 tools/capture.py --keep # keep the raw webm next to the mp4 Serves the repo with tools/clips.js injected (the game's own files are untouched, and tools/ is not in the Dockerfile COPY, so none of this ships), opens Chrome at phone-portrait size on a throwaway profile, and lets the page stage each highlight through window.T, record canvas + audio, and POST the webm back. ffmpeg then doubles it to 1080x1920 with NEAREST-NEIGHBOUR — pixel art must not be blurred by an upscale — and writes h264/aac into promo/clips/. Why a real browser window and not headless: the recording is real-time off requestAnimationFrame, and a backgrounded or headless tab 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, 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' # the scene names, in shooting order — must match SCENES in tools/clips.js SCENES = ['transition', 'horde', 'flamer', 'mopboy', 'death', 'boss', 'match'] INJECT = b'\n' args = [a for a in sys.argv[1:] if not a.startswith('-')] keep = '--keep' in sys.argv scenes = args or SCENES bad = [s for s in scenes if s not in SCENES] if bad: sys.exit('unknown scene(s): %s\nknown: %s' % (', '.join(bad), ', '.join(SCENES))) done = threading.Event() made = [] def to_mp4(webm, mp4): # -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=1080:1920:flags=neighbor', # a no-op at RES 4; the guarantee is cheap '-r', '60', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', mp4], check=True) class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=ROOT, **kw) def log_message(self, *a): pass 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.split('?')[0] in ('/', '/index.html'): html = open(os.path.join(ROOT, 'index.html'), 'rb').read().replace(b'', INJECT, 1) self.send_response(200) self.send_header('Content-Type', 'text/html') self.send_header('Content-Length', str(len(html))) self.send_header('Cache-Control', 'no-store') self.end_headers() self.wfile.write(html) return 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): %s' % (len(scenes), ', '.join(scenes)), flush=True) 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 -> 1080x1920 '--window-size=700,1000', '--window-position=0,0', '--app=http://127.0.0.1:%d/?clips=%s' % (PORT, ','.join(scenes)), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ok = done.wait(timeout=60 + 30 * len(scenes)) 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)