diff --git a/pop/lib/preview-shared.mjs b/pop/lib/preview-shared.mjs index 15edf68815..70013f1c9b 100644 --- a/pop/lib/preview-shared.mjs +++ b/pop/lib/preview-shared.mjs @@ -13,9 +13,20 @@ import { spawnSync, spawn } from "node:child_process"; import { mkdirSync, existsSync } from "node:fs"; import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadImage } from "canvas"; -export const YWFT_PATH = `${homedir()}/Library/Fonts/ywft-processing-bold.ttf`; +// The typeface is installed on the machines that render regularly, but it +// also ships in-repo as a Menu Band resource. Prefer the installed copy +// (a user may have a newer cut) and fall back to the repo's, so a render +// works on any checkout without a font-install step first. +const YWFT_INSTALLED = `${homedir()}/Library/Fonts/ywft-processing-bold.ttf`; +const YWFT_VENDORED = resolve( + dirname(fileURLToPath(import.meta.url)), "..", "..", + "slab/menuband/Sources/MenuBand/Resources/ywft-processing-bold.ttf", +); +export const YWFT_PATH = existsSync(YWFT_INSTALLED) ? YWFT_INSTALLED : YWFT_VENDORED; export const AUDIO_SR_DEFAULT = 4000; // 4 kHz mono is enough for waveform display export const ENV_FPS_DEFAULT = 60; // RMS envelope at 60 Hz drives bounces diff --git a/pop/menuband/bin/chrome-reel.mjs b/pop/menuband/bin/chrome-reel.mjs index d9dd27905a..55f57d8d1c 100644 --- a/pop/menuband/bin/chrome-reel.mjs +++ b/pop/menuband/bin/chrome-reel.mjs @@ -86,13 +86,20 @@ const wmCtx = wmCanvas.getContext("2d"); const charTintCanvas = createCanvas(2, 2); const charTintCtx = charTintCanvas.getContext("2d"); -// scene tint (from the sim's meta) brightened toward white through the scene +// Scene tint (from the sim's meta), DEEPENED toward black through the scene. +// +// These tints were picked to glow against the old aubergine desktop, and the +// chrome used to brighten them toward white as a scene played out. The reel +// is light mode now: the desktop is pale lilac, so a tint walking toward +// white walks straight into the background. Pull them down instead — the +// stamps and the climbing title read as ink on paper. +const TINT_DEEPEN = 0.55; function sectionTcRgb(t) { const s = META.slides.find((x) => t >= x.from && t < x.to) ?? META.slides.at(-1); - let [r, g, b] = s.tint ?? [180, 180, 180]; + let [r, g, b] = (s.tint ?? [180, 180, 180]).map((v) => v * TINT_DEEPEN); const lp = Math.max(0, Math.min(1, (t - s.from) / Math.max(0.001, s.to - s.from))); const k = 0.30 * lp; - return [Math.round(r + (255 - r) * k), Math.round(g + (255 - g) * k), Math.round(b + (255 - b) * k)]; + return [Math.round(r * (1 - k)), Math.round(g * (1 - k)), Math.round(b * (1 - k))]; } function palsTinted(c, src) { const ww = src.width, wh = src.height; diff --git a/pop/menuband/bin/sim.mjs b/pop/menuband/bin/sim.mjs index 41fc241905..52f6740271 100644 --- a/pop/menuband/bin/sim.mjs +++ b/pop/menuband/bin/sim.mjs @@ -47,6 +47,8 @@ const score = JSON.parse(readFileSync(NOTES_PATH, "utf8")); const TOTAL = score.durationSec; const FRAMES = Math.round(TOTAL * FPS); const leadNotes = (score.notes || []).filter((n) => n.lane === "lead"); +/// One 3/4 bar. The hero band bobs on it, so it breathes with the waltz. +const BAR_SEC = score.barSec || 1.3; // ── the exact Menu Band icon keyboard palette (AboutWindow.swift) ────────── // 5 chromatic keys C C♯ D D♯ E, each its own vivid tint. @@ -59,37 +61,18 @@ const KEY_COLORS = [ ]; const SQ_TL = [211, 80, 196], SQ_BR = [67, 29, 113]; // squircle gradient const CREAM = [246, 242, 232], KEYBLACK = [27, 26, 25], EDGE = [40, 28, 22]; -const AC_PURPLE = "rgb(167,139,250)"; -// Real scannable QR to the landing page (qrencode → PNG → loaded once). -const QR_PNG = `${OUT}/qr-menuband.png`; -spawnSync("qrencode", ["-o", QR_PNG, "-s", "16", "-m", "1", "-l", "H", "https://menuband.app"]); -const qrImg = existsSync(QR_PNG) ? await loadImage(QR_PNG) : null; const canvas = createCanvas(W, H); const ctx = canvas.getContext("2d"); const rgb = (a, al = 1) => `rgba(${a[0]},${a[1]},${a[2]},${al})`; -// Real macOS menu-bar glyphs — the same SF Symbol captures the App Store -// screenshot pipeline composites (slab/menuband/bin/menubar-assets), tinted -// white here for the reel's dark translucent bar. Real symbols at their true -// aspect ratios, not hand-drawn approximations. -const GLYPHS = resolve(HERE, "../../../slab/menuband/bin/menubar-assets"); -async function loadTinted(file, color = "rgba(255,255,255,0.95)") { - const p = `${GLYPHS}/${file}`; - if (!existsSync(p)) return null; - const img = await loadImage(p); - const c = createCanvas(img.width, img.height), x = c.getContext("2d"); - x.drawImage(img, 0, 0); - x.globalCompositeOperation = "source-in"; - x.fillStyle = color; x.fillRect(0, 0, img.width, img.height); - return c; -} -const sfApple = await loadTinted("sf-apple.png"); -const sfControl = await loadTinted("sf-control.png"); -const sfWifi = await loadTinted("sf-wifi.png"); -const sfBattery = await loadTinted("sf-battery.png"); +// LIGHT MODE for the whole reel: a pale desktop and dark chrome. It matches +// the strip, which is captured with `--light`, and the About / keymap windows, +// which the app already renders light unless asked for `--dark`. Ink is the +// one dark value everything on the desktop is drawn in. +const INK = "rgba(20,18,28,0.92)"; function roundRect(x, y, w, h, r) { const rr = Math.min(r, w / 2, h / 2); @@ -152,15 +135,17 @@ function drawIcon(x, y, px, litKeys) { // ── desktop wallpaper (soft AC purple gradient) ──────────────────────────── function drawDesktop() { + // The same purple wallpaper, lifted into light appearance — pale lilac + // rather than aubergine, so black keys and dark chrome read against it. const g = ctx.createLinearGradient(0, 0, W, H); - g.addColorStop(0, "rgb(58,42,98)"); - g.addColorStop(0.5, "rgb(92,60,140)"); - g.addColorStop(1, "rgb(150,96,180)"); + g.addColorStop(0, "rgb(238,232,246)"); + g.addColorStop(0.5, "rgb(216,203,234)"); + g.addColorStop(1, "rgb(190,170,214)"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); // faint diagonal sheen const s = ctx.createLinearGradient(0, H, W, 0); s.addColorStop(0, "rgba(255,255,255,0)"); - s.addColorStop(0.6, "rgba(255,255,255,0.05)"); + s.addColorStop(0.6, "rgba(255,255,255,0.28)"); s.addColorStop(1, "rgba(255,255,255,0)"); ctx.fillStyle = s; ctx.fillRect(0, 0, W, H); } @@ -172,9 +157,11 @@ function drawDesktop() { // captured pixel-exact from the Swift KeyboardIconRenderer via // `MenuBand --render-menubar` (short keys + the dynamic icon on the right), // one capture per distinct lit-note set, composited per frame. -const MBAR_H = 56; const MENUBAR_BIN = `${REPO}/slab/menuband/.build/debug/MenuBand`; const MB_CACHE = `${OUT}/menubar-frames`; +/// Where each key sits across the strip + its ROYGBIV color, straight from the +/// app's own geometry (`--emit-keys`). Written by prerenderMenubars. +const KEYS_JSON = `${OUT}/menubar-keys.json`; mkdirSync(MB_CACHE, { recursive: true }); // fold a melody MIDI note into the menubar piano's displayed range (C4..B5) @@ -194,14 +181,32 @@ async function prerenderMenubars() { console.warn(` ⚠ MenuBand binary missing (${MENUBAR_BIN}) — run \`swift build\` in slab/menuband; menubar will be blank`); return; } + // Ask the app where its keys are, once. Unconditional — the captures are + // cached by note-set, so a cache hit would otherwise skip this and leave the + // particles with no geometry to fire from. + const probe = spawnSync(MENUBAR_BIN, ["--render-menubar", "--notes", "", + "--scale", "1", "--light", "--no-settings", "--emit-keys", KEYS_JSON, + "--out", `${MB_CACHE}/.probe.png`], { stdio: ["ignore", "ignore", "pipe"] }); + if (probe.status !== 0) { + console.error(` ✗ key geometry: ${probe.stderr?.toString().slice(-160)}`); + } + const keys = new Set(); for (let fi = 0; fi < FRAMES; fi++) keys.add(barKey(barNotesAt(fi / FPS))); console.log(`▸ capturing ${keys.size} real menubar graphics via MenuBand --render-menubar …`); for (const key of keys) { const png = `${MB_CACHE}/mb-${key === "" ? "idle" : key.replace(/,/g, "-")}.png`; if (!existsSync(png)) { + // No `--voice`: a voiceLabel would be drawn verbatim in the badge, and + // the badge is gone anyway — `--no-settings` strips the whole chip so + // the band is symmetrical and centers cleanly. Light mode + a pinned + // black accent, so the render never depends on the rendering machine's + // system accent. Scale 6 because the bare keyboard is a smaller native + // canvas than the full icon, and the hero draws it nearly frame-wide. const r = spawnSync(MENUBAR_BIN, ["--render-menubar", "--notes", key, - "--voice", "Glockenspiel", "--scale", "4", "--out", png], { stdio: ["ignore", "ignore", "pipe"] }); + "--program", "9", "--scale", "6", "--light", "--accent", "#000000", + "--no-settings", "--key-accent", "--out", png], + { stdio: ["ignore", "ignore", "pipe"] }); if (r.status !== 0) console.error(` ✗ capture ${key}: ${r.stderr?.toString().slice(-160)}`); } if (existsSync(png)) menubarCache.set(key, await loadImage(png)); @@ -209,48 +214,22 @@ async function prerenderMenubars() { menubarIdle = menubarCache.get("") || [...menubarCache.values()][0] || null; } -function drawMenubar(t) { - ctx.fillStyle = "rgba(24,20,34,0.72)"; - ctx.fillRect(0, 0, W, MBAR_H); - const cy = MBAR_H / 2; - // Real logo (SF apple.logo capture), true aspect. - if (sfApple) { - const ah = 24, aw2 = ah * sfApple.width / sfApple.height; - ctx.drawImage(sfApple, 26, cy - ah / 2 - 1, aw2, ah); - } - ctx.fillStyle = "rgba(255,255,255,0.96)"; ctx.textBaseline = "middle"; ctx.font = "700 22px MBSans"; - ctx.fillText("Menu Band", 66, cy + 1); - // Right cluster, right → left, true macOS order: - // clock · battery · Wi-Fi · Control Center · the REAL piano strip. - ctx.textAlign = "right"; ctx.font = "500 21px MBSans"; ctx.fillStyle = "rgba(255,255,255,0.92)"; - ctx.fillText("9:41", W - 22, cy + 1); - const clockW = ctx.measureText("9:41").width; - ctx.textAlign = "left"; - let rx = W - 22 - clockW - 20; - for (const g of [sfBattery, sfWifi, sfControl]) { - if (!g) continue; - const gh = 19, gw = gh * g.width / g.height; - ctx.drawImage(g, rx - gw, cy - gh / 2, gw, gh); - rx -= gw + 18; - } - // The REAL captured menubar piano item, keys lighting with the waltz. - // Height-fit, but never overlapping the app title on the left — shrink to - // the available run between the title and the system glyphs if needed. - const img = menubarCache.get(barKey(barNotesAt(t))) || menubarIdle; - if (img) { - let ih = MBAR_H - 12, iw = ih * img.width / img.height; - const avail = rx - 4 - 210; // 210 ≈ right edge of "Menu Band" - if (iw > avail) { iw = avail; ih = iw * img.height / img.width; } - ctx.drawImage(img, rx - iw - 4, (MBAR_H - ih) / 2, iw, ih); - } -} +// No macOS menu bar in the reel — no Apple logo, no clock, no Wi-Fi, no +// battery, no miniature strip up top. The band itself is the subject, and it +// climbs to where the menu bar would have been. // ── note-particle system (vector eighth-notes puffing up) ────────────────── const particles = []; -function spawnNote(cx, cy, color) { +/// `down` sends the note falling instead of puffing up — for once the band has +/// climbed to the top of the screen and rains its notes over the windows +/// floating up beneath it. +function spawnNote(cx, cy, color, down = false) { particles.push({ - x: cx + (Math.sin(particles.length * 1.7) * 24), y: cy, - vx: (Math.sin(particles.length * 2.3)) * 60, vy: -260 - (particles.length % 5) * 18, + // Only a few px of scatter: the note is supposed to read as leaving THAT + // key, and a key is only ~43px wide at this scale. + x: cx + (Math.sin(particles.length * 1.7) * 6), y: cy, + vx: (Math.sin(particles.length * 2.3)) * 60, + vy: down ? 40 + (particles.length % 5) * 12 : -260 - (particles.length % 5) * 18, life: 0, ttl: 2.2, color, rot: (particles.length % 7 - 3) * 0.12, }); } @@ -349,14 +328,6 @@ function drawLanguageMap(rx, t) { ctx.textAlign = "left"; } -// ── faux QR (finder patterns + module field) → reads as "scan me" ────────── -// the REAL scannable QR (qrencode PNG), on a white rounded tile. -function drawQR(x, y, s) { - ctx.fillStyle = "white"; roundRect(x, y, s, s, s * 0.08); ctx.fill(); - const pad = s * 0.10; - if (qrImg) ctx.drawImage(qrImg, x + pad, y + pad, s - pad * 2, s - pad * 2); -} - const easeOut = (u) => 1 - Math.pow(1 - Math.max(0, Math.min(1, u)), 3); function text(str, x, y, size, color, weight = 700, align = "center", rounded = true) { @@ -384,6 +355,37 @@ async function prerenderAbout() { } } +// The icon↔QR card shuffle, captured straight out of the app. `CardStackView` +// animates through Core Animation, and a headless snapshot only ever sees model +// values — so the app exposes the same shuffle as a function of progress +// (`--card-flip`) and we grab a frame per step. Two directions: the icon +// tucking behind to reveal the QR, and the QR tucking back. +const FLIP_STEPS = 9; +const FLIP_JITTER = "0.35"; // pinned — the live shuffle picks this at random +const flipImgs = { icon: [], qr: [] }; +/// Where the card sits inside the About window, as fractions of it. +let CARD = { cx: 0.5, cy: 0.16, side: 0.54 }; +const CARD_JSON = `${ABOUT_CACHE}/about-card.json`; + +async function prerenderFlip() { + if (!existsSync(MENUBAR_BIN)) return; + console.log(`▸ capturing ${FLIP_STEPS * 2} card-shuffle frames via MenuBand --card-flip …`); + for (const from of ["icon", "qr"]) { + for (let i = 0; i < FLIP_STEPS; i++) { + const p = (i / (FLIP_STEPS - 1)).toFixed(4); + const png = `${ABOUT_CACHE}/flip-${from}-${i}.png`; + if (!existsSync(png)) { + const r = spawnSync(MENUBAR_BIN, ["--render-about", "--lang", "en", "--scale", "3", + "--card-front", from, "--card-flip", p, "--card-jitter", FLIP_JITTER, + "--emit-card", CARD_JSON, "--out", png], { stdio: ["ignore", "ignore", "pipe"] }); + if (r.status !== 0) console.error(` ✗ flip ${from} ${p}: ${r.stderr?.toString().slice(-160)}`); + } + if (existsSync(png)) flipImgs[from].push(await loadImage(png)); + } + } + if (existsSync(CARD_JSON)) CARD = JSON.parse(readFileSync(CARD_JSON, "utf8")); +} + // The REAL fullscreen keymap view (big piano + raster scope + QWERTY map), // captured per instrument program so the family tint cycles — piano, guitar, // violin, trumpet, flute — the same way the About pass cycles languages. @@ -413,36 +415,142 @@ const SCENES = [ ].map((s) => ({ ...s, from: s.from * TOTAL, to: s.to * TOTAL })); const sceneAt = (t) => SCENES.find((s) => t >= s.from && t < s.to) ?? SCENES.at(-1); -// The MENU, front and center: a floating macOS menu bar (Apple · title · clock) -// holding the REAL captured piano, scaled large and vertically centered. +// The MENU, front and center: the REAL captured piano, alone on the desktop. +// No plate behind it — the captured strip already carries its own rounded +// housing, so a second dark rounded rect around it just reads as a black +// bubble. And no settings chip (`--no-settings`), which used to hang off the +// right end and pull the whole strip's weight to one side; without it the +// band is symmetrical and can simply sit centered. +const HERO_W = W * 0.96; +const HERO_X = (W - HERO_W) / 2; +/// Height of one lazy up-and-down, and how long the band takes to fall in. +const HERO_BOB = 22; +const HERO_DROP = 1.4; +/// Where the band parks once it has climbed, and how long the climb takes. It +/// finishes exactly as the About window arrives, so the band is already out of +/// the way and still playing when the other windows float up beneath it. +const HERO_TOP = 54; +const HERO_RISE = 1.2; + +/// Where the hero strip sits at time `t`. The note particles read this too, so +/// they leave the band wherever it currently floats. Three motions ride on the +/// resting position: an entrance falling in from above the frame, a slow bob on +/// the waltz's bar so it breathes in 3/4, and the climb to the top of the +/// screen at the end of the first act. +function heroRect(t) { + const img = menubarIdle; + const h = img ? HERO_W * img.height / img.width : 66; + const middle = H / 2 - h / 2; + const riseEnd = SCENES[0].to; + const rise = easeOut((t - (riseEnd - HERO_RISE)) / HERO_RISE); + const rest = middle + (HERO_TOP - middle) * rise; + + const enter = easeOut(Math.min(1, Math.max(0, t / HERO_DROP))); + const bob = Math.sin((t / BAR_SEC) * Math.PI * 2) * HERO_BOB * (1 - rise * 0.6); + const y = (-h - 40) + (rest - (-h - 40)) * enter + bob * enter; + return { x: HERO_X, y, w: HERO_W, h, risen: rise > 0.5 }; +} + function drawBigMenu(t, alpha) { const img = menubarCache.get(barKey(barNotesAt(t))) || menubarIdle; - const plateW = W * 0.94, plateH = 168; - const px = (W - plateW) / 2, py = H / 2 - plateH / 2; + if (!img) return; + const r = heroRect(t); ctx.save(); ctx.globalAlpha = alpha; - ctx.shadowColor = "rgba(0,0,0,0.45)"; ctx.shadowBlur = 55; ctx.shadowOffsetY = 20; - roundRect(px, py, plateW, plateH, 30); ctx.fillStyle = "rgba(22,18,32,0.94)"; ctx.fill(); - ctx.shadowColor = "transparent"; - const cy = py + 40; - if (sfApple) { - const ah = 30, aw2 = ah * sfApple.width / sfApple.height; - ctx.drawImage(sfApple, px + 30, cy - ah / 2, aw2, ah); + ctx.drawImage(img, r.x, r.y, r.w, r.h); + ctx.restore(); + // (No headline / subheader here — the burned VO captions carry the words; + // the strip plays alone, front and center.) +} + +/// Key geometry from the app itself: midi → { cx (fraction of strip width), +/// hex (its ROYGBIV stripe color, null for sharps) }. Loaded after the +/// prerender writes it. +let KEYS = new Map(); +function loadKeyGeometry() { + if (!existsSync(KEYS_JSON)) return; + for (const k of JSON.parse(readFileSync(KEYS_JSON, "utf8"))) { + KEYS.set(k.midi, k); } - text("Menu Band", px + 138, cy, 30, "rgba(255,255,255,0.96)", 700, "left", false); - text("9:41", px + plateW - 34, cy, 28, "rgba(255,255,255,0.9)", 500, "right", false); - if (img) { - const iw = plateW - 96, ih = iw * img.height / img.width; - ctx.drawImage(img, px + 48, py + plateH - ih - 26, iw, ih); +} + +/// Exactly where on the hero strip a note's key sits. The strip lights the +/// note folded into its own octave range, so the particle has to fold too — +/// otherwise it leaves from a key that never lit. +function heroKeyX(midi, t) { + const key = KEYS.get(foldToBar(midi)); + const r = heroRect(t); + return r.x + r.w * (key ? key.cx : 0.5); +} + +/// The color the key itself lights in, as [r,g,b]. Sharps have no stripe color; +/// they light in the accent, so their notes fly off in ink. +const INK_RGB = [20, 18, 28]; +function heroKeyColor(midi) { + const hex = KEYS.get(foldToBar(midi))?.hex; + if (!hex) return INK_RGB; + const v = parseInt(hex.slice(1), 16); + return [(v >> 16) & 255, (v >> 8) & 255, v & 255]; +} + +/// Title bar height for the drawn window frame. Scaled to the content it sits +/// on — at 42px with 9px lights it read as a toy strip glued to a 1180px window. +const TITLEBAR_H = 72; + +// ── the end card's tap: a cursor flies to the app icon and clicks it a few +// times, and the deck shuffles the QR into view. Three taps, so it lands on +// the QR (icon → qr → icon → qr) and the scan target is what's left on screen. +const TAPS = [0.75, 1.45, 2.15]; // seconds into the end scene +const TAP_DUR = 0.34; // one shuffle, matching the app's 2×0.15s + slack +const CURSOR_FLY = 0.62; + +/// Which capture to show for the end card at `local` seconds into it, and how +/// far through a tap we are. Between taps the deck rests on whichever face the +/// last tap left forward. +function flipStateAt(local) { + let front = "icon"; // deck starts icon-forward + for (const at of TAPS) { + if (local < at) break; + const u = (local - at) / TAP_DUR; + if (u < 1) { + // mid-shuffle: the sequence captured FROM this face + const i = Math.min(FLIP_STEPS - 1, Math.max(0, Math.round(u * (FLIP_STEPS - 1)))); + return { img: flipImgs[front][i], tapping: true, sinceTap: local - at }; + } + front = front === "icon" ? "qr" : "icon"; // that tap committed } + // At rest, show the deck posed with `front` forward — that's frame 0 of the + // sequence captured FROM that face. (The last frame is the shuffle already + // finished, which is the other face; reaching for it lands the deck + // backwards after every tap.) + return { img: flipImgs[front][0], tapping: false, sinceTap: Infinity }; +} + +/// A macOS arrow pointer, drawn as the real one is: a black-filled outline with +/// a white stroke, so it reads on any backdrop. +function drawCursor(x, y, pressed) { + const s = pressed ? 30 : 33; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, s); + ctx.lineTo(s * 0.27, s * 0.76); + ctx.lineTo(s * 0.44, s * 1.12); + ctx.lineTo(s * 0.60, s * 1.05); + ctx.lineTo(s * 0.43, s * 0.70); + ctx.lineTo(s * 0.70, s * 0.68); + ctx.closePath(); + ctx.shadowColor = "rgba(0,0,0,0.35)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 3; + ctx.fillStyle = "white"; ctx.fill(); + ctx.shadowColor = "transparent"; + ctx.lineWidth = 2.4; ctx.strokeStyle = "rgba(20,18,28,0.95)"; ctx.stroke(); ctx.restore(); - // (No headline / subheader here — the burned VO captions carry the words; - // the plate plays alone, front and center.) } // The real captured About window, framed as a macOS window (titlebar + lights). function drawAbout(img, alpha, yOff) { if (!img) return; - const tb = 42; + const tb = TITLEBAR_H; const contentH = Math.min(H * 0.66, 1180); const aw = contentH * img.width / img.height; const ah = contentH + tb; @@ -454,7 +562,7 @@ function drawAbout(img, alpha, yOff) { ctx.save(); roundRect(ax, ay, aw, ah, 26); ctx.clip(); ctx.fillStyle = "rgb(238,235,245)"; ctx.fillRect(ax, ay, aw, tb); const lights = ["rgb(255,95,86)", "rgb(255,189,46)", "rgb(39,201,63)"]; - for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.fillStyle = lights[i]; ctx.arc(ax + 28 + i * 28, ay + tb / 2, 9, 0, 7); ctx.fill(); } + for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.fillStyle = lights[i]; ctx.arc(ax + 44 + i * 46, ay + tb / 2, 15, 0, 7); ctx.fill(); } ctx.drawImage(img, ax, ay + tb, aw, contentH); ctx.restore(); ctx.restore(); @@ -486,65 +594,70 @@ function drawFrame(t) { const local = (sc.to - sc.from) > 0 ? (t - sc.from) / (sc.to - sc.from) : 1; const dt = 1 / FPS; - if (sc.name === "menu") { - // first act — the menu, front and center, playing the melody. - drawBigMenu(t, 1); - for (const n of onsetsBetween(t - dt, t)) { - const idx = ((n.midi % 12) + 12) % 12 % 5; - spawnNote(W / 2 + Math.sin(n.t * 9) * 220, H / 2 - 110, KEY_COLORS[idx]); - } - } else if (sc.name === "keymap" && keymapImgs.length) { - onsetsBetween(t - dt, t); + // The band plays for the whole reel. It opens front and center, then climbs + // to the top of the screen and keeps playing there while the windows float + // up beneath it. Drawn before them, so they pass in front. + const hero = heroRect(t); + drawBigMenu(t, 1); + + // Every note leaves the exact key that lit, in that key's own color — the + // app's geometry and palette, not a guess. Once the band has climbed, the + // notes rain down off its underside instead of puffing up. Exactly one call + // to `onsetsBetween` per frame: it advances a cursor. + const edge = hero.risen ? hero.y + hero.h + 6 : hero.y - 6; + for (const n of onsetsBetween(t - dt, t)) { + spawnNote(heroKeyX(n.midi, t), edge, heroKeyColor(n.midi), hero.risen); + } + + if (sc.name === "keymap" && keymapImgs.length) { // third act — the REAL fullscreen keymap (raster scope + big piano + - // QWERTY map), instrument family colors cycling. + // QWERTY map), instrument family colors cycling. No caption: it's a + // screenshot of the real thing and it speaks for itself. const idx = Math.min(keymapImgs.length - 1, Math.floor(local * keymapImgs.length)); const e = easeOut(Math.min(1, local * 6)); // quick rise-in - const rc = drawKeymap(keymapImgs[idx], e, (H * 0.5) * (1 - e)); - if (rc) { - ctx.save(); ctx.globalAlpha = e; - text("and it goes fullscreen", W / 2, rc.ay - 64, 52, "white", 800); - text("keymap · gamepad · mic sampler", W / 2, rc.ay + rc.ah + 64, 32, AC_PURPLE, 700); - ctx.restore(); - } - } else { - onsetsBetween(t - dt, t); // keep the onset cursor advancing - // second act — the REAL About window, language cycling, dropping in. - let img, alpha = 1, yOff = 0; + drawKeymap(keymapImgs[idx], e, (H * 0.5) * (1 - e)); + } else if (sc.name !== "menu") { + // second act — the REAL About window, language cycling, floating up. + let img, alpha = 1, yOff = 0, flip = null; if (sc.name === "about") { const idx = Math.min(aboutImgs.length - 1, Math.floor(local * aboutImgs.length)); img = aboutImgs[idx]; - const e = easeOut(Math.min(1, local * 6)); // quick drop-in at the middle - yOff = -(H * 0.66) * (1 - e); alpha = e; + const e = easeOut(Math.min(1, local * 6)); + yOff = (H * 0.66) * (1 - e); alpha = e; } else { - img = aboutImgs[0]; + // end card — a cursor taps the app icon and the deck shuffles the QR up. + const localT = t - sc.from; + flip = flipStateAt(localT); + img = flip.img ?? aboutImgs[0]; } const rc = drawAbout(img, alpha, yOff); - if (sc.name === "end" && rc && qrImg) { - const e = easeOut(local * 1.8); - ctx.save(); ctx.globalAlpha = e; - drawQR(W / 2 - 120, rc.ay + rc.ah - 280, 240); - text("menuband.app", W / 2, rc.ay + rc.ah + 44, 38, "white", 800); - text("on the Mac App Store", W / 2, rc.ay + rc.ah + 96, 30, AC_PURPLE, 700); - ctx.restore(); + + if (flip && rc) { + // The card's spot, straight from the app's own layout (`--emit-card`). + const cx = rc.ax + rc.aw * CARD.cx; + const cy = rc.ay + TITLEBAR_H + (rc.ah - TITLEBAR_H) * CARD.cy; + const localT = t - sc.from; + + // A note bursts out of the icon on every click — the app does exactly + // this (`emitNotesBurst` in CardStackView.flip). + for (const at of TAPS) { + if (localT - dt < at && localT >= at) { + for (let i = 0; i < 5; i++) { + spawnNote(cx + (i - 2) * 16, cy - 10, KEY_COLORS[i]); + } + } + } + + // The cursor flies in from the lower right, then rests on the icon, + // dipping on each click. + const fly = easeOut(Math.min(1, localT / CURSOR_FLY)); + const px = W * 0.86 + (cx + 6 - W * 0.86) * fly; + const py = H * 0.78 + (cy + 6 - H * 0.78) * fly; + const pressed = flip.sinceTap >= 0 && flip.sinceTap < 0.12; + drawCursor(px, py + (pressed ? 3 : 0), pressed); } } - // the real macOS menu bar stays pinned at the very top for context. - drawMenubar(t); - - // Persistent QR to menuband.app, top-left under the bar — scannable the - // whole reel, not just at the end card. Fades in over the first beat. - if (qrImg) { - const qa = easeOut(Math.min(1, t / 1.2)); - const plate = 178, qs = 150, qx = 24, qy = MBAR_H + 20; - ctx.save(); ctx.globalAlpha = qa; - ctx.shadowColor = "rgba(0,0,0,0.35)"; ctx.shadowBlur = 24; ctx.shadowOffsetY = 8; - roundRect(qx, qy, plate, plate, 20); ctx.fillStyle = "white"; ctx.fill(); - ctx.shadowColor = "transparent"; - ctx.drawImage(qrImg, qx + (plate - qs) / 2, qy + (plate - qs) / 2, qs, qs); - text("menuband.app", qx + plate / 2, qy + plate + 26, 20, "white", 700); - ctx.restore(); - } stepAndDrawParticles(dt); // gentle global vignette @@ -555,7 +668,9 @@ function drawFrame(t) { // ── render → ffmpeg (muxed with the waltz) ───────────────────────────────── await prerenderMenubars(); +loadKeyGeometry(); await prerenderAbout(); +await prerenderFlip(); await prerenderKeymap(); console.log(`▸ menuband sim · ${FRAMES} frames · ${TOTAL.toFixed(1)}s · ${W}x${H}@${FPS} · ${leadNotes.length} lead notes`); const enc = spawnFFmpegEncode({ audioPath: AUDIO, w: W, h: H, fps: FPS, outPath: BASE, crf: 18 });