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>
179 lines
8.0 KiB
Python
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)
|