The highlights shoot themselves: one command, seven vertical clips
`python3 tools/capture.py` fills promo/clips/ with 1080x1920 h264/aac takes of the moments the campaign plan (item 4) already named — transition, horde, flamer, mopboy, death on the gore side, boss and match on the zombie-tennis side. capture.py serves the repo with tools/clips.js injected, opens a throwaway-profile Chrome and converts each POSTed webm with ffmpeg; clips.js stages every scene through the real doors on window.T and drives it with the reachability soak's own mediocre autopilot. Nothing is faked for the camera. The game gains four names on window.T (start, intoSplatter, startHorde, sendBoy) and nothing else: the frame is pinned by the canvas box rather than the window, RES 4 comes from lowPower 0 plus device-scale-factor 3, and the audio is tapped by making BaseAudioContext's `destination` a gain node — so audio.js needs no master bus for a tool that ships to nobody. tools/ and promo/clips/ are ignored by git and docker alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
296d34d8ca
commit
491f3b97f1
7 files changed
+338
-5
No files matched your search
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Promo clip factory — one command, a folder of postable vertical 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
|
||||
|
||||
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/.
|
||||
|
||||
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.
|
||||
"""
|
||||
import http.server, 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)))
|
||||
|
||||
done = threading.Event()
|
||||
made = []
|
||||
|
||||
|
||||
def to_mp4(webm, mp4):
|
||||
# -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
|
||||
'-r', '60', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18',
|
||||
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart',
|
||||
mp4], check=True)
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, directory=ROOT, **kw)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
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.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 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): %s' % (len(scenes), ', '.join(scenes)), flush=True)
|
||||
|
||||
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
|
||||
'--window-size=700,1000', '--window-position=0,0',
|
||||
'--app=http://127.0.0.1:%d/?clips=%s' % (PORT, ','.join(scenes)),
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
ok = done.wait(timeout=60 + 30 * len(scenes))
|
||||
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)
|
||||
Reference in new issue
Block a user