Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions vapor/scripts/promo/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env bun
// vapor/scripts/promo/capture.ts — capture synchronized gameplay footage.
//
// Runs the same choreography (navigate, toggle, cycle filters, open the
// editor, type "HN" with the glyph picker, save, toggle it done) on all
// three consoles, dumping EVERY video frame as PPM. All three use the same
// 24-frames-per-press schedule, so the composited side-by-side stays in
// lockstep. Frames land in dist/vapor/promo/frames/{gba,gb,nes}/.

import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { $ } from "bun";
import { Controller, NES } from "jsnes";
import { compileVaporApp, type VaporTargetName } from "../../compiler/compile.ts";
import { buildRom } from "../../compiler/rom.ts";
import { Button } from "../../host/input.ts";

const ROOT = join(import.meta.dir, "..", "..", "..");
const OUT = join(ROOT, "dist", "vapor", "promo");
const ENTRY = join(import.meta.dir, "..", "..", "examples", "todo", "todo.tsx");
const MGBA = join(import.meta.dir, "..", "..", "tests", "harness", "mgba_runner");

const HOLD = 14;
const GAP = 10;
export const FRAMES_PER_PRESS = HOLD + GAP;
export const LEAD = 90;
export const TAIL = 120;

const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789";

/** The shared choreography, as a flat list of button ids. */
export function pressList(): number[] {
const p: number[] = [];
p.push(Button.Down, Button.Down, Button.A, Button.Up);
p.push(Button.Right, Button.Right, Button.Right); // filters: ACTIVE, DONE, ALL
p.push(Button.Start); // editor
let at = 0;
for (const ch of "HN") {
const t = GLYPHS.indexOf(ch);
for (let i = 0; i < (t - at + GLYPHS.length) % GLYPHS.length; i++) p.push(Button.Right);
p.push(Button.A);
at = t;
}
p.push(Button.Start); // save
p.push(Button.Down, Button.Down, Button.Down); // cursor onto HN
p.push(Button.A); // toggle it done
return p;
}

export const TOTAL_FRAMES = LEAD + pressList().length * FRAMES_PER_PRESS + TAIL;

async function captureMgba(target: "gba" | "gb", preRoll: number): Promise<void> {
const dir = join(OUT, "frames", target);
mkdirSync(dir, { recursive: true });
const app = compileVaporApp(ENTRY, await Bun.file(ENTRY).text(), "VAPOR TODO", target);
const rom = join(OUT, `todo.${target}`);
await buildRom(app, target as VaporTargetName, rom);

const lines: string[] = [`A ${preRoll}`, `M ${LEAD} ${dir}/f`];
for (const b of pressList()) {
lines.push(`K ${(1 << b).toString(16)}`);
lines.push(`M ${HOLD} ${dir}/f`);
lines.push(`K 0`);
lines.push(`M ${GAP} ${dir}/f`);
}
lines.push(`M ${TAIL} ${dir}/f`);
const sc = join(OUT, `capture-${target}.txt`);
await Bun.write(sc, lines.join("\n") + "\n");
await $`${MGBA} ${rom} ${sc}`.quiet();
console.log(`${target}: ${TOTAL_FRAMES} frames`);
}

async function captureNes(): Promise<void> {
const dir = join(OUT, "frames", "nes");
mkdirSync(dir, { recursive: true });
const app = compileVaporApp(ENTRY, await Bun.file(ENTRY).text(), "VAPOR TODO", "nes");
const rom = join(OUT, "todo.nes");
await buildRom(app, "nes", rom);

let frameBuffer: ArrayLike<number> | null = null;
const nes = new NES({
onFrame: (buf: ArrayLike<number>) => {
frameBuffer = buf;
},
onAudioSample: () => {},
});
nes.loadROM(Buffer.from(new Uint8Array(await Bun.file(rom).arrayBuffer())).toString("latin1"));

const BTN: Record<number, number> = {
[Button.A]: Controller.BUTTON_A,
[Button.B]: Controller.BUTTON_B,
[Button.Select]: Controller.BUTTON_SELECT,
[Button.Start]: Controller.BUTTON_START,
[Button.Right]: Controller.BUTTON_RIGHT,
[Button.Left]: Controller.BUTTON_LEFT,
[Button.Up]: Controller.BUTTON_UP,
[Button.Down]: Controller.BUTTON_DOWN,
};

let at = 0;
const dump = async () => {
if (!frameBuffer) return;
const w = 256;
const h = 240;
const header = `P6\n${w} ${h}\n255\n`;
const out = new Uint8Array(header.length + w * h * 3);
out.set(new TextEncoder().encode(header), 0);
let o = header.length;
for (let i = 0; i < w * h; i++) {
const p = (frameBuffer as number[])[i];
out[o++] = p & 0xff;
out[o++] = (p >> 8) & 0xff;
out[o++] = (p >> 16) & 0xff;
}
await Bun.write(join(dir, `f${String(at++).padStart(5, "0")}.ppm`), out);
};
const run = async (n: number, record: boolean) => {
for (let i = 0; i < n; i++) {
nes.frame();
if (record) await dump();
}
};

await run(90, false); // pre-roll
await run(LEAD, true);
for (const b of pressList()) {
nes.buttonDown(1, BTN[b] as never);
await run(HOLD, true);
nes.buttonUp(1, BTN[b] as never);
await run(GAP, true);
}
await run(TAIL, true);
console.log(`nes: ${TOTAL_FRAMES} frames`);
}

