Files
wuwiandClaude Opus 5 532dd3b83e The shot list documents itself: all eight tins, five beats, ten bosses
The header block in tools/scenes.json is now the whole schema — every key with
its range and default, what each of the EIGHT weapons does (fire, laser and gun
were missing from both the docs and the validator), the five beats, the crowd
roster diversity walks in order, the bosses by chapter, and the traps worth
knowing: a serve tin is spent in about four seconds of autopilot fire, a burst
tin owns its own window, a horde locks the rack, a boss cannot start inside a
match, and `movement` fights the match's own auto-run legs.

Two scenes added from the campaign's own wish list: `epidemic` (the fire ball
lighting a whole wave off the body it hit) and `champion` (chapter ten, the
night court). Both verified on film.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:34:17 +02:00

179 lines
8.0 KiB
Python

#!/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'<script src="/tools/clips.js"></script>\n</head>'
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'</head>', 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)