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)
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
// PROMO CLIP RIG — injected into index.html by tools/capture.py, never shipped (tools/ is not in
|
||||
// the Dockerfile COPY). It stages a highlight with the real game through window.T, records the
|
||||
// canvas + the WebAudio bus with MediaRecorder, POSTs the webm back to capture.py and reloads into
|
||||
// the next scene. ONE PAGE LOAD PER SCENE — a fresh boot is the only teardown that is guaranteed
|
||||
// correct, and it costs a second.
|
||||
(() => {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const list = (q.get('clips') || '').split(',').filter(Boolean);
|
||||
if (!list.length) return;
|
||||
|
||||
// ---- determinism: the same seed gives the same take, so a good clip can be re-shot ----------
|
||||
let seed = (Number(q.get('seed')) || 20260818) >>> 0;
|
||||
Math.random = () => {
|
||||
seed ^= seed << 13; seed >>>= 0;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5; seed >>>= 0;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
|
||||
// ---- the audio tap. Every voice in audio.js connects straight to `ac.destination`, so the whole
|
||||
// mix is grabbed by making `destination` a gain node that fans out to the real speakers AND to a
|
||||
// MediaStreamDestination. Patching the prototype here (this script runs before the game module)
|
||||
// means the game needs no master-bus refactor for a tool that ships to nobody.
|
||||
let tap = null;
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
// `destination` is defined on BaseAudioContext, not on AudioContext — walk the chain for it
|
||||
let proto = AC.prototype, desc = null;
|
||||
while (proto && !(desc = Object.getOwnPropertyDescriptor(proto, 'destination'))) proto = Object.getPrototypeOf(proto);
|
||||
const real = desc.get;
|
||||
Object.defineProperty(proto, 'destination', { configurable: true, get() {
|
||||
if (!this._promoBus) {
|
||||
this._promoBus = this.createGain();
|
||||
this._promoBus.connect(real.call(this));
|
||||
tap = this.createMediaStreamDestination();
|
||||
this._promoBus.connect(tap);
|
||||
}
|
||||
return this._promoBus;
|
||||
} });
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const T = () => window.T;
|
||||
|
||||
// ---- the two autopilots. The tennis one is the reachability soak's from test.js (auto-run plus a
|
||||
// fixed tap window); the gore one draws the slingshot at the nearest body. Both are mediocre on
|
||||
// purpose — a clip of a perfect player is a clip of nothing happening.
|
||||
let lastTap = 0;
|
||||
const pilotTennis = t => {
|
||||
const { G, ball, P, keyPressed } = T();
|
||||
if (G.modal) { G.modal = null; G.modalQ.length = 0; }
|
||||
if (G.phase === 'pretoss') { if (t - lastTap > 0.6) { lastTap = t; keyPressed('Space'); } }
|
||||
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
|
||||
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
|
||||
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
|
||||
if (d < 2.9 && d > 2.2) keyPressed('Space');
|
||||
}
|
||||
};
|
||||
const pilotGore = t => {
|
||||
const { G, P, zombies, TUNE, dragServe } = T();
|
||||
if (G.modal) { G.modal = null; G.modalQ.length = 0; }
|
||||
if (G.horde || G.mgT > 0 || t - lastTap < 0.45) return;
|
||||
let best = null, bd = 1e9;
|
||||
for (const z of zombies) {
|
||||
const d = Math.hypot(z.x - P.x, z.z - P.z);
|
||||
if (d < bd) { bd = d; best = z; }
|
||||
}
|
||||
if (!best) return;
|
||||
lastTap = t;
|
||||
const dx = best.x - P.x, dz = best.z - P.z, len = Math.hypot(dx, dz) || 1;
|
||||
dragServe({ mag: TUNE.pullFull + 6, fx: dx / len, fy: dz / len });
|
||||
};
|
||||
|
||||
// ---- THE SCENES. `secs` is the take; `at` fires staged events on the clock; `pilot` picks who
|
||||
// plays. Everything here is the real game reached through the real doors — nothing is faked for
|
||||
// the camera, which is the only way the footage stays honest when the game changes.
|
||||
const SCENES = {
|
||||
// SPLATTER GORE -----------------------------------------------------------------------------
|
||||
transition: { secs: 17, pilot: 'tennis', at: [ // the flagship: ordinary tennis, then the turn
|
||||
[0, () => T().start(null)],
|
||||
[4.5, () => { T().forceZombies(); }],
|
||||
] },
|
||||
horde: { secs: 14, pilot: 'gore', at: [
|
||||
[0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 6; }],
|
||||
[2.5, () => T().startHorde()],
|
||||
] },
|
||||
flamer: { secs: 12, pilot: null, at: [
|
||||
[0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 8; t.grantWeapon('flamer', 3); }],
|
||||
[1.0, () => { const t = T(); for (let i = 0; i < 9; i++) t.spawnZombie(-5 + i * 1.25, -9 + (i % 3) * 1.2, i % 3 ? 'a' : 'd'); }],
|
||||
[3.0, () => { const t = T(); t.selectWeapon(1 + t.G.rack.findIndex(s => s.id === 'flamer')); t.dragServe({ mag: 140, fx: 0, fy: -1 }); }],
|
||||
] },
|
||||
// THE BALL BOY, calmly mopping a massacre — the joke lands only if the court is a mess first,
|
||||
// so the pilot shoots for six seconds and is then switched off to let him work in peace.
|
||||
// ...and they die on the NEAR court on purpose: mopWorthIt only sends him for a stain on his
|
||||
// own side of the net, so a massacre at the far baseline is a massacre nobody mops.
|
||||
mopboy: { secs: 15, pilot: 'gore', pilotUntil: 6.5, at: [
|
||||
[0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 9; }],
|
||||
[0.8, () => { const t = T(); for (let i = 0; i < 8; i++) t.spawnZombie(-4.5 + i * 1.3, 15 + (i % 3) * 1.6, i % 4 ? 'a' : 'b'); }],
|
||||
[7.0, () => { const t = T(); t.G.boy = null; t.sendBoy(0, 18, true); }],
|
||||
] },
|
||||
death: { secs: 13, pilot: 'gore', at: [
|
||||
[0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 11; }],
|
||||
[1.0, () => { const t = T(); for (let i = 0; i < 7; i++) t.spawnZombie(-4 + i * 1.4, -3 - (i % 2), 'a'); }],
|
||||
[7.0, () => T().killPlayer()],
|
||||
] },
|
||||
// ZOMBIE TENNIS -----------------------------------------------------------------------------
|
||||
boss: { secs: 16, pilot: 'gore', at: [
|
||||
[0, () => { const t = T(); t.start(null); t.intoSplatter();
|
||||
t.G.runGames = 3 * t.TUNE.bossEvery; t.G.bossDue = 0;
|
||||
for (const id of ['bomb', 'triple', 'beacon', 'flamer', 'rocket']) t.grantWeapon(id, 9); } ],
|
||||
[1.2, () => T().startBossFight(3)],
|
||||
] },
|
||||
// THE OPPONENT BURSTS WHEN HIS GAME IS TAKEN, and that burst is the mode's whole payoff — but a
|
||||
// mediocre autopilot needs a minute to earn one and the clip is eighteen seconds. So every serve
|
||||
// is game point: the rally is real, the wait is not.
|
||||
match: { secs: 18, pilot: 'tennis', each: () => { const s = T().score; if (T().G.phase === 'pretoss') s.pts[0] = 3; }, at: [
|
||||
[0, () => { const t = T(); t.start(null); t.intoSplatter(); t.startMatch(); }],
|
||||
] },
|
||||
};
|
||||
|
||||
async function record(cv, secs) {
|
||||
const s = cv.captureStream(60);
|
||||
if (tap) for (const a of tap.stream.getAudioTracks()) s.addTrack(a);
|
||||
const rec = new MediaRecorder(s, { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 12e6 });
|
||||
const chunks = [];
|
||||
rec.ondataavailable = e => e.data.size && chunks.push(e.data);
|
||||
const done = new Promise(r => { rec.onstop = r; });
|
||||
rec.start();
|
||||
await sleep(secs * 1000);
|
||||
rec.stop();
|
||||
await done;
|
||||
return new Blob(chunks, { type: 'video/webm' });
|
||||
}
|
||||
|
||||
// PORTRAIT IS THE FORMAT, AND THE WINDOW IS NOT ASKED. The clip is the canvas BACKING STORE, not
|
||||
// the window, so the frame is pinned by giving the canvas a 9:16 CSS box (render.js measures the
|
||||
// box, not the viewport) — no window manager, no title bar, no maximised-on-launch surprise.
|
||||
// 270x480 logical at RES 4 (capture.py forces device-scale-factor 3, the dpr tier that picks 4)
|
||||
// is exactly 1080x1920 native: the file needs no upscale, so no filter ever touches the pixels.
|
||||
function frame() {
|
||||
// FULL FAT FOR THE CAMERA. lowPower defaults ON (RES capped at 3) because it is invisible on a
|
||||
// phone and buys 44% of the fill rate — but the clip is the one place the sharpness is judged
|
||||
// on a big screen, and 0 is what makes the backing store exactly 1080x1920 at dpr 3.
|
||||
T().TUNE.lowPower = 0;
|
||||
T().TUNE.pullHintAfter = 1e9; // ...and no ghost hand: the SWIPE TO DODGE demo is for a player
|
||||
// who has stalled, and on film it is just a hint over the art
|
||||
const st = document.createElement('style');
|
||||
st.textContent = 'canvas#c { width: 270px !important; height: 480px !important; top: 0 !important; }';
|
||||
document.head.appendChild(st);
|
||||
dispatchEvent(new Event('resize')); // ...and the ResizeObserver in render.js catches the box
|
||||
return sleep(400);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
while (!window.T || !window.T.SPR) await sleep(100);
|
||||
await frame();
|
||||
await sleep(1200); // let the boot settle: sprites decoded, theme running
|
||||
const name = list[0], sc = SCENES[name];
|
||||
if (!sc) { console.warn('no such scene', name); return next(); }
|
||||
const cv = document.getElementById('c');
|
||||
fetch('/log?' + encodeURIComponent(name + ': dpr ' + devicePixelRatio + ', viewport ' + innerWidth + 'x' + innerHeight + ', canvas ' + cv.width + 'x' + cv.height));
|
||||
const pilot = sc.pilot === 'tennis' ? pilotTennis : sc.pilot === 'gore' ? pilotGore : null;
|
||||
const fired = new Set();
|
||||
const t0 = performance.now();
|
||||
const loop = () => {
|
||||
const t = (performance.now() - t0) / 1000;
|
||||
for (const [at, fn] of sc.at) if (t >= at && !fired.has(at)) { fired.add(at); try { fn(); } catch (e) { console.error(name, at, e); } }
|
||||
if (t > 0.2) try {
|
||||
if (pilot && !(sc.pilotUntil && t > sc.pilotUntil)) pilot(t);
|
||||
if (sc.each) sc.each(t);
|
||||
} catch (e) { console.error(e); }
|
||||
if (t < sc.secs + 1) requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
sc.at[0][1](); // t=0 event before the recorder rolls: no black frames
|
||||
fired.add(sc.at[0][0]);
|
||||
await sleep(250);
|
||||
const blob = await record(cv, sc.secs);
|
||||
await fetch('/clip/' + name + '.webm', { method: 'POST', body: blob });
|
||||
next();
|
||||
}
|
||||
|
||||
function next() {
|
||||
const rest = list.slice(1);
|
||||
if (!rest.length) { fetch('/done', { method: 'POST' }); return; }
|
||||
location.replace('?clips=' + rest.join(',') + (q.get('seed') ? '&seed=' + q.get('seed') : ''));
|
||||
}
|
||||
|
||||
addEventListener('load', run);
|
||||
})();
|
||||
Reference in new issue
Block a user