diff --git a/vapor/scripts/promo/capture.ts b/vapor/scripts/promo/capture.ts new file mode 100644 index 00000000..e523c3f0 --- /dev/null +++ b/vapor/scripts/promo/capture.ts @@ -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 { + 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 { + 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 | null = null; + const nes = new NES({ + onFrame: (buf: ArrayLike) => { + frameBuffer = buf; + }, + onAudioSample: () => {}, + }); + nes.loadROM(Buffer.from(new Uint8Array(await Bun.file(rom).arrayBuffer())).toString("latin1")); + + const BTN: Record = { + [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)`); +} diff --git a/vapor/scripts/promo/music.ts b/vapor/scripts/promo/music.ts new file mode 100644 index 00000000..676f5697 --- /dev/null +++ b/vapor/scripts/promo/music.ts @@ -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)`); diff --git a/vapor/scripts/promo/promo.ts b/vapor/scripts/promo/promo.ts new file mode 100644 index 00000000..7b321824 --- /dev/null +++ b/vapor/scripts/promo/promo.ts @@ -0,0 +1,446 @@ +#!/usr/bin/env bun +// vapor/scripts/promo/promo.ts — render the Pocket Vapor promo video. +// +// bun vapor/scripts/promo/capture.ts # once: gameplay frames +// bun vapor/scripts/promo/music.ts # once: the chiptune bed +// bun vapor/scripts/promo/promo.ts # → dist/vapor/promo/pocket-vapor.mp4 +// +// 1920x1080 @ 60 fps, ~47 s, final cut — no appended end card; the PocketJS +// logo badge rides every frame instead. Frames are drawn with @napi-rs/canvas +// on the PocketJS brand field (dark #05070d, blueprint grid, corner glows) and +// streamed as raw RGBA into ffmpeg, muxed with the synthesized soundtrack. +// The gameplay segment starts exactly on bar 10 of the soundtrack, where the +// lead melody enters — keep promo timeline and music structure in sync. + +import { join } from "node:path"; +import { createCanvas, type SKRSContext2D } from "@napi-rs/canvas"; +import { FRAMES_PER_PRESS, LEAD, pressList, TOTAL_FRAMES } from "./capture.ts"; +import { Button } from "../../host/input.ts"; + +const ROOT = join(import.meta.dir, "..", "..", ".."); +const OUT = join(ROOT, "dist", "vapor", "promo"); +const W = 1920; +const H = 1080; +const FPS = 60; + +// ---- palette (site/outro visual system) ------------------------------------- +const FIELD = "#05070d"; +const GRID = "rgba(56, 189, 248, 0.055)"; +const TEXT = "#f1f5f9"; +const DIM = "#94a3b8"; +const FAINT = "#64748b"; +const CYAN = "#22d3ee"; +const BLUE = "#38bdf8"; +const EMERALD = "#34d399"; +const VIOLET = "#a78bfa"; +const AMBER = "#fbbf24"; + +const MONO = "Menlo, Monaco, monospace"; + +// ---- segment timeline -------------------------------------------------------- +const SEG = { + title: { start: 0, len: 210 }, + code: { start: 210, len: 450 }, + split: { start: 660, len: 492 }, + play: { start: 1152, len: 882 }, // frame 1152 = 19.2 s = soundtrack bar 10 (lead enters) + numbers: { start: 2034, len: 420 }, + close: { start: 2454, len: 360 }, +}; +const TOTAL = SEG.close.start + SEG.close.len; +if (SEG.play.len !== TOTAL_FRAMES) throw new Error("gameplay segment must match capture length"); + +// ---- drawing helpers ----------------------------------------------------------- +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); + +function background(): void { + ctx.fillStyle = FIELD; + ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = GRID; + ctx.lineWidth = 1; + for (let x = 0.5; x < W; x += 48) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); + } + for (let y = 0.5; y < H; y += 48) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + let g = ctx.createRadialGradient(0, 0, 0, 0, 0, 900); + g.addColorStop(0, "rgba(56, 189, 248, 0.14)"); + g.addColorStop(1, "rgba(56, 189, 248, 0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, W, H); + g = ctx.createRadialGradient(W, H, 0, W, H, 900); + g.addColorStop(0, "rgba(34, 211, 238, 0.12)"); + g.addColorStop(1, "rgba(34, 211, 238, 0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, W, H); +} + +function easeOut(t: number): number { + return 1 - (1 - t) ** 3; +} +/** staggered entrance: alpha + upward drift for element i at local frame t */ +function entrance(t: number, i: number, step = 14, dur = 22): { a: number; dy: number } { + const lt = (t - i * step) / dur; + if (lt <= 0) return { a: 0, dy: 24 }; + if (lt >= 1) return { a: 1, dy: 0 }; + return { a: easeOut(lt), dy: 24 * (1 - easeOut(lt)) }; +} + +function text(s: string, x: number, y: number, size: number, color: string, alpha = 1, align: CanvasTextAlign = "left", weight = ""): void { + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.font = `${weight ? weight + " " : ""}${size}px ${MONO}`; + ctx.textAlign = align; + ctx.fillText(s, x, y); + ctx.globalAlpha = 1; +} + +// minimal syntax highlighting: ordered token classes +const TOKEN_RULES: [RegExp, string][] = [ + [/^\/\/.*/, FAINT], + [/^\/\*.*/, FAINT], + [/^"[^"]*"/, AMBER], + [/^<\/?[A-Z][A-Za-z]*/, EMERALD], + [/^<\/?>|^ 0) { + let matched = false; + for (const [re, color] of TOKEN_RULES) { + const m = re.exec(rest); + if (m && m[0].length > 0) { + ctx.fillStyle = color; + ctx.fillText(m[0], cx, y); + cx += ctx.measureText(m[0]).width; + rest = rest.slice(m[0].length); + matched = true; + break; + } + } + if (!matched) break; + } + ctx.globalAlpha = 1; +} + +function panel(x: number, y: number, w: number, h: number, alpha = 1, stroke = "#1c2233"): void { + ctx.globalAlpha = alpha; + ctx.fillStyle = "rgba(8, 12, 22, 0.92)"; + ctx.strokeStyle = stroke; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, 14); + ctx.fill(); + ctx.stroke(); + ctx.globalAlpha = 1; +} + +// ---- gameplay frame loading --------------------------------------------------- +interface Screen { + name: string; + sub: string; + rom: string; + dir: string; + sw: number; sh: number; // source crop + scale: number; + color: string; +} +const SCREENS: Screen[] = [ + { name: "GAME BOY ADVANCE", sub: "ARM7TDMI · 16.8 MHz · 2001", rom: "9.1 KB ROM", dir: "gba", sw: 240, sh: 160, scale: 3, color: BLUE }, + { name: "GAME BOY", sub: "SM83 · 4.19 MHz · 1989", rom: "32 KB ROM", dir: "gb", sw: 160, sh: 144, scale: 3, color: EMERALD }, + { name: "NES", sub: "6502 · 1.79 MHz · 1983", rom: "40 KB ROM", dir: "nes", sw: 256, sh: 240, scale: 2, color: VIOLET }, +]; + +const frameCache = new Map>(); + +async function loadScreen(s: Screen, idx: number) { + const key = `${s.dir}/${idx}`; + const hit = frameCache.get(key); + if (hit) return hit; + frameCache.clear(); // only current frame set stays hot + const path = join(OUT, "frames", s.dir, `f${String(idx).padStart(5, "0")}.ppm`); + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + const head = new TextDecoder().decode(bytes.slice(0, 32)); + const m = head.match(/^P6\n(\d+) (\d+)\n255\n/); + if (!m) throw new Error(`bad ppm ${path}`); + const fw = Number(m[1]); + const off = m[0].length; + const c = createCanvas(s.sw, s.sh); + const cc = c.getContext("2d"); + const img = cc.createImageData(s.sw, s.sh); + for (let y = 0; y < s.sh; y++) { + for (let x = 0; x < s.sw; x++) { + const si = off + (y * fw + x) * 3; + const di = (y * s.sw + x) * 4; + img.data[di] = bytes[si]; + img.data[di + 1] = bytes[si + 1]; + img.data[di + 2] = bytes[si + 2]; + img.data[di + 3] = 255; + } + } + cc.putImageData(img, 0, 0); + frameCache.set(key, c); + return c; +} + +const PRESS_LABEL: Record = { + [Button.Up]: "UP", [Button.Down]: "DOWN", [Button.Left]: "LEFT", [Button.Right]: "RIGHT", + [Button.A]: "A", [Button.B]: "B", [Button.Start]: "START", [Button.Select]: "SELECT", +}; +const PRESSES = pressList(); + +// ---- segment renderers ---------------------------------------------------------- + +function segAlpha(t: number, len: number, fade = 14): number { + if (t < fade) return t / fade; + if (t > len - fade) return Math.max(0, (len - t) / fade); + return 1; +} + +function drawTitle(t: number): void { + const a = segAlpha(t, SEG.title.len); + ctx.globalAlpha = a; + const e0 = entrance(t, 0), e1 = entrance(t, 1), e2 = entrance(t, 2); + text("POCKET VAPOR", W / 2, 470 + e0.dy, 118, TEXT, a * e0.a, "center", "bold"); + ctx.fillStyle = EMERALD; + ctx.globalAlpha = a * e1.a; + ctx.fillRect(W / 2 - 260, 512, 520, 5); + text("Reactive JavaScript in 2 KB of RAM", W / 2, 590 + e1.dy, 40, EMERALD, a * e1.a, "center"); + text("GBA · GAME BOY · NES", W / 2, 668 + e2.dy, 30, DIM, a * e2.a, "center"); + ctx.globalAlpha = 1; +} + +const COMPONENT_CODE = [ + "export default () => {", + " const todos = ref([", + ' { text: "SHIP POCKET VAPOR", done: false },', + ' { text: "WRITE THE COMPILER", done: true },', + ' { text: "RUN ON A REAL GBA", done: false },', + " ]);", + " const remaining = computed(() =>", + " todos.value.filter((t) => !t.done).length);", + " const current = computed(() => filtered.value[cursor.value]);", + "", + " return (", + " <>", + ' ', + " {visible.value.map((t, i) => (", + " ", + " ))}", + " ", + " );", + "};", +]; + +function drawCode(t: number): void { + const a = segAlpha(t, SEG.code.len); + text("this is a real Vue component", W / 2, 130, 42, TEXT, a * entrance(t, 0).a, "center", "bold"); + text("ref · computed · JSX · imported from \"vue\"", W / 2, 185, 26, DIM, a * entrance(t, 1).a, "center"); + const px = 400, py = 240, pw = 1120, ph = 720; + panel(px, py, pw, ph, a); + const lineStep = 16; // one new line every 16 frames + const visible = Math.min(COMPONENT_CODE.length, Math.floor((t - 20) / lineStep) + 1); + for (let i = 0; i < visible; i++) { + const la = Math.min(1, (t - 20 - i * lineStep) / 10); + if (la > 0) codeLine(COMPONENT_CODE[i], px + 44, py + 62 + i * 32, 22, a * la); + } + if (visible < COMPONENT_CODE.length && t > 20) { + ctx.globalAlpha = a * (Math.floor(t / 16) % 2 ? 1 : 0.2); + ctx.fillStyle = EMERALD; + ctx.fillRect(px + 44, py + 44 + visible * 32, 12, 24); + ctx.globalAlpha = 1; + } +} + +const TS_SIDE = [ + "const filtered = computed(() =>", + " filter.value === 1", + " ? todos.value.filter((t) => !t.done)", + " : todos.value.filter((t) => t.done));", +]; +const C_SIDE = [ + "static void c_filtered_update(void) {", + " u8 i; c_filtered_v.len = 0;", + " for (i = 0; i < g_todos_len; i++) {", + " rec_todo *p = g_todos + (u16)i;", + " if (!p->done)", + " c_filtered_v.idx[c_filtered_v.len++] = i;", + " }", + "}", +]; + +function drawSplit(t: number): void { + const a = segAlpha(t, SEG.split.len); + text("the compiler turns reactivity into data", W / 2, 130, 42, TEXT, a * entrance(t, 0).a, "center", "bold"); + text("refs → dirty bits · dependencies → ROM bitmasks · filter/slice → index views", W / 2, 185, 25, DIM, a * entrance(t, 1).a, "center"); + const e2 = entrance(t, 2), e3 = entrance(t, 5); + panel(120, 260 + e2.dy, 820, 480, a * e2.a, "#274060"); + text("you write", 160, 320 + e2.dy, 22, BLUE, a * e2.a); + TS_SIDE.forEach((l, i) => codeLine(l, 160, 380 + e2.dy + i * 40, 23, a * e2.a)); + panel(980, 260 + e3.dy, 820, 480, a * e3.a, "#2a4a3a"); + text("it ships (generated, verbatim)", 1020, 320 + e3.dy, 22, EMERALD, a * e3.a); + C_SIDE.forEach((l, i) => codeLine(l, 1020, 380 + e3.dy + i * 40, 23, a * e3.a)); +} + +async function drawPlay(t: number): Promise { + const a = segAlpha(t, SEG.play.len, 10); + text("same component · same inputs · three consoles, live", W / 2, 110, 36, TEXT, a, "center", "bold"); + const layout = [ + { s: SCREENS[0], x: 56, w: 720, h: 480 }, + { s: SCREENS[1], x: 824, w: 480, h: 432 }, + { s: SCREENS[2], x: 1352, w: 512, h: 480 }, + ]; + const top = 260; + for (const { s, x, w, h } of layout) { + const y = top + (480 - h) / 2; + ctx.globalAlpha = a; + ctx.fillStyle = "#000"; + ctx.fillRect(x - 8, y - 8, w + 16, h + 16); + ctx.strokeStyle = "#1c2233"; + ctx.lineWidth = 2; + ctx.strokeRect(x - 8, y - 8, w + 16, h + 16); + const frame = await loadScreen(s, Math.min(t, TOTAL_FRAMES - 1)); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(frame, 0, 0, s.sw, s.sh, x, y, w, h); + text(s.name, x + w / 2, top - 40, 26, s.color, a, "center", "bold"); + text(s.sub, x + w / 2, top - 8, 19, FAINT, a, "center"); + text(s.rom, x + w / 2, y + h + 44, 24, s.color, a, "center", "bold"); + ctx.globalAlpha = 1; + } + // live button read-out from the deterministic schedule + const pi = Math.floor((t - LEAD) / FRAMES_PER_PRESS); + const inHold = (t - LEAD) % FRAMES_PER_PRESS < 14; + if (t >= LEAD && pi >= 0 && pi < PRESSES.length && inHold) { + const label = PRESS_LABEL[PRESSES[pi]]; + panel(W / 2 - 130, 830, 260, 76, a, "#274060"); + text(label, W / 2, 882, 34, CYAN, a, "center", "bold"); + } + const phase = + t < LEAD ? "booted. every ref is a dirty bit now" : + pi < 4 ? "toggling a todo: one mask test, one row repaint" : + pi < 7 ? "cycling a computed filter view" : + pi < 24 ? "typing into a reactive string ref, one glyph at a time" : + "saved. push() into a fixed pool, remaining recomputed"; + text(phase, W / 2, 970, 25, DIM, a, "center"); +} + +function drawNumbers(t: number): void { + const a = segAlpha(t, SEG.numbers.len); + text("the receipts", W / 2, 160, 42, TEXT, a * entrance(t, 0).a, "center", "bold"); + const rows = [ + ["app state, compiled", "940 bytes", EMERALD], + ["Vue Vapor runtime (prod build)", "218 KB", BLUE], + ["entire NES work RAM", "2 KB", VIOLET], + ["assertions: real Vue vs three ROMs, every keypress", "7,264", CYAN], + ]; + rows.forEach(([k, v, c], i) => { + const e = entrance(t, 2 + i * 2); + const y = 300 + i * 150 + e.dy; + text(k as string, 240, y, 30, DIM, a * e.a); + text(v as string, 1680, y, 56, c as string, a * e.a, "right", "bold"); + ctx.globalAlpha = a * e.a * 0.35; + ctx.strokeStyle = "#1c2233"; + ctx.beginPath(); ctx.moveTo(240, y + 40); ctx.lineTo(1680, y + 40); ctx.stroke(); + ctx.globalAlpha = 1; + }); + text("the same tape, the same cells, every commit", W / 2, 950 + entrance(t, 11).dy, 26, FAINT, a * entrance(t, 11).a, "center"); +} + +function drawClose(t: number): void { + const a = segAlpha(t, SEG.close.len, 20); + const e0 = entrance(t, 0), e1 = entrance(t, 2), e2 = entrance(t, 4); + text("POCKET VAPOR", W / 2, 440 + e0.dy, 84, TEXT, a * e0.a, "center", "bold"); + text("pocketjs.dev/blog/pocket-vapor", W / 2, 560 + e1.dy, 38, EMERALD, a * e1.a, "center"); + text("github.com/pocket-stack/pocketjs", W / 2, 625 + e2.dy, 30, DIM, a * e2.a, "center"); +} + +// ---- logo badge (every frame) ------------------------------------------------------ +// The PocketJS lens/viewfinder glyph, redrawn in vectors from the site's +// brand SVG (same geometry as skills/pocketjs-video-outro/assets/outro.html): +// 32x32 viewBox — rounded-rect edge, lens dot at (10,16), two bars. +function drawLogo(x0: number, y0: number, size: number): void { + const u = size / 32; + const grad = (x1: number, y1: number, x2: number, y2: number, stops: [number, string][]) => { + const g = ctx.createLinearGradient(x0 + x1 * u, y0 + y1 * u, x0 + x2 * u, y0 + y2 * u); + for (const [o, c] of stops) g.addColorStop(o, c); + return g; + }; + ctx.lineJoin = "round"; + ctx.strokeStyle = grad(4, 4, 28, 28, [ + [0, "#eef6ff"], [0.38, "#b7c8e2"], [0.58, "#7487a0"], [0.78, "#aec0d6"], [1, "#dbe8f6"], + ]); + ctx.lineWidth = 2.6 * u; + ctx.beginPath(); + ctx.roundRect(x0 + 2 * u, y0 + 6 * u, 28 * u, 20 * u, 6 * u); + ctx.stroke(); + ctx.fillStyle = grad(7, 13, 13, 19, [[0, "#e4edf8"], [0.55, "#a7b8cf"], [1, "#53677f"]]); + ctx.beginPath(); + ctx.arc(x0 + 10 * u, y0 + 16 * u, 3.1 * u, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = grad(16, 12, 24, 20, [[0, "#d7e3f1"], [1, "#71849d"]]); + ctx.beginPath(); + ctx.roundRect(x0 + 16 * u, y0 + 12.6 * u, 10 * u, 2.2 * u, 1.1 * u); + ctx.fill(); + ctx.beginPath(); + ctx.roundRect(x0 + 16 * u, y0 + 17.2 * u, 6.5 * u, 2.2 * u, 1.1 * u); + ctx.fill(); +} + +function badge(): void { + ctx.globalAlpha = 0.92; + drawLogo(48, 38, 44); + ctx.globalAlpha = 1; + text("PocketJS", 106, 69, 24, DIM, 0.92); +} + +// ---- render loop ----------------------------------------------------------------- +const mp4 = join(OUT, "pocket-vapor.mp4"); +const ff = Bun.spawn( + [ + "ffmpeg", "-y", + "-f", "rawvideo", "-pix_fmt", "rgba", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "pipe:0", + "-i", join(OUT, "music.wav"), + "-map", "0:v", "-map", "1:a", + "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", + // no -t / -shortest: music.wav is synthesized to exactly TOTAL/FPS seconds, + // and a duration cap makes ffmpeg close stdin before the writer finishes (EPIPE) + "-c:a", "aac", "-b:a", "192k", mp4, + ], + { stdin: "pipe", stdout: "ignore", stderr: Bun.file(join(OUT, "ffmpeg.log")) }, +); + +const t0 = Date.now(); +for (let f = 0; f < TOTAL; f++) { + background(); + if (f < SEG.code.start) drawTitle(f - SEG.title.start); + else if (f < SEG.split.start) drawCode(f - SEG.code.start); + else if (f < SEG.play.start) drawSplit(f - SEG.split.start); + else if (f < SEG.numbers.start) await drawPlay(f - SEG.play.start); + else if (f < SEG.close.start) drawNumbers(f - SEG.numbers.start); + else drawClose(f - SEG.close.start); + badge(); + + const img = (ctx as SKRSContext2D).getImageData(0, 0, W, H); + // write() under backpressure returns a Promise; not awaiting it TRUNCATES the + // stream mid-frame (rawvideo: "packet size < expected frame_size", then EPIPE) + await ff.stdin.write(img.data); + await ff.stdin.flush(); + if (f % 300 === 0) console.log(`frame ${f}/${TOTAL} (${((Date.now() - t0) / 1000).toFixed(0)}s)`); +} +await ff.stdin.end(); +await ff.exited; +console.log(`${mp4} rendered in ${((Date.now() - t0) / 1000).toFixed(0)}s`); diff --git a/vapor/tests/harness/mgba_runner.c b/vapor/tests/harness/mgba_runner.c index 01d5a7c6..d19fe627 100644 --- a/vapor/tests/harness/mgba_runner.c +++ b/vapor/tests/harness/mgba_runner.c @@ -11,6 +11,9 @@ * R read 1/2/4 bytes little-endian * D read len bytes, emit as hex string * S screenshot (PPM P6) + * K set held keys (no frames run) + * M movie: run n frames, dumping every one + * as NNNNN.ppm (for video capture) * Output: one JSON object on stdout: {"ok":true,"reads":{...}}. */ #include @@ -140,6 +143,21 @@ int main(int argc, char **argv) { char path[512]; sscanf(line + 1, "%511s", path); screenshot(path); + } else if (op == 'K') { + unsigned mask = 0; + sscanf(line + 1, "%x", &mask); + core->setKeys(core, mask); + } else if (op == 'M') { + char prefix[400]; + char path[512]; + int n = 0, i; + static int movie_at = 0; + sscanf(line + 1, "%d %399s", &n, prefix); + for (i = 0; i < n; i++) { + core->runFrame(core); + snprintf(path, sizeof path, "%s%05d.ppm", prefix, movie_at++); + screenshot(path); + } } } printf("}}\n");