if (import.meta.main) {
await captureMgba("gba", 20);
await captureMgba("gb", 150);
await captureNes();
console.log(`total per console: ${TOTAL_FRAMES} frames (${(TOTAL_FRAMES / 60).toFixed(1)}s)`);
}
156 changes: 156 additions & 0 deletions vapor/scripts/promo/music.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env bun
// vapor/scripts/promo/music.ts — an original chiptune/electro bed for the
// promo video, synthesized from scratch (square arps, triangle bass, noise
// hats, sine kick), so the soundtrack is as engine-made as the footage.
//
// 125 BPM, A minor, Am F C G. Structure keyed to the storyboard — the lead
// melody MUST enter at bar 10 (19.2 s), the exact frame the three-console
// gameplay starts (promo.ts SEG.play.start = 1152). Keep them in sync.
// bars 1-4 intro: arp + soft pad (title, component code)
// bars 5-8 + hats and bass (code, TS -> C split)
// bars 9-10 + kick, groove settles (split lands)
// bars 11-18 full groove + lead melody (three-console gameplay)
// bars 19-21 breakdown, kick softens (numbers)
// bars 22-24 resolve and fade (close)

import { join } from "node:path";

const SR = 44100;
const BPM = 125;
const BEAT = 60 / BPM; // 0.48 s
const BAR = BEAT * 4;
const BARS = 24;
const DUR = 2814 / 60; // == the promo timeline (TOTAL frames / fps); trailing 0.8 s rings out
const N = Math.floor(SR * DUR);

// A minor: chord roots (Hz) for Am, F, C, G (low octave)
const A2 = 110.0, F2 = 87.31, C3 = 130.81, G2 = 98.0;
const CHORDS: number[][] = [
[A2, A2 * 2 ** (3 / 12), A2 * 2 ** (7 / 12)], // Am: A C E
[F2, F2 * 2 ** (4 / 12), F2 * 2 ** (7 / 12)], // F: F A C
[C3, C3 * 2 ** (4 / 12), C3 * 2 ** (7 / 12)], // C: C E G
[G2, G2 * 2 ** (4 / 12), G2 * 2 ** (7 / 12)], // G: G B D
];
// A minor pentatonic for the lead (one octave up + extensions)
const PENTA = [440.0, 523.25, 587.33, 659.25, 783.99, 880.0];

const buf = new Float32Array(N);

function square(ph: number, duty = 0.5): number {
return (ph % 1) < duty ? 1 : -1;
}
function tri(ph: number): number {
const p = ph % 1;
return p < 0.5 ? 4 * p - 1 : 3 - 4 * p;
}
let noiseState = 0x1234;
function noise(): number {
noiseState ^= noiseState << 13; noiseState &= 0xffffffff;
noiseState ^= noiseState >>> 17;
noiseState ^= noiseState << 5; noiseState &= 0xffffffff;
return ((noiseState >>> 0) / 0xffffffff) * 2 - 1;
}

/** Add one voiced note: simple attack/decay envelope. */
function note(
start: number, dur: number, freq: number, amp: number,
osc: (ph: number) => number, attack = 0.005, release = 0.05,
): void {
const s0 = Math.floor(start * SR);
const n = Math.floor(dur * SR);
for (let i = 0; i < n && s0 + i < N; i++) {
const t = i / SR;
let env = 1;
if (t < attack) env = t / attack;
else if (t > dur - release) env = Math.max(0, (dur - t) / release);
buf[s0 + i] += osc(freq * t) * amp * env;
}
}

