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
+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