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:
1 parent
491f3b97f1
commit
472c86b36e
4 files changed
+350
-149
No files matched your search
+100
-34
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user