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>
179 lines
7.9 KiB
Python
179 lines
7.9 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', 'beacon', 'flamer', 'rocket')
|
|
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)
|