From 2cb2f2d1a53a8d7672979c98e96f5091945664cb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 9 Jul 2026 23:12:18 -0700 Subject: [PATCH] menuband reel: light mode, a bare band that climbs, and a QR you tap for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reel is light mode now — pale lilac desktop, dark chrome — and the macOS menu bar is gone entirely. No Apple logo, no clock, no Wi-Fi, no battery, no miniature strip. The band is the subject. It opens centered, larger than the frame, then climbs to where the menu bar would have been and keeps playing there while the About and keymap windows float up beneath it. Once risen its notes rain down off the underside instead of puffing up. Each note leaves the exact key that lit, in that key's own color, both read from the app's --emit-keys geometry; the old code interpolated pitch across the strip and launched notes off keys that never sounded. Spawn scatter drops from ±24px to ±6px, since a key is only ~43px wide here. The black plate behind the strip is gone: the capture already carries its own housing, and a second rounded rect around it just read as a bubble. So is the settings chip (--no-settings), which pulled the band's weight right and stopped it centering. The end card is the About window, clean. Both QRs are deleted — the stamped one had been sitting on top of the window's own "Looking For Players?" link and version line for as long as it existed. In its place a cursor flies in and taps the app icon three times, and the app's real card shuffle deals the QR up. Notes burst on each click because flip() calls emitNotesBurst(); that's the app's behavior, not invented juice. Also: chrome tints deepen toward black rather than brightening toward white, which was right on aubergine and invisible on lilac. And the YWFT typeface falls back to the copy vendored in the Menu Band bundle, so a render works on any checkout without installing a font first — which is what stopped this rendering anywhere but neo. Co-Authored-By: Claude Opus 4.8 (1M context) --- pop/lib/preview-shared.mjs | 13 +- pop/menuband/bin/chrome-reel.mjs | 13 +- pop/menuband/bin/sim.mjs | 401 ++++++++++++++++++++----------- 3 files changed, 280 insertions(+), 147 deletions(-) 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 }); -- 2.51.2