function kick(start: number, amp = 0.9): void {
const s0 = Math.floor(start * SR);
const n = Math.floor(0.16 * SR);
for (let i = 0; i < n && s0 + i < N; i++) {
const t = i / SR;
const f = 40 + 120 * Math.exp(-t * 30); // pitch drop 160 -> 40 Hz
const env = Math.exp(-t * 18);
buf[s0 + i] += Math.sin(2 * Math.PI * f * t) * amp * env;
}
}

function hat(start: number, amp = 0.16, dur = 0.03): void {
const s0 = Math.floor(start * SR);
const n = Math.floor(dur * SR);
for (let i = 0; i < n && s0 + i < N; i++) {
const env = Math.exp((-i / SR) * 120);
buf[s0 + i] += noise() * amp * env;
}
}

for (let bar = 0; bar < BARS; bar++) {
const t0 = bar * BAR;
const chord = CHORDS[bar % 4];
const groove = bar >= 8 && bar < 21;
const full = bar >= 10 && bar < 18; // bar 10 = 19.2 s = gameplay in
const fading = bar >= 21;

// arp: 16th-note square through chord tones, two octaves
const arpAmp = fading ? 0.05 : bar < 4 ? 0.07 : 0.09;
for (let s = 0; s < 16; s++) {
const tone = chord[s % 3] * (s % 6 >= 3 ? 4 : 2);
note(t0 + s * (BEAT / 4), BEAT / 4 - 0.01, tone, arpAmp, (p) => square(p, 0.25));
}
// pad: soft detuned triangle chord, whole bar
for (const f of chord) {
note(t0, BAR, f * 2, 0.035, tri, 0.4, 0.6);
note(t0, BAR, f * 2 * 1.003, 0.028, tri, 0.4, 0.6);
}
// bass: triangle 8ths on the root
if (bar >= 4 && !fading) {
for (let s = 0; s < 8; s++) {
note(t0 + s * (BEAT / 2), BEAT / 2 - 0.03, chord[0], s % 2 ? 0.16 : 0.22, tri, 0.004, 0.03);
}
}
// hats: 16ths, off-beat accents
if (bar >= 4 && !fading) {
for (let s = 0; s < 16; s++) hat(t0 + s * (BEAT / 4), s % 4 === 2 ? 0.2 : 0.09);
}
// kick: four on the floor, softened for the numbers breakdown
if (groove) for (let b = 0; b < 4; b++) kick(t0 + b * BEAT, bar >= 18 ? 0.55 : 0.85);
// lead: a pentatonic phrase over the gameplay section, with echo
if (full) {
const PHRASE = [0, 2, 3, 2, 4, 3, 2, 1]; // indices into PENTA
for (let s = 0; s < 8; s++) {
const f = PENTA[PHRASE[(s + bar * 3) % 8]];
const st = t0 + s * (BEAT / 2);
note(st, BEAT / 2 - 0.05, f, 0.11, (p) => square(p, 0.5), 0.008, 0.08);
note(st + BEAT * 0.75, BEAT / 2 - 0.05, f, 0.045, (p) => square(p, 0.5), 0.008, 0.08); // echo
}
}
}

// master: gentle fade-in, fade-out over the last 2.5 s, soft-clip
const fadeIn = Math.floor(0.4 * SR);
const fadeOut = Math.floor(2.5 * SR);
for (let i = 0; i < N; i++) {
let v = buf[i];
if (i < fadeIn) v *= i / fadeIn;
if (i > N - fadeOut) v *= (N - i) / fadeOut;
buf[i] = Math.tanh(v * 1.4) * 0.85;
}

// 16-bit PCM WAV
const pcm = new Int16Array(N);
for (let i = 0; i < N; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(buf[i] * 32767)));
const data = new Uint8Array(pcm.buffer);
const header = new ArrayBuffer(44);
const dv = new DataView(header);
const w = (o: number, s: string) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
w(0, "RIFF"); dv.setUint32(4, 36 + data.length, true); w(8, "WAVE");
w(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
dv.setUint32(24, SR, true); dv.setUint32(28, SR * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
w(36, "data"); dv.setUint32(40, data.length, true);

const out = join(import.meta.dir, "..", "..", "..", "dist", "vapor", "promo", "music.wav");
await Bun.write(out, new Blob([header, data]));
console.log(`${out} (${DUR.toFixed(1)}s)`);
Loading