The highlights shoot themselves: one command, seven vertical clips

`python3 tools/capture.py` fills promo/clips/ with 1080x1920 h264/aac takes of
the moments the campaign plan (item 4) already named — transition, horde,
flamer, mopboy, death on the gore side, boss and match on the zombie-tennis
side. capture.py serves the repo with tools/clips.js injected, opens a
throwaway-profile Chrome and converts each POSTed webm with ffmpeg; clips.js
stages every scene through the real doors on window.T and drives it with the
reachability soak's own mediocre autopilot. Nothing is faked for the camera.

The game gains four names on window.T (start, intoSplatter, startHorde,
sendBoy) and nothing else: the frame is pinned by the canvas box rather than
the window, RES 4 comes from lowPower 0 plus device-scale-factor 3, and the
audio is tapped by making BaseAudioContext's `destination` a gain node — so
audio.js needs no master bus for a tool that ships to nobody. tools/ and
promo/clips/ are ignored by git and docker alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-18 13:07:21 +02:00
1 parent 296d34d8ca
commit 491f3b97f1
7 files changed
+338 -5

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
#!/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'<script src="/tools/clips.js"></script>\n</head>'
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'</head>', 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)