The shot list becomes a config file, and the camera holds a phone

tools/scenes.json says WHAT to shoot; tools/clips.js only knows how. Every
knob Oliver asked for is a key: seconds, setting (gore/zombie), beat, court,
boss, weapons, movement, density, diversity, gore, games, screen — documented
in the file's own "_" block, validated in capture.py so a typo names itself
instead of coming back as fifteen seconds of empty court. Nine scenes ship in
the default list, across all four courts.

The frame is a PHONE now (393x852 -> 1080x2340), not 9:16: the portrait branch
derives its logical height from the canvas box, so a 9:16 take was never the
framing a player sees. The player has legs — held arrow keys through the real
axisX, so horde and flamer are no longer a statue with a flamethrower. The
crowd tops itself up where the game's own spawner puts it, diversity walks the
roster, and `gore` paints in the run's history a fifteen-second clip has no
time to earn. The pilot is picked by what the court is doing rather than by
config: tennis mode taps to swing, splatter draws the slingshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-18 13:25:59 +02:00
1 parent 491f3b97f1
commit 472c86b36e
4 files changed
+350 -149

No files matched your search

+100 -34
View File
@@ -1,51 +1,106 @@
#!/usr/bin/env python3
"""Promo clip factory — one command, a folder of postable vertical mp4s.
"""Promo clip factory — one command, a folder of postable phone-shaped 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
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
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/.
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 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.
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, os, shutil, subprocess, sys, threading, urllib.parse
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'
# 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)))
COURTS = ('grass', 'clay', 'hard', 'gloom')
WEAPONS = ('bomb', 'triple', 'beacon', 'flamer', 'rocket')
BEATS = ('transition', 'horde', 'boss', 'death', 'mopboy')
SCREENS = ('phone', 'tall', '9x16')
DIALS = ('movement', 'density', 'diversity', 'gore')
done = threading.Event()
made = []
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=1080:1920:flags=neighbor', # a no-op at RES 4; the guarantee is cheap
'-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)
@@ -53,19 +108,23 @@ class Handler(http.server.SimpleHTTPRequestHandler):
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)
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 self.send_bytes(html, 'text/html')
return super().do_GET()
def do_POST(self):
@@ -93,19 +152,26 @@ class Handler(http.server.SimpleHTTPRequestHandler):
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)
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 -> 1080x1920
'--force-device-scale-factor=3', # dpr 3 -> render.js picks RES 4, the sharp tier
'--window-size=700,1000', '--window-position=0,0',
'--app=http://127.0.0.1:%d/?clips=%s' % (PORT, ','.join(scenes)),
'--app=' + url,
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ok = done.wait(timeout=60 + 30 * len(scenes))
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))