From db4ab0a0681d7c25c3fc5e4240845a48e4f561b3 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 24 May 2026 22:21:37 -0400 Subject: [PATCH] pop: ship widescreen YT visualizers for helpabeach + marimbaba; align all pixsies cuts on trancepenta side-stamps chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the deleted vertical-Short uploads with 1920×1080 landscape cuts so YouTube stops classifying them as Shorts (where metadata edits didn't surface). All AC YouTube cuts now share the trancepenta-yt side-stamps chrome — pals + rotated-90° title chars climbing UP beside each stamp — and use Title Case titles with hashtag-only descriptions. - gen-illy.mjs (chillwave) + gen-sections.mjs (marimba): --landscape mode (1536×1024 gpt-image, -yt filename infix, LANDSCAPE_NOTE prompt override that re-frames each panel for 16:9 with chrome breathing room). - preview-score-helpabeach-yt.mjs + preview-score-marimbaba-yt.mjs: landscape forks at 1920×1080 with the canonical side-stamps chrome (palsFrameColor + tintCharGlyph + drawPalsTitleChars copied from trancepenta-yt; title chars pre-rendered with shadowColor:null so the per-frame seep tint works). - gen-illy.mjs: --validate-butterfly flag wraps each gen with a gpt-4o-mini-vision check against pop/chillwave/assets/wg-scrap.png and regens FAILs in place (PASS / SKIP / FAIL verdicts; cached panels are validated too so re-runs replace prior FAILs). - .youtube.txt files (4 tracks): stripped to hashtags only. - RELEASES.md: top-level YouTube state snapshot + per-track notes for the new IDs (Marimbaba → X63Ni-Lb_Kc; Helpabeach v2 UPLOAD PENDING — YouTube 6/day/project quota hit, mp4 is rendered and the resume command is in the helpabeach block). Co-Authored-By: Claude Opus 4.7 (1M context) --- pop/RELEASES.md | 27 +- pop/chillwave/bin/gen-illy.mjs | 233 ++- .../bin/preview-score-helpabeach-yt.mjs | 1315 +++++++++++++++++ pop/chillwave/helpabeach.youtube.txt | 10 - pop/dance/trancenwaltz.youtube.txt | 10 - pop/dance/trancepenta.youtube.txt | 10 - pop/marimba/bin/gen-sections.mjs | 33 +- .../bin/preview-score-marimbaba-yt.mjs | 1309 ++++++++++++++++ pop/marimba/marimbaba.youtube.txt | 10 - 9 files changed, 2860 insertions(+), 97 deletions(-) create mode 100644 pop/chillwave/bin/preview-score-helpabeach-yt.mjs create mode 100644 pop/marimba/bin/preview-score-marimbaba-yt.mjs diff --git a/pop/RELEASES.md b/pop/RELEASES.md index 3995f27a00..af272ffd5e 100644 --- a/pop/RELEASES.md +++ b/pop/RELEASES.md @@ -7,6 +7,25 @@ Status legend: **RELEASED** · **SUBMITTED** · **MASTERING** · **RENDER** · * --- +## YouTube state — pixsies singles (snapshot 2026-05-24) + +All AC YouTube cuts are **1920×1080 landscape only** (vertical = Short, metadata doesn't surface). Titles are **Title Case**; descriptions are **hashtag-only**. Chrome family for all `*-yt.mjs` forks: trancepenta-yt side stamps — pals + rotated-90° title chars climbing UP beside each stamp (movie-poster spine). Pre-render title chars with `shadowColor: null` so the per-frame tint works. + +| Track | Video | Chrome / Build | +|---|---|---| +| Trancepenta | [Em_lvTYET7M](https://youtu.be/Em_lvTYET7M) | `preview-score-trancepenta-yt.mjs` (canonical side-stamps) | +| Trancenwaltz | [RBG3k_XsfLA](https://youtu.be/RBG3k_XsfLA) | `cover-video.mjs --size 1920x1080` w/ v10b landscape illys | +| Marimbaba (v2) | [X63Ni-Lb_Kc](https://youtu.be/X63Ni-Lb_Kc) | `preview-score-marimbaba-yt.mjs` (side-stamps; replaces v1 `qntoYeAZSmM`) | +| Helpabeach (v2) | **UPLOAD PENDING — quota** | `preview-score-helpabeach-yt.mjs` (side-stamps; mp4 at `pop/chillwave/out/helpabeach-preview-score-yt.mp4`, 109 MB) | + +YouTube API quota: **6 video_insert / day / project** (`defaultVideoInsertPerDayPerProject`); deletes count toward the same window. On 2026-05-24 burned through the day's six on the two deleted Shorts + two v1 landscape cuts + marimbaba v2; helpabeach v2 deferred until reset (~24h after first upload of the day). Resume command in the helpabeach block below. + +Open follow-ups (queued, not blockers): +- Re-run `node pop/chillwave/bin/gen-illy.mjs --slug helpabeach --landscape --sections --validate-butterfly` once OpenAI gpt-4o-mini quota clears — the validator wraps each gen with a vision check against `pop/chillwave/assets/wg-scrap.png` and regens FAILs (drift-1 visibly fails: tree-person not butterfly). +- `helpabeach-yt-forms.json` not authored — backlight/zoom layer disabled on landscape until face-detected forms land (`pop/dance/bin/detect-face.py` is the precedent). + +--- + ## marimbaba — RELEASED - **Lane:** `pop/marimba/` · lullaby for synthesized marimba, 1:24 · @@ -24,7 +43,7 @@ Status legend: **RELEASED** · **SUBMITTED** · **MASTERING** · **RENDER** · * - audio — https://assets.aesthetic.computer/pop/marimbaba.mp3 - cover — https://assets.aesthetic.computer/pop/marimbaba.jpg - visualizer — https://assets.aesthetic.computer/pop/marimbaba.mp4 - - youtube · https://youtu.be/byt_NnRhbs4 (1080×1920 vertical storyline cut — uploaded 2026-05-24 via `toolchain/youtube/yt.mjs`; YouTube renders vertical natively. Description at `pop/marimba/marimbaba.youtube.txt`, thumbnail at `/tmp/yt-thumbs/marimbaba-thumb.jpg` — 1280-wide compressed jpg, required because the 3000² CDN cover exceeds YouTube's 2 MB thumbnail cap.) + - youtube · https://youtu.be/X63Ni-Lb_Kc (1920×1080 landscape visualizer — uploaded 2026-05-24, REPLACED v1 `qntoYeAZSmM`. Built from the `-yt` landscape illy set (`gen-sections.mjs --landscape` → 11 panels at 1536×1024 with the LANDSCAPE_NOTE re-framing the late-night study for 16:9) and the landscape fork `pop/marimba/bin/preview-score-marimbaba-yt.mjs`. v1 had unused-`drawTitle` side stacks; v2 restores `drawPalsTitleChars` so the chrome matches trancepenta-yt exactly: pals stamps snug against rotated-90° "marimbaba" climbing up beside each stamp. Replaced the deleted vertical Short `byt_NnRhbs4`. Title is "Marimbaba"; description is hashtag-only.) - **DistroKid dashboard:** https://distrokid.com/dashboard/album/?albumuuid=772E43F5-D367-44A9-A7B4A1FA4E57FBD9 (admin-only) - **Master:** `~/Documents/Working Desktop/marimbaba-DISTROKID/marimbaba-MASTER.wav` @@ -86,7 +105,7 @@ Status legend: **RELEASED** · **SUBMITTED** · **MASTERING** · **RENDER** · * - cover · https://assets.aesthetic.computer/pop/helpabeach.jpg (3000², Rhizome Health clinic tableau) - video · https://assets.aesthetic.computer/pop/helpabeach.mp4 (1080×1920 vertical IG-story, 9 clinic panels) - story cut · https://assets.aesthetic.computer/pop/helpabeach-short.mp4 (1:17 narrated cut) - - youtube · https://youtu.be/maQ8GRZcS90 (1080×1920 vertical storyline cut — uploaded 2026-05-24. Description at `pop/chillwave/helpabeach.youtube.txt`; thumbnail compressed to 1280 wide.) + - youtube · UPLOAD PENDING — v1 (`NG55RkBI7N0`) had the wrong chrome (horizontal top title) and was deleted 2026-05-24 to make room for the side-stamps re-render. The v2 mp4 is built at `pop/chillwave/out/helpabeach-preview-score-yt.mp4` (109 MB, 1920×1080, side-stamps chrome matching trancepenta-yt: pals stamps snug against rotated-90° "helpabeach" climbing up beside each stamp; no top horizontal title). Re-upload blocked by YouTube's 6-uploads/day/project quota — the daily window already covers the two shorts (since deleted), the two v1 landscape cuts (helpabeach + marimbaba), and the marimbaba v2 (`X63Ni-Lb_Kc`). Quota resets ~24h after first upload today. Resume command: `node toolchain/youtube/yt.mjs upload pop/chillwave/out/helpabeach-preview-score-yt.mp4 --title "Helpabeach" --description-file pop/chillwave/helpabeach.youtube.txt --tags "chillwave,ambient,electronic,visualizer,aesthetic computer,pixsies,music,instrumental" --privacy public --category 10`. - **Master:** `~/Documents/Working Desktop/helpabeach-DISTROKID/helpabeach-MASTER.wav` — 44.1 kHz / 16-bit stereo WAV, 151.1 s. ≈ −13.4 LUFS, −1.5 dBTP. Master chain: highpass 24 → treble +1.8 dB @ 9.5 k → loudnorm @@ -122,7 +141,7 @@ Status legend: **RELEASED** · **SUBMITTED** · **MASTERING** · **RENDER** · * 1. `node marketing/bin/gen-promo.mjs --variant v10b --size 1536x1024 --prompt-file cover-prompt-landscape.txt --force --no-mirror` per section (regen-only; the `cover-prompt-landscape.txt` is the portrait prompt with PORTRAIT framing language swapped for LANDSCAPE 3:2 / 16:9 strip). 2. `recap/.venv/bin/python3 pop/dance/bin/detect-face.py /gens/v10b.png` per section (writes `.face.json` sidecars the backlight emitters need). 3. `node pop/dance/bin/cover-video.mjs --track --illustrations intro=...,break1=...,... --size 1920x1080 --prelude /gens/v10.png --title trancenwaltz --bpm 137.143 --out ` (the **build.mjs** `youtube` format wires this). - 4. `node toolchain/youtube/yt.mjs upload --title "trancenwaltz" --description-file pop/dance/trancenwaltz.youtube.txt --tags "trance,waltz,electronic,visualizer,aesthetic computer,pixsies,music" --privacy public --category 10`. + 4. `node toolchain/youtube/yt.mjs upload --title "Trancenwaltz" --description-file pop/dance/trancenwaltz.youtube.txt --tags "trance,waltz,electronic,visualizer,aesthetic computer,pixsies,music" --privacy public --category 10`. (YouTube title convention is Title Case; description is hashtags only — see 2026-05-24 metadata refresh.) - **Master:** `~/Desktop/trancenwaltz-MASTER.wav` (= `-FINAL-distrokid.wav`) — 16-bit/44.1 kHz stereo WAV, 86.62 s. **Bright master:** −13.5 LUFS, −1.2 dBTP, LRA 6.7, +5 dB air vs the original dark cut. Pre-bright @@ -307,7 +326,7 @@ DistroKid has a "request Spotify for Artists" shortcut for new artists. - **YouTube visualizer build recipe** (deterministic from the felt panel set + bright master): 1. `node pop/dance/bin/gen-trancepenta-sections.mjs --force` (regen 16 felt landscape panels + cover at 1536×1024 via the felt-puppet constants in the script; concurrency 3; ~15–20 min wall time; ~$5 OpenAI). 2. `node pop/dance/bin/preview-score-trancepenta-yt.mjs` (default I/O — reads `~/Documents/Working Desktop/twi-out/trancepenta.mp3` + struct, writes `pop/dance/out/trancepenta-preview-score-yt.mp4`; ~35 min for the full 190 s @ 30 fps). - 3. `node toolchain/youtube/yt.mjs upload pop/dance/out/trancepenta-preview-score-yt.mp4 --title "trancepenta" --description-file pop/dance/trancepenta.youtube.txt --tags "trance,chilltrance,electronic,visualizer,aesthetic computer,pixsies,music,instrumental,5/4" --privacy public --category 10 --thumbnail /tmp/yt-thumbs/trancepenta-thumb.jpg` (the thumbnail must be ≤ 2 MB; the 3000² CDN cover is too big — use the 1280-wide ffmpeg downscale or call `yt.mjs thumbnail ` after the fact). + 3. `node toolchain/youtube/yt.mjs upload pop/dance/out/trancepenta-preview-score-yt.mp4 --title "Trancepenta" --description-file pop/dance/trancepenta.youtube.txt --tags "trance,chilltrance,electronic,visualizer,aesthetic computer,pixsies,music,instrumental,5/4" --privacy public --category 10 --thumbnail /tmp/yt-thumbs/trancepenta-thumb.jpg` (the thumbnail must be ≤ 2 MB; the 3000² CDN cover is too big — use the 1280-wide ffmpeg downscale or call `yt.mjs thumbnail ` after the fact). - Reconstructable byte-faithfully any time: `BAKE_FORCE=1 bash pop/dance/bin/bake-trancepenta.sh` → produces `~/Documents/Working Desktop/twi-out/trancepenta-MASTER.wav` + paired struct.json → cover at `~/Documents/Working Desktop/gens/trancepenta-cover-3000.jpg` (felt-character regen via `gens/trancepenta-cover-final/`) diff --git a/pop/chillwave/bin/gen-illy.mjs b/pop/chillwave/bin/gen-illy.mjs index 53d86cb37c..7245bd85e2 100644 --- a/pop/chillwave/bin/gen-illy.mjs +++ b/pop/chillwave/bin/gen-illy.mjs @@ -12,6 +12,16 @@ // node bin/gen-illy.mjs --slug helpabeach // node bin/gen-illy.mjs --slug helpabeach --force // node bin/gen-illy.mjs --slug helpabeach --size 1024x1536 +// node bin/gen-illy.mjs --slug helpabeach --portrait # 1024x1536 "-p" set (IG story) +// node bin/gen-illy.mjs --slug helpabeach --landscape # 1536x1024 "-yt" set (YouTube widescreen) +// node bin/gen-illy.mjs --slug helpabeach --landscape --sections # cover + 9 panels for YT +// node bin/gen-illy.mjs ... --validate-butterfly # gpt-4o-mini checks each gen +// for a coherent whistlegraph +// butterfly on jeffrey's chartreuse +// Neo lid; FAILs are regenned up +// to --validate-retries N (default 2). +// SKIP (no laptop in frame) and PASS +// both accept. import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -33,11 +43,19 @@ for (let i = 2; i < process.argv.length; i++) { } const SLUG = flags.slug || "helpabeach"; -// --portrait → vertical 9:16 set: gpt-image 1024x1536, "-p" filename -// infix, and a tall-composition override appended to every prompt. -const PORTRAIT = flags.portrait === true; -const SIZE = flags.size || (PORTRAIT ? "1024x1536" : "1024x1024"); -const TAG = PORTRAIT ? "-p" : ""; +// --portrait → vertical 9:16 set: gpt-image 1024x1536, "-p" filename. +// --landscape → wide 3:2 / 16:9 set: gpt-image 1536x1024, "-yt" filename. +// Mutually exclusive with --portrait. Used for the YouTube widescreen +// visualizer fork (preview-score-helpabeach-yt.mjs). +const PORTRAIT = flags.portrait === true; +const LANDSCAPE = flags.landscape === true; +if (PORTRAIT && LANDSCAPE) { + console.error("✗ --portrait and --landscape are mutually exclusive"); + process.exit(1); +} +const SIZE = flags.size + || (PORTRAIT ? "1024x1536" : LANDSCAPE ? "1536x1024" : "1024x1024"); +const TAG = PORTRAIT ? "-p" : LANDSCAPE ? "-yt" : ""; const FORCE = flags.force === true; const PROMPT_PATH = `${LANE}/${SLUG}.illy.txt`; const OUT_PATH = `${LANE}/out/${SLUG}${TAG}-cover.png`; @@ -55,6 +73,22 @@ const PORTRAIT_NOTE = "the colored-pencil + gouache medium on cream paper, the no-outline volume " + "modeling, diegetic light only, and the same story beat for this section — only " + "the framing widens."; +const LANDSCAPE_NOTE = + "LANDSCAPE OVERRIDE — recompose for a WIDE 3:2 / 16:9 frame (NOT square, NOT " + + "tall), and PULL THE CAMERA BACK so the WHOLE Rhizome Health clinic stretches " + + "horizontally across the frame: tall arched windows running the full width, the " + + "white-brick back wall extending across, exam bench, counter, rolling cart, " + + "plants, pale wood floor — a complete, wide room with generous LEFT and RIGHT " + + "breathing space around the figures, NEVER cropped tight, NEVER tall. CRUCIAL " + + "— the PROJECTED Rhizome Health logo sits HIGH and HORIZONTALLY CENTRED in the " + + "upper-third band, complete wordmark legible ('Rhizome' + 'Health' both words " + + "fully visible, neither cut off), with a wide cream margin on BOTH sides. " + + "FIGURES are positioned in the LOWER-CENTER of the frame at chest-and-above " + + "framing, not crammed in a corner, allowing room for a subtle bottom-edge band " + + "of empty cream paper / floor where the YouTube visualizer chrome (title, " + + "score-train, progress bar) will sit. keep the colored-pencil + gouache medium " + + "on cream paper, the no-outline volume modeling, diegetic light only, and the " + + "same story beat for this section — only the framing widens."; mkdirSync(`${LANE}/out`, { recursive: true }); @@ -146,6 +180,80 @@ function safeName(n) { const apiKey = loadOpenAIKey(); const basePrompt = readFileSync(PROMPT_PATH, "utf8").trim(); +// ── butterfly validator ────────────────────────────────────────────── +// gpt-4o-mini vision check: does jeffrey's chartreuse-green Neo show +// the whistlegraph-butterfly white scrap on its lid? Three possible +// verdicts: +// PASS — butterfly is recognizable + coherent on the lid +// SKIP — no chartreuse laptop visible in this panel (accepted) +// FAIL — laptop is present but the lid shows the WRONG mark +// (tree-person / apple / pals / random doodle / nothing) +// Failures trigger a regeneration of the panel up to N attempts. +// See [[feedback_imagegen_jeffrey_butterfly_neo.md]]. +const VALIDATE_BUTTERFLY = flags["validate-butterfly"] === true; +const VALIDATE_RETRIES = Number(flags["validate-retries"] ?? 2); + +function imgDataUrl(path) { + const buf = readFileSync(path); + const lower = path.toLowerCase(); + const mime = lower.endsWith(".png") ? "image/png" + : lower.endsWith(".webp") ? "image/webp" + : "image/jpeg"; + return `data:${mime};base64,${buf.toString("base64")}`; +} + +async function validateButterfly(imagePath) { + if (!existsSync(BUTTERFLY_REF)) { + console.warn(` ⚠ butterfly ref missing at ${BUTTERFLY_REF.replace(REPO + "/", "")} — skipping validation`); + return { verdict: "SKIP", reason: "no reference image on disk" }; + } + const body = { + model: "gpt-4o-mini", + max_tokens: 80, + messages: [{ + role: "user", + content: [ + { type: "text", text: +`You are validating an AI-generated illustration for whistlegraph-butterfly coherence on jeffrey's laptop lid. + +IMAGE 1 is the reference whistlegraph butterfly: a hand-drawn figure with a smiling rectangular head/body down the centre, four round wings (two upper, two lower), and a small rectangular tail. Simple grey marker lines on white. + +IMAGE 2 is the illustration to check. jeffrey's laptop is a chartreuse / lime / yellow-green "MacBook Neo". Look for it. The lid (closed or visible back side) should carry a small WHITE TORN PAPER SCRAP with a hand-drawn butterfly that resembles IMAGE 1. + +Decide ONE of three verdicts: + PASS — chartreuse laptop visible AND its lid clearly shows a white scrap with a butterfly that resembles the reference (smiling head + wings + tail; small variations OK). + SKIP — NO chartreuse laptop visible anywhere in IMAGE 2 (jeffrey isn't with his Neo in this beat). Accept. + FAIL — chartreuse laptop IS visible BUT the lid shows the wrong mark (tree-person / stick-figure / apple logo / pals figure / random scribble / blank / wordmark / something else). Reject. + +Reply on ONE LINE in exactly this format: + PASS + SKIP: + FAIL: ` }, + { type: "image_url", image_url: { url: imgDataUrl(BUTTERFLY_REF), detail: "low" } }, + { type: "image_url", image_url: { url: imgDataUrl(imagePath), detail: "high" } }, + ], + }], + }; + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.text(); + console.warn(` ⚠ vision validator ${res.status}: ${err.slice(0, 200)} — accepting as SKIP`); + return { verdict: "SKIP", reason: `validator error ${res.status}` }; + } + const json = await res.json(); + const raw = (json.choices?.[0]?.message?.content || "").trim(); + const verdict = raw.startsWith("PASS") ? "PASS" + : raw.startsWith("SKIP") ? "SKIP" + : raw.startsWith("FAIL") ? "FAIL" + : "SKIP"; // unparseable → accept + const reason = raw.replace(/^(PASS|SKIP|FAIL)\s*:?\s*/, "").trim(); + return { verdict, reason, raw }; +} + // One gpt-image-2 edit call → write outPath. Per-file cached; --force regens. // Build the multipart body fresh per attempt — a FormData/Blob body is // consumed on send and can't be re-streamed on a retry. @@ -177,60 +285,93 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function generate(promptText, outPath, label) { const rel = outPath.replace(REPO + "/", ""); if (existsSync(outPath) && !FORCE) { - console.log(`✓ cached → ${rel}`); - return; + // Cached files still get butterfly-validated when --validate-butterfly + // is on, so a re-run picks up the previous batch's FAILs and regens + // them in place. + if (VALIDATE_BUTTERFLY) { + const v = await validateButterfly(outPath); + if (v.verdict === "FAIL") { + console.log(`✗ cached panel failed butterfly check (${v.reason}) — regenerating: ${rel}`); + } else { + console.log(`✓ cached · ${v.verdict}${v.reason ? ` (${v.reason})` : ""} → ${rel}`); + return; + } + } else { + console.log(`✓ cached → ${rel}`); + return; + } } - console.log(`▸ ${label} · ${SIZE} · ${REFS.length} refs`); + console.log(`▸ ${label} · ${SIZE} · ${REFS.length} refs${VALIDATE_BUTTERFLY ? " · ↻validate" : ""}`); const MAX_TRIES = 4; - for (let attempt = 1; attempt <= MAX_TRIES; attempt++) { - const t0 = Date.now(); - try { - const res = await fetch("https://api.openai.com/v1/images/edits", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: buildForm(promptText), - }); - if (!res.ok) { - const err = await res.text(); - // 429 / 5xx are transient → retry; other 4xx (billing, moderation) → stop. - const transient = res.status === 429 || res.status >= 500; - if (transient && attempt < MAX_TRIES) { + // Whole-attempt loop: each VALIDATE_RETRY round burns one image-gen + // (and its inner network/transient retries). VALIDATE_RETRIES caps + // how many distinct butterfly-failed images we'll throw away before + // giving up and keeping the latest as-is. + for (let vRound = 0; vRound <= VALIDATE_RETRIES; vRound++) { + for (let attempt = 1; attempt <= MAX_TRIES; attempt++) { + const t0 = Date.now(); + try { + const res = await fetch("https://api.openai.com/v1/images/edits", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: buildForm(promptText), + }); + if (!res.ok) { + const err = await res.text(); + // 429 / 5xx are transient → retry; other 4xx (billing, moderation) → stop. + const transient = res.status === 429 || res.status >= 500; + if (transient && attempt < MAX_TRIES) { + const wait = 4000 * attempt; + console.warn(` ⚠ OpenAI ${res.status} (${label}) — retry ${attempt}/${MAX_TRIES - 1} in ${wait / 1000}s`); + await sleep(wait); + continue; + } + console.error(`✗ OpenAI ${res.status} (${label}): ${err.slice(0, 600)}`); + return; + } + const json = await res.json(); + const b64 = json.data?.[0]?.b64_json; + if (!b64) { + console.error(`✗ no image (${label}): ${JSON.stringify(json).slice(0, 280)}`); + return; + } + writeFileSync(outPath, Buffer.from(b64, "base64")); + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + const u = json.usage || {}; + const tok = u.input_tokens ? ` · tok in=${u.input_tokens} out=${u.output_tokens}` : ""; + console.log(`✓ ${elapsed}s${tok} → ${rel}${vRound > 0 ? ` (regen ${vRound}/${VALIDATE_RETRIES})` : ""}`); + break; // out of attempt-loop → fall through to validation + } catch (e) { + // network-level failure (fetch threw) — ETIMEDOUT / EPIPE / DNS … + const cause = e?.cause?.code || e?.cause?.message || e?.message || "unknown"; + if (attempt < MAX_TRIES) { const wait = 4000 * attempt; - console.warn(` ⚠ OpenAI ${res.status} (${label}) — retry ${attempt}/${MAX_TRIES - 1} in ${wait / 1000}s`); + console.warn(` ⚠ network fail (${label}: ${cause}) — retry ${attempt}/${MAX_TRIES - 1} in ${wait / 1000}s`); await sleep(wait); continue; } - console.error(`✗ OpenAI ${res.status} (${label}): ${err.slice(0, 600)}`); + console.error(`✗ network fail (${label}): ${cause} — gave up after ${MAX_TRIES} tries`); return; } - const json = await res.json(); - const b64 = json.data?.[0]?.b64_json; - if (!b64) { - console.error(`✗ no image (${label}): ${JSON.stringify(json).slice(0, 280)}`); - return; - } - writeFileSync(outPath, Buffer.from(b64, "base64")); - const elapsed = ((Date.now() - t0) / 1000).toFixed(1); - const u = json.usage || {}; - const tok = u.input_tokens ? ` · tok in=${u.input_tokens} out=${u.output_tokens}` : ""; - console.log(`✓ ${elapsed}s${tok} → ${rel}`); - return; - } catch (e) { - // network-level failure (fetch threw) — ETIMEDOUT / EPIPE / DNS … - const cause = e?.cause?.code || e?.cause?.message || e?.message || "unknown"; - if (attempt < MAX_TRIES) { - const wait = 4000 * attempt; - console.warn(` ⚠ network fail (${label}: ${cause}) — retry ${attempt}/${MAX_TRIES - 1} in ${wait / 1000}s`); - await sleep(wait); - continue; - } - console.error(`✗ network fail (${label}): ${cause} — gave up after ${MAX_TRIES} tries`); + } + // Validation: PASS / SKIP → keep file; FAIL → loop (next vRound burns a fresh gen). + if (!VALIDATE_BUTTERFLY) return; + const v = await validateButterfly(outPath); + if (v.verdict !== "FAIL") { + console.log(` ↻ butterfly · ${v.verdict}${v.reason ? ` (${v.reason})` : ""}`); return; } + if (vRound < VALIDATE_RETRIES) { + console.warn(` ✗ butterfly FAIL (${v.reason}) — regenerating panel (${vRound + 1}/${VALIDATE_RETRIES})`); + } else { + console.warn(` ✗ butterfly FAIL (${v.reason}) — exhausted ${VALIDATE_RETRIES} regens, keeping latest`); + } } } -const portraitTail = PORTRAIT ? `\n\n${PORTRAIT_NOTE}` : ""; +const portraitTail = PORTRAIT ? `\n\n${PORTRAIT_NOTE}` + : LANDSCAPE ? `\n\n${LANDSCAPE_NOTE}` + : ""; // --only [,…] regenerates just those panels (others untouched). const onlySet = SECTIONS_MODE && typeof flags.only === "string" diff --git a/pop/chillwave/bin/preview-score-helpabeach-yt.mjs b/pop/chillwave/bin/preview-score-helpabeach-yt.mjs new file mode 100644 index 0000000000..56154b983e --- /dev/null +++ b/pop/chillwave/bin/preview-score-helpabeach-yt.mjs @@ -0,0 +1,1315 @@ +#!/usr/bin/env node +// chillwave/bin/preview-score-helpabeach-yt.mjs — 1920×1080 landscape +// YouTube fork of preview-score.mjs. Same shared engine; the YT cut +// uses the "-yt" landscape illy set (gen-illy.mjs --landscape) and +// landscape-tuned chrome positions below. No TikTok / IG-platform +// branching — this file is always insta-style chrome at 16:9. +// +// Inputs: +// --slug helpabeach (default) +// --cover path-to.png (default: out/-yt-cover.png) +// --audio path-to.mp3 (default: out/.mp3) +// --struct path-to.json (default: out/.struct.json) +// --out path-to.mp4 (default: out/-preview-score-yt.mp4) +// --size 1920x1080 (default) +// --fps 30 (default) + +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import * as progress from "../../lib/render-progress.mjs"; +import { createCanvas, loadImage } from "canvas"; +import { getNoteColorForOctave } from "../../../system/public/aesthetic.computer/lib/note-colors.mjs"; +import { + checkYwftAvailable, + decodeAudioMono, + computeRmsEnvelope, + prerenderTitleChars, + magickRenderText, + drawCoverKenBurns, + drawFormBacklight, + drawTitleBounce, + spawnFFmpegEncode, + AUDIO_SR_DEFAULT, +} from "../../lib/preview-shared.mjs"; +// Shared verlet-string + rotating-disc + string→illustration WARP — +// the SAME engine trancenwaltz uses (pop/lib/cover-engine.mjs). +import { makeVerletString } from "../../lib/cover-engine.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const REPO = resolve(LANE, "../.."); + +const flags = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (!a.startsWith("--")) continue; + const next = process.argv[i + 1]; + if (next === undefined || next.startsWith("--")) flags[a.slice(2)] = true; + else { flags[a.slice(2)] = next; i++; } +} + +const SLUG = flags.slug || "helpabeach"; +// --title overrides the on-screen title text (defaults to capitalized SLUG). +const TITLE = flags.title || SLUG; +// YT fork: always landscape, always insta-style chrome — no PORTRAIT / +// TIKTOK branches. Uses the "-yt" illy set (gen-illy.mjs --landscape). +const TAG = "-yt"; +const TIKTOK = false; +const COVER = flags.cover || `${LANE}/out/${SLUG}${TAG}-cover.png`; +const AUDIO = flags.audio || `${LANE}/out/${SLUG}.mp3`; +const STRUCT = flags.struct || `${LANE}/out/${SLUG}.struct.json`; +const OUT = flags.out || `${LANE}/out/${SLUG}-preview-score-yt.mp4`; +const FPS = Number(flags.fps ?? 30); +const SIZE = flags.size || "1920x1080"; +const [W, H] = SIZE.split("x").map(Number); + +checkYwftAvailable(); +for (const [name, p] of [["cover", COVER], ["audio", AUDIO], ["struct", STRUCT]]) { + if (!existsSync(p)) { + console.error(`✗ ${name} missing: ${p.replace(REPO + "/", "")}`); + process.exit(1); + } +} + +const struct = JSON.parse(readFileSync(STRUCT, "utf8")); +const DURATION = struct.totalSec; +const FRAMES = Math.ceil(DURATION * FPS); +console.log(`▸ ${SLUG} score-preview · ${W}x${H} · ${DURATION.toFixed(1)}s · ${FRAMES} frames`); + +// ── audio decode + envelope ────────────────────────────────────────── +console.log(" decoding audio …"); +const { audio, sr: audioSr, audioPeak } = decodeAudioMono(AUDIO, AUDIO_SR_DEFAULT); +const envelope = computeRmsEnvelope(audio, audioSr, FPS, DURATION); +function envAt(t) { + const idx = Math.floor(t * FPS); + if (idx < 0 || idx >= envelope.length) return 0; + return envelope[idx]; +} + +// ── per-lane audio buffers (display-rate mono peak streams) ────────── +// Each lane has its own downsampled buffer (e.g. 4 kHz) written by +// render.mjs. We use these so each waveform represents that lane's +// actual mix volume, not the full mixed audio. +import { readFileSync as readFileSyncFs, existsSync as existsSyncFs } from "node:fs"; +const laneAudio = {}; +let laneSr = AUDIO_SR_DEFAULT; +let laneAudioPeak = 1e-6; +if (struct.laneAudio?.paths) { + laneSr = struct.laneAudio.sampleRate || 4000; + for (const [key, relPath] of Object.entries(struct.laneAudio.paths)) { + const full = `${LANE}/${relPath}`; + if (existsSyncFs(full)) { + const buf = readFileSyncFs(full); + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + const arr = new Float32Array(ab); + laneAudio[key] = arr; + for (let i = 0; i < arr.length; i++) { + const a = Math.abs(arr[i]); + if (a > laneAudioPeak) laneAudioPeak = a; + } + } + } + console.log(` loaded ${Object.keys(laneAudio).length} per-lane buffers @ ${laneSr} Hz`); +} + +// ── chillwave identity ─────────────────────────────────────────────── +// Lane palette pushed to pure dayglo for strong color differentiation +// between lanes. Plus an FX lane so the start/tail bitcrush + flange +// show up in the timeline. +// Lane palette = a full RAINBOW spread evenly around the hue circle +// (9 lanes → 40° apart), so the dense spinning record reads as a +// rainbow disc rather than a limited palette. +const LANES = [ + { key: "bells", color: "#ff3b3b", textColor: "#7a1f1f" }, // 0° red + { key: "birds", color: "#ff9b2e", textColor: "#7a4a14" }, // 40° orange + { key: "hat", color: "#e8e23a", textColor: "#6e6a1a" }, // 80° yellow + { key: "bubbles", color: "#5cf04a", textColor: "#246b1f" }, // 120° green + { key: "waves", color: "#3df2b0", textColor: "#1f6b5a" }, // 160° spring + { key: "kick", color: "#3dc2ff", textColor: "#1a5a7a" }, // 200° cyan-blue + { key: "sub", color: "#4a6aff", textColor: "#1f2f8a" }, // 240° blue + { key: "fx", color: "#b04aff", textColor: "#4a1f8a" }, // 280° violet + { key: "vox", color: "#ff4ad8", textColor: "#7a1f63" }, // 320° magenta +]; + +// Title palette — soft red / white / blue / pink pastels, the Rhizome +// Health clinic register (no more beach-sunlight yellows). +const TITLE_PALETTE = [ + "#f2a6ae", "#ffffff", "#a8c4e6", "#f0c8d2", + "#eef0f4", "#9fb8de", "#f4d0d8", "#c9d8ef", +]; + +// Section tints — soft red/white/blue/pink pastels, one per section, +// tracking the checkup arc. Keys MUST match struct.sections names. +const SECTION_TINTS = { + "tide-in": "rgba(151,193,233,1.0)", // pastel blue — arrival + "drift 1": "rgba(240,183,200,1.0)", // pastel pink — intake + "swell 1": "rgba(240,150,148,1.0)", // pastel coral-red — first peak + "deep-current": "rgba(240,236,226,1.0)", // pastel cream-white — eye chart + "drift 2": "rgba(164,191,228,1.0)", // pastel blue — handle typed + "undertow": "rgba(150,160,214,1.0)", // soft periwinkle — the listen + "swell 2": "rgba(244,168,176,1.0)", // warm pastel pink-red — covered + "tide-out": "rgba(244,240,233,1.0)", // pastel white — wrap-up + "ebb": "rgba(234,201,213,1.0)", // soft rose — the room quiets +}; + +// ── pre-render YWFT title chars + lane labels + section labels ─────── +console.log(" rasterizing YWFT …"); +const assetsDir = AUDIO.replace(/\.mp3$/, ".assets"); +mkdirSync(assetsDir, { recursive: true }); + +const titleFontSize = 96; +const { chars: titleChars, totalWidth: titleTotalW } = await prerenderTitleChars({ + text: TITLE, + ptSize: titleFontSize, + palette: TITLE_PALETTE, + // YT cut: bake NO shadow so source-in tinting yields a clean alpha + // mask for the per-frame colour seep in drawPalsTitleChars. + shadowColor: null, + assetsDir, +}); + +// ── Title/pals geometry (matches trancepenta-yt — pals snug against +// the first char of each vertical text column, both centred on the +// same vertical line so logo + type read as one family) ───────────── +const PALS_S = 145; // pals stamp size (square) +const PALS_HALF = PALS_S / 2; +const PALS_EDGE_X = 100; // cx of pals (left side) +const CHARS_EDGE_X = PALS_EDGE_X + 12; // chars sit just inward of pals +const CHAR_SCALE = 0.52; +const CHAR_SPAN = titleTotalW * CHAR_SCALE; // vertical length of rotated text column +const BOUNCE_BUF = 26; // vertical gap pals → first char +const LEFT_CHARS_CY = H * 0.82 - 16; // bottom-left group nudged UP toward centre +const RIGHT_CHARS_CY = H * 0.18 + 32 + 16; // top-right group nudged DOWN toward centre +const LEFT_PALS_CY = LEFT_CHARS_CY - CHAR_SPAN / 2 - BOUNCE_BUF - PALS_HALF; +const RIGHT_PALS_CY = RIGHT_CHARS_CY + CHAR_SPAN / 2 + BOUNCE_BUF + PALS_HALF; +const TITLE_TOP_Y = 44; // (now only used by IG_TOP_SAFE / typing-intro fallback) +const titleBoxH = Math.ceil(titleFontSize * 1.7); + +const tcFontSize = 64; +// We'll re-render the timecode each second since it changes — too +// slow per-frame. Use a cache of per-second strings. +const tcCache = new Map(); +async function getTcImg(text) { + if (tcCache.has(text)) return tcCache.get(text); + const safe = text.replace(/[^0-9]/g, "_"); + // node-canvas does NOT honor ctx.filter = "brightness(0)", so we + // can't tint a copy black at draw time. Pre-render a real solid- + // black glyph plate via ImageMagick alongside the colored one and + // composite it offset behind for a true dark drop shadow. + const img = await magickRenderText(text, { + ptSize: tcFontSize, + fill: "rgba(255,253,242,0.97)", + outPath: `${assetsDir}/tc.${safe}.png`, + }); + const shadow = await magickRenderText(text, { + ptSize: tcFontSize, + fill: "rgba(0,0,0,1)", + outPath: `${assetsDir}/tc.${safe}.shadow.png`, + }); + const entry = { img, shadow }; + tcCache.set(text, entry); + return entry; +} +// Pre-bake every whole-second tc label for the full duration. +{ + const totMm = Math.floor(DURATION / 60); + const totSs = Math.floor(DURATION - totMm * 60).toString().padStart(2, "0"); + for (let s = 0; s <= Math.ceil(DURATION); s++) { + const mm = Math.floor(s / 60); + const ss = (s - mm * 60).toString().padStart(2, "0"); + await getTcImg(`${mm}:${ss} / ${totMm}:${totSs}`); + } +} + +// ── lane events ────────────────────────────────────────────────────── +const laneEvents = {}; +for (const lane of LANES) { + laneEvents[lane.key] = (struct.events?.[lane.key] || []).slice().sort((a, b) => a.t - b.t); +} + +// ── kick envelope (drives the illustration "sharpen" punch) ────────── +// Only the deep kick drum (kind:"kick"), not the snare. Fast ~20ms +// attack → ~130ms exponential decay. Sampled per-frame in drawScene. +const kickTimes = (struct.events?.kick || []) + .filter((e) => e.kind === "kick").map((e) => e.t).sort((a, b) => a - b); +function kickEnvAt(t) { + let e = 0; + for (const kt of kickTimes) { + if (kt > t + 0.03) break; + const dt = t - kt; + const v = dt < 0 ? Math.max(0, 1 + dt / 0.02) : Math.exp(-dt / 0.13); + if (v > e) e = v; + } + return e; +} + +// ── canvas layout ──────────────────────────────────────────────────── +// Tracks float across the full canvas height over the illustration — +// no backing panel, no boxed area, no lane labels. The playhead is a +// single vertical needle that spans the whole canvas top-to-bottom. +const PANEL_PAD_X = 24; +const LANE_GUTTER = 3; // dense record — minimal space between tracks +const LANE_TOP = 220; // below title — landscape fork: bumped from 200 to clear the 96pt title at the new TITLE_TOP_Y=44 +const LANE_BOTTOM = H - 80; // leaves room for progress bar + timecode — landscape fork: tightened from 108 since 1080-tall has less air +const LANE_AREA_H = LANE_BOTTOM - LANE_TOP; +const LANE_H = Math.floor((LANE_AREA_H - (LANES.length - 1) * LANE_GUTTER) / LANES.length); +const LANE_X0 = PANEL_PAD_X; +const PX_PER_SEC = 240; +const TRACK_W = W - LANE_X0 - PANEL_PAD_X; +const PLAYHEAD_X = LANE_X0 + Math.round(TRACK_W / 2); +// AAC encoder priming delay — visual frame at time t plays back audio +// from t - AUDIO_DELAY_SEC. Shift the visual readout so blocks cross +// the playhead at the instant the listener actually hears them. +const AUDIO_DELAY_SEC = Number(flags["audio-delay"] ?? 0.045); + +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); +const cover = await loadImage(COVER); + +// ── per-section illustrations + Star Wars slide/fade transition ────── +// Each section can have its own illy at out/-sec--.png +// (gen-illy.mjs --sections). Missing ones fall back to the main cover. +function safeName(n) { + return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} +const SECTIONS = struct.sections.slice().sort((a, b) => a.startSec - b.startSec); +const sectionImgs = []; +let haveSectionImgs = 0; +for (let i = 0; i < SECTIONS.length; i++) { + const p = `${LANE}/out/${SLUG}${TAG}-sec-${i}-${safeName(SECTIONS[i].name)}.png`; + if (existsSync(p)) { sectionImgs[i] = await loadImage(p); haveSectionImgs++; } + else sectionImgs[i] = cover; +} +console.log(` section illys: ${haveSectionImgs}/${SECTIONS.length} (rest → cover)`); + +// ── pals watermark (real AC purple-pals.svg, top-right) ────────────── +// Rasterize the canonical SVG once via rsvg-convert and composite it +// small + semi-transparent in the top-right corner of every frame. +const PALS_WM_SIZE = 212; +let palsImg = null; +let palsBlur = null; +const wmCanvas = createCanvas(8, 8); +const wmCtx = wmCanvas.getContext("2d"); +function hslToRgb(h, s, l) { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} +// Recolor a pals alpha-mask (`src`) to `rgb` into the offscreen. +function palsTinted(rgb, src) { + const ww = src.width, wh = src.height; + wmCanvas.width = ww; wmCanvas.height = wh; + wmCtx.clearRect(0, 0, ww, wh); + wmCtx.globalCompositeOperation = "source-over"; + wmCtx.drawImage(src, 0, 0); + wmCtx.globalCompositeOperation = "source-in"; + wmCtx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + wmCtx.fillRect(0, 0, ww, wh); + wmCtx.globalCompositeOperation = "source-over"; + return wmCanvas; +} +{ + const palsSvg = `${REPO}/system/public/purple-pals.svg`; + const palsPng = `${assetsDir}/pals-watermark.png`; + const palsBlurPng = `${assetsDir}/pals-watermark-blur.png`; + if (existsSync(palsSvg)) { + const r = spawnSync("rsvg-convert", + ["-w", String(PALS_WM_SIZE * 2), "-h", String(PALS_WM_SIZE * 2), + "-o", palsPng, palsSvg]); + if (r.status === 0 && existsSync(palsPng)) { + palsImg = await loadImage(palsPng); + // only a hair of edge-softening (kills the hard vector edge so it + // sits in the hand-drawn world) — NOT a gaussian ghost. + const rb = spawnSync("magick", + [palsPng, "-channel", "A", "-blur", "0x1.5", "+channel", palsBlurPng]); + palsBlur = (rb.status === 0 && existsSync(palsBlurPng)) + ? await loadImage(palsBlurPng) : palsImg; + } + } + console.log(` pals watermark: ${palsImg ? "loaded" : "MISSING"}`); +} +// YT fork: trancepenta-yt-style side stamps — pals snug against each +// vertical text column, with rotated-90° title chars climbing UP beside +// them like a movie-poster spine. +function palsFrameColor(audioT) { + const u = (audioT + AUDIO_DELAY_SEC) * FPS / FRAMES; + const hue = ((u * 360 * 4) % 360 + 360) % 360; + const hRgb = hslToRgb(hue, 0.9, 0.62); + const [sr, sg, sb] = sectionTcRgb(audioT); + return [ + Math.round(sr * 0.6 + hRgb[0] * 0.4), + Math.round(sg * 0.6 + hRgb[1] * 0.4), + Math.round(sb * 0.6 + hRgb[2] * 0.4), + ]; +} +// Re-tint a pre-rendered title-char glyph (alpha mask) to a target rgb +// via source-in. Its own canvas (separate from wmCanvas) so tinting + +// drawing don't trample each other in drawPalsTitleChars. +const charTintCanvas = createCanvas(2, 2); +const charTintCtx = charTintCanvas.getContext("2d"); +function tintCharGlyph(img, rgb) { + const w = img.width, h = img.height; + charTintCanvas.width = w; charTintCanvas.height = h; + charTintCtx.globalCompositeOperation = "source-over"; + charTintCtx.clearRect(0, 0, w, h); + charTintCtx.drawImage(img, 0, 0); + charTintCtx.globalCompositeOperation = "source-in"; + charTintCtx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + charTintCtx.fillRect(0, 0, w, h); + charTintCtx.globalCompositeOperation = "source-over"; + return charTintCanvas; +} +function drawWatermark(audioT) { + if (!palsImg) return; + const s = PALS_S; + // PERFECT-LOOP GEOMETRY — see trancepenta-yt comment block. Position, + // wiggle, swivel, hue all derive from the whole-video phase so the + // last frame's pals sit exactly where frame 0's do. + const u = (audioT + AUDIO_DELAY_SEC) * FPS / FRAMES; + const TAU = Math.PI * 2; + const hue = ((u * 360 * 4) % 360 + 360) % 360; + const hRgb = hslToRgb(hue, 0.9, 0.62); + const col = palsFrameColor(audioT); + const env = Math.min(1, envAt(audioT)); + const glow = env * env; + const hotRgb = hslToRgb(hue, 1.0, Math.min(0.88, 0.60 + 0.34 * glow)); + const ledCol = [ + Math.round(col[0] + (hotRgb[0] - col[0]) * glow), + Math.round(col[1] + (hotRgb[1] - col[1]) * glow), + Math.round(col[2] + (hotRgb[2] - col[2]) * glow), + ]; + const wig = 13 * Math.sin(TAU * 30 * u) + 4 * Math.sin(TAU * 10 * u); + const swiv = 0.05 * Math.sin(TAU * 19 * u) + 0.025 * Math.sin(TAU * 38 * u); + // pals snug against each text column (cy from title geometry, cx + // pinned at PALS_EDGE_X). The chars climbing beside them in + // drawPalsTitleChars share the same wiggle so logo + type move as one. + const spots = [ + { cx: PALS_EDGE_X - wig, cy: LEFT_PALS_CY, rot: Math.PI / 2 + swiv }, + { cx: W - PALS_EDGE_X + wig, cy: RIGHT_PALS_CY, rot: -Math.PI / 2 - swiv }, + ]; + const passes = [ + ["multiply", 0.78], ["color-burn", 0.42], + ["overlay", 0.58], ["source-over", 0.06], + ]; + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.cx, sp.cy); + ctx.rotate(sp.rot); + palsTinted([0, 0, 0], palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(wmCanvas, -s / 2 + 3, -s / 2 + 4, s, s); + palsTinted(col, palsBlur); + for (const [op, a] of passes) { + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.30; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + if (glow > 0.001) { + palsTinted(ledCol, palsBlur); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.78 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + palsTinted(ledCol, palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.18 + 0.46 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + ctx.restore(); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + // Title chars climbing UP beside each pals stamp (movie-poster spine). + drawPalsTitleChars(audioT); +} +// "helpabeach" chars climbing VERTICALLY (rotated −90°) BESIDE each +// pals stamp — left pals at bottom: text column ABOVE it; right pals at +// top: text column BELOW it. Each char re-tinted per frame to the pals +// section+hue colour so logo + type read as one family. +function drawPalsTitleChars(audioT) { + if (!palsImg) return; + const u = (audioT + AUDIO_DELAY_SEC) * FPS / FRAMES; + const TAU = Math.PI * 2; + const wig = 13 * Math.sin(TAU * 30 * u) + 4 * Math.sin(TAU * 10 * u); + const span = titleTotalW * CHAR_SCALE; + const palsRgb = palsFrameColor(audioT); + const spots = [ + { charsCx: CHARS_EDGE_X - wig, cy: LEFT_CHARS_CY, rot: Math.PI / 2 }, + { charsCx: W - CHARS_EDGE_X + wig, cy: RIGHT_CHARS_CY, rot: -Math.PI / 2 }, + ]; + const startX = -span / 2; + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.charsCx, sp.cy); + ctx.rotate(sp.rot); + for (let i = 0; i < titleChars.length; i++) { + const ch = titleChars[i]; + if (!ch.img) continue; + const x = startX + ch.prefixWidth * CHAR_SCALE; + const dw = ch.img.width * CHAR_SCALE; + const dh = ch.img.height * CHAR_SCALE; + const charEnv = envAt(audioT - i * 0.03); + const lift = 4 * Math.sin(audioT * 4.0 + i * 0.8) * (0.3 + charEnv); + const y = -dh / 2 + lift; + // shadow stamp matching the pals shadow (solid black, α=0.26, offset 3/4) + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(tintCharGlyph(ch.img, [0, 0, 0]), x + 3, y + 4, dw, dh); + ctx.restore(); + // 4-pass seep — same blend stack as the pals so type soaks in identically. + const charPasses = [ + ["multiply", 0.78], + ["color-burn", 0.42], + ["overlay", 0.58], + ["source-over", 0.06], + ]; + for (const [op, a] of charPasses) { + ctx.save(); + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + } + // crisp top pass at higher α so smaller type still matches pals density + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.46; + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + // LED screen pulse on loud chars (mirrors the pals LED bloom) + if (charEnv > 0.45) { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.46 * Math.min(1, (charEnv - 0.45) / 0.55); + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + } + } + ctx.restore(); + } +} + +const TRANS_S = 1.5; // slide+fade transition length +// mirrorTile — show the WHOLE illy un-cropped (fit to width), mirror- +// tiled vertically to fill the frame; a gentle vertical drift reveals +// the illy's top/bottom edges instead of zoom-cropping them away. +const KB = { mirrorTile: true, breathAmp: 0.06, breathPeriodSec: 18, + envWobbleAmp: 18 }; + +// ── figure bounding boxes → zoom targets + backlight ───────────────── +// helpabeach-forms.json carries hand-placed figure bboxes per section +// (panel-image coords). ZOOM sections punch in on jeffrey's box; every +// section casts an audio-driven backlight glow on the form regions. +let FORMS = {}; +try { + // YT fork: prefer landscape-tuned forms (-yt-forms.json) if present; + // fall back to portrait forms only if no YT-specific file exists. + // The portrait bboxes are tuned for 1024×1536 panels — they will be + // wrong for the 1536×1024 landscape illys, so face-detect them fresh + // (pop/dance/bin/detect-face.py per the trancenwaltz recipe) and + // hand-place into helpabeach-yt-forms.json once the illys land. + const ytFp = `${LANE}/${SLUG.replace(/-short$/, "")}-yt-forms.json`; + const fp = `${LANE}/${SLUG.replace(/-short$/, "")}-forms.json`; + const pick = existsSync(ytFp) ? ytFp : (existsSync(fp) ? fp : null); + if (pick) FORMS = JSON.parse(readFileSync(pick, "utf8")).sections || {}; +} catch { /* no forms → plain mirror-tile, no zoom/backlight */ } +const ZOOM_SECTIONS = new Set(["swell 1", "deep-current", "swell 2", "tide-out"]); +const BACKLIGHT_RGB = "255,236,196"; // warm backlit-illumination glow +function sectionForms(name) { + const f = FORMS[name]; + if (!f) return []; + return [f.jeffrey, f.clinician].filter(Boolean) + .map(([x, y, w, h]) => ({ x, y, w, h })); +} +function sectionIndexAt(t) { + let idx = 0; + for (let i = 0; i < SECTIONS.length; i++) if (t >= SECTIONS[i].startSec) idx = i; + return idx; +} +// Section transitions are a full-frame CROSS-DISSOLVE (see drawScene) +// so the illustration never slides off and never exposes an edge. +// Draw one section panel — mirror-tile WIDE, or (on ZOOM sections) +// punched WAY into jeffrey's figure bbox — and return its panel→frame +// transform + form bboxes so the backlight can be cast on the figures. +function drawSectionPanel(img, name, audioT, env, punch, extra) { + const fs = sectionForms(name); + const zoomBox = ZOOM_SECTIONS.has(name) && fs[0] ? fs[0] : null; + const xform = drawCoverKenBurns(ctx, img, audioT, { + ...KB, env, punch, ...extra, zoomBox, + }); + return { xform, fs }; +} +function drawScene(audioT, env, punch) { + const idx = sectionIndexAt(audioT); + const sec = SECTIONS[idx]; + const sinceStart = audioT - sec.startSec; + // backlight intensity — a calm base that swells with the audio + const blI = 0.22 + env * 0.5 + punch * 0.42; + if (idx > 0 && sinceStart >= 0 && sinceStart < TRANS_S) { + let p = sinceStart / TRANS_S; + p = p * p * (3 - 2 * p); // smoothstep ease + // CROSS-DISSOLVE between the two panels. + drawSectionPanel(sectionImgs[idx - 1], SECTIONS[idx - 1].name, audioT, env, punch, + { alpha: 1, fillBackground: true }); + const cur = drawSectionPanel(sectionImgs[idx], sec.name, audioT, env, punch, + { alpha: p, fillBackground: false }); + drawFormBacklight(ctx, cur.fs, cur.xform, blI * p, BACKLIGHT_RGB); + } else { + const cur = drawSectionPanel(sectionImgs[idx], sec.name, audioT, env, punch, {}); + drawFormBacklight(ctx, cur.fs, cur.xform, blI, BACKLIGHT_RGB); + } +} + + +function drawTitle(audioT, env) { + // INSTA cut: rest at the SAME anchor the typing-intro uses (glyph-top + // = TITLE_TOP_Y, baseX = 30) so the title does NOT jump when the + // intro hands off to the track — it stays snug at the top and bounces + // from there. + const titleY = TITLE_TOP_Y + Math.ceil(titleFontSize * 1.25); + drawTitleBounce(ctx, { + chars: titleChars, + ptSize: titleFontSize, + baseX: 30, + baseY: titleY, + audioT, + env, + getEnvAt: envAt, // letters sample envelope as a wave + charDelay: 0.03, + bounceAmp: 90, + restAlpha: 1.0, // chars ALWAYS fully opaque — only + // color + bounce + glow change, + // never transparency + glowThreshold: 0.45, + glowMax: 22, + wobbleAmp: 3, + }); +} + +// TIKTOK cut: the title is typed into the CENTRE of screen, then when +// the track loads it JUMPS in and the individual characters BOUNCE +// (per-char traveling wave) and TINT to the current section colour +// (blended with each char's palette colour) — exactly like the +// trancenwaltz title — then after a few seconds the letters FALL down +// (staggered gravity + tumble + fade) and are GONE for good (no +// reappear). Glyph metrics match the centred typing-intro so the +// hand-off doesn't move. +const _ttCanvas = createCanvas(2, 2); +const _ttCtx = _ttCanvas.getContext("2d"); +function tintTitleGlyph(img, r, g, b) { + _ttCanvas.width = img.width; _ttCanvas.height = img.height; + _ttCtx.globalCompositeOperation = "source-over"; + _ttCtx.clearRect(0, 0, img.width, img.height); + _ttCtx.drawImage(img, 0, 0); + _ttCtx.globalCompositeOperation = "source-in"; + _ttCtx.fillStyle = `rgb(${r},${g},${b})`; + _ttCtx.fillRect(0, 0, img.width, img.height); + _ttCtx.globalCompositeOperation = "source-over"; + return _ttCanvas; +} +function drawCenterTitle(audioT) { + const pr = struct.prerollSec || 0; + const beatHz = (struct.bpm || 70) / 60; + const startX = Math.round((W - titleTotalW) / 2); + const gyc = Math.round((H - titleBoxH) / 2); // centred glyph-box top + const env = envAt(audioT); + const [tSr, tSg, tSb] = sectionTcRgb(audioT); // current section colour + const JUMP_T = pr; // title "loads in" w/ track + const sinceJump = audioT - JUMP_T; + const jumping = sinceJump >= 0 && sinceJump < 0.8; + const FALL_START = pr + 4.0; // bounce a bit, then drop + const baseBounce = 8, envBounce = env * 34; + for (let i = 0; i < titleChars.length; i++) { + const ch = titleChars[i]; + if (!ch || !ch.img) continue; + const img = ch.img; + const x = startX + ch.prefixWidth - 3; + const phase = (i / titleChars.length) * 2 * Math.PI; + const beatWave = Math.sin(2 * Math.PI * beatHz * audioT + phase); + let y = gyc - (baseBounce + envBounce) * Math.abs(beatWave); + if (jumping) { + const e = 1 - sinceJump / 0.8; // 1 → 0 over 0.8 s + y += -e * e * 44 * Math.cos(sinceJump * 26 - i * 0.6); + } + // staggered gravity fall + tumble + fade, then GONE (never returns) + const charFallT = FALL_START + i * 0.10; + let fdy = 0, frot = 0, falpha = 1; + if (audioT > charFallT) { + const fe = audioT - charFallT; + fdy = 0.5 * 2650 * fe * fe; // gravity (px) + const spin = (((i * 53) % 11) - 5) * 0.55; // deterministic ± per char + frot = spin * fe + 0.16 * Math.sin(fe * 9 + i); + falpha = Math.max(0, 1 - fe / 2.2); + } + if (falpha <= 0.001) continue; // gone — skip + const pal = ncHexToRgb(TITLE_PALETTE[i % TITLE_PALETTE.length]); + const cr = Math.round(tSr * 0.55 + pal[0] * 0.45); + const cg = Math.round(tSg * 0.55 + pal[1] * 0.45); + const cb = Math.round(tSb * 0.55 + pal[2] * 0.45); + const blk = tintTitleGlyph(img, 0, 0, 0); + const drawStack = (px, py) => { + ctx.globalCompositeOperation = "source-over"; + for (const [ox, oy, a] of [[-2,-1,0.18],[2,3,0.20],[5,7,0.20],[0,3,0.22]]) { + ctx.globalAlpha = a * falpha; ctx.drawImage(blk, px + ox, py + oy); + } + for (const [ox, oy, a] of [[3,4,0.95],[4,5,0.85],[3,5,0.70]]) { + ctx.globalAlpha = a * falpha; ctx.drawImage(blk, px + ox, py + oy); + } + ctx.globalAlpha = falpha; + ctx.drawImage(tintTitleGlyph(img, cr, cg, cb), px, py); + }; + ctx.save(); + if (fdy > 0 || frot !== 0) { + const cx = x + img.width / 2, cy = y + img.height / 2; + ctx.translate(cx, cy + fdy); + ctx.rotate(frot); + drawStack(-img.width / 2, -img.height / 2); + } else { + drawStack(x, y); + } + ctx.restore(); + } + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; +} + +const PROGRESS_BAR_H = 22; +const PROGRESS_BAR_Y = H - PROGRESS_BAR_H; + +// Offscreen used to recolor the (white) pre-rendered timecode glyphs +// to the current section's tint via source-in. +const tcTint = createCanvas(8, 8); +const tcTintCtx = tcTint.getContext("2d"); +// Current section color, brightened toward white by how far through +// the section we are — so the timecode reads both WHICH section and +// HOW FAR (progress) we're at. +function sectionTcRgb(audioT) { + const i = sectionIndexAt(audioT); + const s = SECTIONS[i]; + let [r, g, b] = tintRgb(SECTION_TINTS[s.name] || "rgba(255,253,242,1)"); + const span = Math.max(0.001, s.endSec - s.startSec); + const lp = Math.max(0, Math.min(1, (audioT - s.startSec) / span)); + const k = 0.30 * lp; // ramp toward white across section + r = Math.round(r + (255 - r) * k); + g = Math.round(g + (255 - g) * k); + b = Math.round(b + (255 - b) * k); + return [r, g, b]; +} +function drawTimecode(audioT) { + const totMm = Math.floor(DURATION / 60); + const totSs = Math.floor(DURATION - totMm * 60).toString().padStart(2, "0"); + const sec = Math.min(Math.max(0, Math.floor(audioT)), Math.ceil(DURATION)); + const mm = Math.floor(sec / 60); + const ss = (sec - mm * 60).toString().padStart(2, "0"); + const entry = tcCache.get(`${mm}:${ss} / ${totMm}:${totSs}`); + if (!entry) return; + const { img, shadow } = entry; + // bottom-right, sits ABOVE the segmented progress bar. The timecode + // bounces with the audio envelope (like the title) plus a small + // breathing wobble so it stays alive between hits. + const env = envAt(audioT); + const bounce = 16 * env; + const wobble = 2.2 * Math.sin(audioT * 5.5); + const x = W - img.width - 32; + const y = PROGRESS_BAR_Y - img.height - 14 - bounce + wobble; + // tight hard-offset DARK drop shadow — a real pre-rendered solid + // black glyph plate (node-canvas ignores ctx.filter), stamped twice + // for density so it reads over the bright dayglo illustration. + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.95; + ctx.drawImage(shadow, x + 3, y + 4); + ctx.drawImage(shadow, x + 2, y + 3); + ctx.globalAlpha = 1; + // colored top pass — recolor the white glyph plate to the current + // section tint (source-in keeps the glyph alpha) so the timecode + // itself reports section + progress, not just white. + const [tr, tg, tb] = sectionTcRgb(audioT); + tcTint.width = img.width; + tcTint.height = img.height; + tcTintCtx.clearRect(0, 0, img.width, img.height); + tcTintCtx.globalCompositeOperation = "source-over"; + tcTintCtx.drawImage(img, 0, 0); + tcTintCtx.globalCompositeOperation = "source-in"; + tcTintCtx.fillStyle = `rgb(${tr},${tg},${tb})`; + tcTintCtx.fillRect(0, 0, img.width, img.height); + ctx.drawImage(tcTint, x, y); + ctx.restore(); +} + +// Segmented bottom progress bar — each section paints its own tint as a +// flat band; the played portion is brighter, unplayed is faint. The +// leading edge of the played fill IS the progress indicator. +function tintRgb(s) { + const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + return m ? [+m[1], +m[2], +m[3]] : [200, 200, 200]; +} +function drawProgressBar(audioT) { + ctx.save(); + // Each section gets a near-black, section-tinted TRACK across its + // full width (so the whole bar always reads as a progress bar), with + // the PLAYED portion painted in the bright saturated tint on top. + const playedX = Math.max(0, audioT) / DURATION * W; + const lastI = struct.sections.length - 1; + for (let si = 0; si < struct.sections.length; si++) { + const sec = struct.sections[si]; + // first section starts at PREROLL_SEC (the typing-intro), so without + // this clamp the bar would begin ~3% in and never touch the LEFT + // edge — force section 0 to x=0 (mirror of the last-section→W rule). + const segX0 = si === 0 ? 0 : (sec.startSec / DURATION) * W; + // the final section runs all the way to the right edge — the + // sections end a couple seconds before the padded total, so without + // this the bar would stop ~3% short of the screen. + const segX1 = si === lastI ? W : (sec.endSec / DURATION) * W; + const [r, g, b] = tintRgb(SECTION_TINTS[sec.name] || "rgba(200,200,200,1)"); + // near-black but tinted toward this section's color — the track + ctx.fillStyle = `rgba(${Math.round(r * 0.16)},${Math.round(g * 0.16)},${Math.round(b * 0.16)},0.85)`; + ctx.fillRect(segX0, PROGRESS_BAR_Y, segX1 - segX0, PROGRESS_BAR_H); + // played: bright saturated tint + const filledX1 = Math.min(segX1, playedX); + if (filledX1 > segX0) { + ctx.fillStyle = `rgba(${r},${g},${b},0.96)`; + ctx.fillRect(segX0, PROGRESS_BAR_Y, filledX1 - segX0, PROGRESS_BAR_H); + } + // section-boundary tick + ctx.fillStyle = "rgba(255,253,242,0.35)"; + ctx.fillRect(segX1 - 1, PROGRESS_BAR_Y, 1, PROGRESS_BAR_H); + } + ctx.restore(); +} + +function hexToRgb(hex) { + return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; +} + +// Convert MIDI number → notepat color schematic { rgb: [r,g,b] }. +// notepat uses octave-dependent palettes — base=4, dayglo>=5, muted<=3. +const NOTE_NAMES = ["c","c#","d","d#","e","f","f#","g","g#","a","a#","b"]; +function midiToNotepatRgb(midi) { + const noteIdx = ((midi % 12) + 12) % 12; + const octave = Math.floor(midi / 12) - 1; + return getNoteColorForOctave(NOTE_NAMES[noteIdx], octave); +} +// ── rotating record ────────────────────────────────────────────────── +// The whole track group (lane waveforms + the playhead string) is a +// disc that rotates one plain 360° about the canvas centre across the +// full record. The string is extended well past the canvas diagonal so +// it ALWAYS cuts clear across the display at any rotation (its round +// caps never enter frame). Scene + title + bar + timecode + watermark +// stay fixed. +const ROT_CX = Math.round(W / 2); // matches the engine's disc centre +const ROT_CY = Math.round(H / 2); +const DIAG = Math.hypot(W, H); // drawPanel viewport span +// Waveforms ride a virtual record groove curved about the canvas +// CENTRE (ROT_CX/ROT_CY = screen centre). A big radius reads almost +// flat (centre off one edge); a small radius reads as a tight, very +// curved spindle. ~0.80·screen-min is a SLOWER, gentler curve — the +// record still centres but the polarity isn't so pronounced. +const GROOVE_R = Number(flags["groove-r"] ?? Math.round(Math.min(W, H) * 0.85)); +// The shared cover engine: verlet string + rotating disc + the LOCAL +// string→illustration WARP. Same movement system as trancenwaltz. +// The illustration is NOT rotated or skewed — only the lane+string +// disc rotates, and warp() bends the picture where the string is +// deflected (the distortion comes straight from the line). +const _vs = makeVerletString(ctx, { W, H, playheadX: PLAYHEAD_X, duration: DURATION }); +const trackTheta = _vs.trackTheta; +const withRotation = _vs.withRotation; +const pluckNeedle = _vs.pluck; +const stepNeedle = _vs.step; +const needleXAt = _vs.needleXAt; +const drawNeedleString = _vs.draw; +const ncHexToRgb = hexToRgb; + +// ── string→illustration warp, COUNTER-ROTATED ─────────────────────── +// The engine's warp() samples an axis-aligned strip but, run inside +// the disc rotation, redraws it rotated → the whole picture spins. +// Instead: snapshot the upright frame, rotate it −θ into a buffer, +// then redraw the strip under +θ. The two rotations cancel for the +// CONTENT (illustration stays perfectly upright, never rotates) while +// the strip + its per-row string-bend live in the string's rotated +// frame — so ONLY the distortion field under the string turns with +// the record. Feathered bands → no seam with the un-warped surround. +const WU_HALF = 200, WU_W = WU_HALF * 2, WU_STEP = 6, WU_BANDS = 7; +const WU_STR = 0.85, WU_BW = WU_W / WU_BANDS; +const wuWin = new Float64Array(WU_BANDS); +for (let b = 0; b < WU_BANDS; b++) { + wuWin[b] = Math.sin(Math.PI * ((b + 0.5) / WU_BANDS)) ** 2; +} +const WU_DSZ = Math.ceil(DIAG) + 4; +const wuSnap = createCanvas(W, H); +const wuSnapC = wuSnap.getContext("2d"); +const wuCR = createCanvas(WU_DSZ, WU_DSZ); +const wuCRC = wuCR.getContext("2d"); +function warpUnderString(theta) { + const { devPeak } = _vs.deflection(); + if (Math.abs(devPeak) < 2) return; // string ~straight → skip + // 1) snapshot the upright frame (scene + backlight) + wuSnapC.clearRect(0, 0, W, H); + wuSnapC.drawImage(canvas, 0, 0); + // 2) counter-rotate it by −θ, centred in the big square + wuCRC.setTransform(1, 0, 0, 1, 0, 0); + wuCRC.clearRect(0, 0, WU_DSZ, WU_DSZ); + wuCRC.translate(WU_DSZ / 2, WU_DSZ / 2); + wuCRC.rotate(-theta); + wuCRC.translate(-W / 2, -H / 2); + wuCRC.drawImage(wuSnap, 0, 0); + wuCRC.setTransform(1, 0, 0, 1, 0, 0); + // 3) redraw only the strip under +θ, each band shifted by the + // string's local deflection (content upright, strip rotated) + const sox = (WU_DSZ - W) / 2, soy = (WU_DSZ - H) / 2; + const x0 = Math.round(PLAYHEAD_X - WU_HALF); + ctx.save(); + ctx.translate(ROT_CX, ROT_CY); + ctx.rotate(theta); + ctx.translate(-ROT_CX, -ROT_CY); + for (let y = 0; y < H; y += WU_STEP) { + const dx = (needleXAt(y) - PLAYHEAD_X) * WU_STR; + for (let b = 0; b < WU_BANDS; b++) { + const sx = b * WU_BW; + const shift = dx * wuWin[b]; + ctx.drawImage( + wuCR, sox + x0 + sx, soy + y, WU_BW + 1, WU_STEP, + x0 + sx + shift, y, WU_BW + 1, WU_STEP, + ); + } + } + ctx.restore(); +} + +// ── boot / shutdown beep flashes ───────────────────────────────────── +// NOT radial — a TOTALIZING whole-frame flash per ac-native chime note +// (struct.events.sfx boot/shutdown-beep-N): the entire image blooms +// the chime colour and decays; shutdown also pulls the whole frame +// down then releases (a system collapse). +const beepEvents = (struct.events?.sfx || []) + .filter((e) => /^(boot|shutdown)-beep-\d+$/.test(e.name)) + .map((e) => ({ t: e.t, boot: e.name.startsWith("boot") })) + .sort((a, b) => a.t - b.t); + +// ── typing-intro: first illy under AC-prompt purple, slug typed ────── +// The whole intro lives in the pre-roll [0, PREROLL_SEC): first the +// boot beeps fire, THEN the slug types in char-by-char (each keyclick +// reveals one glyph, a blinking pink caret trails), then the track +// frame takes over at PREROLL_SEC. +const keyClicks = (struct.events?.sfx || []) + .filter((e) => /^keyclick-\d+$/.test(e.name)) + .map((e) => e.t).sort((a, b) => a - b); +const PREROLL_SEC = struct.prerollSec || 0; +// EXACT port of the trancenwaltz typing intro (pop/dance/bin/ +// cover-video.mjs) — same constants + animation so the two match: +// purple AC bg, glyphs typed from titleBaseX at glyph-box-top gy, +// and the pink block IS the cursor (rides right of the typed text, +// ~3 Hz blink). No leading block, no lozenge. +const titleBaseX = 30; +const charBoxW = Math.ceil(titleFontSize * 1.4); +const charBoxH = Math.ceil(titleFontSize * 1.7); +const charBaselineY = Math.ceil(titleFontSize * 1.25); +const IG_TOP_SAFE = TITLE_TOP_Y; +const titleY = IG_TOP_SAFE + charBaselineY; +function drawTypingIntro(audioT) { + // The FIRST illy (tide-in) sits UNDER a translucent purple wash so it + // ghosts through the AC prompt and gets extra screen-time before the + // track proper begins. + const bg = sectionImgs[0]; + const sc = Math.max(W / bg.width, H / bg.height); + const dw = bg.width * sc, dh = bg.height * sc; + ctx.drawImage(bg, (W - dw) / 2, (H - dh) / 2, dw, dh); + ctx.fillStyle = "rgba(27,0,51,0.80)"; // #1b0033 prompt purple wash + ctx.fillRect(0, 0, W, H); + const PINK = "#e0468c"; // AC prompt pink + let typed = 0; + for (const kt of keyClicks) { if (audioT >= kt) typed++; else break; } + typed = Math.min(typed, titleChars.length); + const blockW = Math.round(charBoxW * 0.30); + const blockH = Math.round(charBaselineY * 0.96); + // INSTA: type from the left at the top safe anchor. TIKTOK: type into + // the CENTRE of screen (same placement drawCenterTitle holds, so the + // hand-off to the track doesn't move). + const gy = TIKTOK + ? Math.round((H - charBoxH) / 2) + : titleY - charBaselineY; // glyph-box top (no bounce) + const startX = TIKTOK + ? Math.round((W - titleTotalW) / 2) + : titleBaseX; + const promptY = gy + Math.round((charBoxH - blockH) / 2); + for (let i = 0; i < typed; i++) { + const ch = titleChars[i]; + if (ch && ch.img) ctx.drawImage(ch.img, startX + ch.prefixWidth - 3, gy); + } + // pink block = the cursor, RIGHT of the typed text; ~3 Hz blink. + const caretPrefix = typed < titleChars.length + ? titleChars[typed].prefixWidth : titleTotalW; + const caretX = startX + caretPrefix + 4; + if (Math.floor(audioT * 6) % 2 === 0) { + ctx.fillStyle = PINK; + ctx.fillRect(Math.round(caretX), promptY, blockW, blockH); + } +} +function drawBeepRipples(audioT) { + const RIPPLE_S = 1.0; + for (const b of beepEvents) { + const dt = audioT - b.t; + if (dt < 0 || dt > RIPPLE_S) continue; + const p = dt / RIPPLE_S; // 0..1 + const fall = (1 - p) * (1 - p); // fast decay + const spike = p < 0.10 ? (1 - p / 0.10) : 0; + const tint = b.boot ? "190,255,180" : "255,165,140"; + ctx.save(); + // whole-frame bloom — the ENTIRE image flashes the chime colour + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = Math.min(0.88, 0.40 * fall + 0.58 * spike); + ctx.fillStyle = `rgb(${tint})`; + ctx.fillRect(0, 0, W, H); + if (!b.boot) { + // shutdown drags the whole frame down, then releases + ctx.globalCompositeOperation = "multiply"; + ctx.globalAlpha = 0.38 * fall; + ctx.fillStyle = "rgb(64,50,72)"; + ctx.fillRect(0, 0, W, H); + } + ctx.restore(); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; +} + +// ── beach-sunset backlight + contrast layering ─────────────────────── +// A warm low radial glow rising from behind jeffrey (lower-centre), +// pulsing with the audio envelope + the bell/sub mix, plus a soft +// vignette that deepens the contrast around it. Screen for the glow, +// multiply for the vignette — the trancenwaltz backlight idea retuned +// to a beach-sunset palette. +function drawBacklight(audioT, env) { + const idx = sectionIndexAt(audioT); + const warmByIdx = [ // dawn→dusk warmth ramp + "255,210,120", "255,196,110", "255,168,86", + "255,138,96", "255,120,140", + ]; + const tint = warmByIdx[Math.min(idx, warmByIdx.length - 1)]; + const gx = W / 2, gy = H * 0.74; // behind seated jeffrey + const pulse = 0.30 + 0.55 * env; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + const rg = ctx.createRadialGradient(gx, gy, 0, gx, gy, H * 0.62); + rg.addColorStop(0, `rgba(${tint},${(0.40 * pulse).toFixed(3)})`); + rg.addColorStop(0.5, `rgba(${tint},${(0.16 * pulse).toFixed(3)})`); + rg.addColorStop(1, `rgba(${tint},0)`); + ctx.fillStyle = rg; + ctx.fillRect(0, 0, W, H); + // contrast layer — multiply vignette darkens the periphery so the + // backlit centre reads with punch. + ctx.globalCompositeOperation = "multiply"; + const vg = ctx.createRadialGradient(gx, gy, H * 0.18, gx, gy, H * 0.78); + vg.addColorStop(0, "rgba(255,255,255,1)"); + vg.addColorStop(1, "rgba(70,52,70,1)"); + ctx.fillStyle = vg; + ctx.fillRect(0, 0, W, H); + ctx.restore(); + ctx.globalCompositeOperation = "source-over"; +} + +// (verlet string state + pluck/step/needleXAt/draw/warp all live in +// the shared cover-engine `_vs` above.) +const laneCenterY = {}; +for (let li = 0; li < LANES.length; li++) { + laneCenterY[LANES[li].key] = LANE_TOP + li * (LANE_H + LANE_GUTTER) + LANE_H / 2; +} + +function drawPanel(audioT) { + ctx.save(); + // No backing strip — image shines through the timeline panel entirely. + + // viewport — span the full canvas DIAGONAL (+pad), not just the + // width, so when the track rotates (and on tall 9:16 frames) the + // waveforms reach the corners instead of cutting off mid-air. + const halfSpanSec = (DIAG / 2 + 120) / PX_PER_SEC; + const tMin = audioT - halfSpanSec; + const tMax = audioT + halfSpanSec; + + for (let li = 0; li < LANES.length; li++) { + const lane = LANES[li]; + const y = LANE_TOP + li * (LANE_H + LANE_GUTTER); + + // events in viewport — no outlines, no fills. The waveform itself + // animates column-by-column as the playhead passes through it: + // future column → dim (anticipation) + // playhead column → brightest, with glow shadow (PRESSED) + // ~0.4s decay window → bright with falling alpha (HELD) + // past that → mid alpha (played, settled) + // Screen blending makes everything soak into the illustration. + ctx.globalCompositeOperation = "screen"; + for (const ev of laneEvents[lane.key]) { + if (ev.t > tMax) break; + const evDur = ev.dur || 0.18; + if (ev.t + evDur < tMin) continue; + // playhead pinned to canvas centre regardless of the span width + const ex = PLAYHEAD_X + (ev.t - audioT) * PX_PER_SEC; + const ew = Math.max(3, evDur * PX_PER_SEC); + const wfX = ex + 1, wfY = y + 3, wfW = ew - 2, wfH = LANE_H - 6; + if (wfW <= 1) continue; + + // pick this event's "voice color" — bells use notepat note color + const noteRgb = (lane.key === "bells" && Number.isFinite(ev.midi)) + ? midiToNotepatRgb(ev.midi) + : hexToRgb(lane.color); + + // ── alive waveform: chunky low-rez BLOCKS, alpha tied to the + // playhead. Wide filled bars (≈9px) instead of 1px lines so the + // tracks read big and blocky rather than hi-res. + const BLOCK_PX = 9; + const cols = Math.max(2, Math.floor(wfW / BLOCK_PX)); + const blockW = Math.max(3, wfW / cols - 2); + const midY = wfY + wfH / 2; + + // Per-column waveform painting. Varies alpha + line width to + // express the "press / held / active" feel as the playhead moves + // through the event. NO shadowBlur — screen blending handles the + // glow optically without the per-pixel cost of canvas shadows. + // + // Source: prefer the per-lane buffer (this lane's mix-volume + // waveform) so size reflects actual contribution. Fall back to + // the full mixed audio if the lane buffer isn't available. + const useLaneBuf = laneAudio[lane.key]; + const srcAudio = useLaneBuf ?? audio; + const srcSr = useLaneBuf ? laneSr : audioSr; + const srcPeak = useLaneBuf ? laneAudioPeak : audioPeak; + const startSampL = Math.max(0, Math.floor(ev.t * srcSr)); + const endSampL = Math.min(srcAudio.length - 1, Math.floor((ev.t + evDur) * srcSr)); + const samplesPerColL = (endSampL - startSampL) / cols; + + const rgbStr = `${noteRgb[0]},${noteRgb[1]},${noteRgb[2]}`; + // pre-compute peak per column once (loop hoist) + const colPeak = new Float32Array(cols); + for (let c = 0; c < cols; c++) { + const s0 = startSampL + Math.floor(c * samplesPerColL); + const s1 = Math.min(endSampL, startSampL + Math.floor((c + 1) * samplesPerColL)); + let p = 0; + for (let s = s0; s < s1; s++) { + const a = Math.abs(srcAudio[s]); + if (a > p) p = a; + } + colPeak[c] = p / srcPeak; + } + for (let c = 0; c < cols; c++) { + const tCol = ev.t + (c / cols) * evDur; + const dt = audioT - tCol; + let alpha; + // The needle is the LEADING EDGE: blocks to the right of the + // needle (dt < 0) are future/dim; block under the needle (dt + // just past 0) is PRESSED; past that decays into HELD/PLAYED. + if (dt < 0) { + alpha = 0.16; // future — dim + } else if (dt < 0.05) { + alpha = 1.0; // PRESSED — under needle + } else if (dt < 0.45) { + const k = (dt - 0.05) / 0.40; + alpha = 1.0 - k * 0.55; // HELD — decaying + } else { + alpha = 0.42; // played — settled + } + const half = Math.max(2, (colPeak[c] * wfH) / 2); + // waveform bends/twists with the string — its blocks ride the + // string's local deflection so the displacement reads as + // directional, not just a flat row. + const bend = (needleXAt(midY) - PLAYHEAD_X) * 0.5; + const bx = wfX + (c / cols) * wfW + bend; + // …then curve the whole track around the record: rotate the + // block about the disc centre by its time-offset / GROOVE_R, so + // it's straight at the needle and arcs more the further out. + const aGroove = (bx - PLAYHEAD_X) / GROOVE_R; + ctx.save(); + ctx.translate(ROT_CX, ROT_CY); + ctx.rotate(aGroove); + ctx.translate(-ROT_CX, -ROT_CY); + ctx.fillStyle = `rgba(${rgbStr},${alpha.toFixed(3)})`; + ctx.fillRect(bx, midY - half, blockW, half * 2); + ctx.restore(); + } + } + } + + // reset blending so the needle + chrome paint normally + ctx.globalCompositeOperation = "source-over"; + + // (the verlet string is drawn later, after the progress bar, so it + // runs THROUGH the bar with no gap) + ctx.restore(); +} + +// ── ffmpeg subprocess (BGRA stdin → mp4 with audio) ────────────────── +const ff = spawnFFmpegEncode({ + audioPath: AUDIO, w: W, h: H, fps: FPS, outPath: OUT, crf: 20, +}); +let writeOk = true; +ff.stdin.on("error", () => { writeOk = false; }); + +// ── perfect-loop cross-dissolve ────────────────────────────────────── +// Snapshot frame 0; over the last LOOP_S the finished frame dissolves +// back into that first frame so the video wraps seamlessly. +const LOOP_S = 1.4; +const loopCanvas = createCanvas(W, H); +const loopCtx = loopCanvas.getContext("2d"); +let loopCaptured = false; + +// ── frame loop ─────────────────────────────────────────────────────── +const t0 = Date.now(); +let nextLog = 0.05; +let prevNeedleT = -1; +const laneList = LANES.slice(); +// Heavily-smoothed scene-motion state (EMA across frames) so the +// verlet vibe DISTORTS the illustration smoothly and never jumps. +let smPunch = 0; +// progress heartbeat → ~/.ac-pop-renders/ (Slab menubar reads it) +progress.begin({ type: "video", label: `${SLUG} ·yt-landscape` }); +for (let f = 0; f < FRAMES; f++) { + const t = f / FPS; + // Shift visual readout by AAC priming delay so blocks cross the + // playhead at the instant the listener actually hears them. + const audioT = t - AUDIO_DELAY_SEC; + const env = envAt(audioT); + const punch = kickEnvAt(audioT); + + // TYPING-INTRO window: first illy under the purple AC prompt for the + // whole pre-roll [0, PREROLL_SEC) — the boot beeps fire FIRST, then + // the slug types in, then the track takes over. Skips the normal + // track render (string held) but still feeds the shared frame + // write + loop capture, so frame 0 IS the typed-prompt opener. + if (keyClicks.length && audioT < PREROLL_SEC) { + drawTypingIntro(audioT); + // Pals are STAMPED ON over the typed prompt too, so they persist + // for the WHOLE video (typing → boot → track → shutdown). TikTok + // cut has NO side pals. Their wiggle is a perfect loop. + if (!TIKTOK) drawWatermark(audioT); + // boot beeps now play DURING this window — flash them here too. + drawBeepRipples(audioT); + prevNeedleT = audioT; // don't backfill plucks at hand-off + } else { + + // pluck the verlet string for every event whose onset falls in + // this frame (block contacting the needle at that lane's y), then + // step the physics once. + for (let li = 0; li < laneList.length; li++) { + const lane = laneList[li]; + const cy = laneCenterY[lane.key]; + const lrgb = ncHexToRgb(lane.color); // string segment glows this + // Gentle plucks — a soft string bend reads as an ORGANIC local + // warp of the picture; big deflections ghost/tear the strip. + let amp = 7; + if (lane.key === "kick" || lane.key === "sub") amp = 12; + else if (lane.key === "bells") amp = 8; + else if (lane.key === "hat" || lane.key === "bubbles") amp = 5; + for (const ev of laneEvents[lane.key]) { + if (ev.t <= prevNeedleT) continue; + if (ev.t > audioT) break; + const sign = ((Math.floor(ev.t * 7) + li) % 2) ? 1 : -1; + pluckNeedle(cy, amp, sign, lrgb); + } + } + prevNeedleT = audioT; + stepNeedle(); + + const theta = trackTheta(audioT); + + // Illustration: SMOOTH ken-burns only (drawScene/drawCoverKenBurns + // already breathes + soft-kick-zooms). NO rotation, NO global skew — + // it never jumps. The kick punch is EMA-smoothed so the zoom glides. + smPunch = smPunch * 0.80 + punch * 0.20; + drawScene(audioT, env, smPunch); + + // beach-sunset backlight + contrast layering over the illustration. + drawBacklight(audioT, env); + + // ONLY the distortion field under the string rotates with the disc — + // the illustration content itself stays upright (counter-rotated). + warpUnderString(theta); + + // Rotating disc: the lane waveforms + the string itself. + withRotation(theta, () => { + drawPanel(audioT); + drawNeedleString(); + }); + + // fixed HUD, drawn OVER the rotating string. + // INSTA: bouncing top title + section progress bar + timecode + side + // pals. TIKTOK: centred title that slowly fades, and NOTHING else — + // no progress bar, no timecode, no side pals. + if (TIKTOK) { + drawCenterTitle(audioT); + } else { + // YT cut: title now lives ON the pals (drawWatermark calls + // drawPalsTitleChars at the end). No horizontal top title. + drawProgressBar(audioT); + drawTimecode(audioT); + drawWatermark(audioT); + } + + // boot/shutdown beep ripples — fullscreen, on top of everything. + drawBeepRipples(audioT); + } // ── end normal-track render (else of typing-intro) ─────────────── + + // LOOP TRANSITION — capture frame 0, then over the tail SHRED frame + // 0 in over jittered horizontal bands and FLASH-BULB the final + // frames to blank white. On the wrap the clean frame 0 snaps back → + // reads as a hard glitchy flash cut, not a soft dissolve. + if (!loopCaptured) { + loopCtx.drawImage(canvas, 0, 0); + loopCaptured = true; + } else if (audioT > DURATION - LOOP_S) { + let lp = (audioT - (DURATION - LOOP_S)) / LOOP_S; + lp = Math.max(0, Math.min(1, lp)); + ctx.save(); + const bh = 22; // shred band height + const seed = Math.floor(audioT * 30) >>> 0; // re-jitter cadence + for (let by = 0; by < H; by += bh) { + let r = ((by * 2654435761) >>> 0) ^ seed; + r = (r ^ (r >>> 13)) >>> 0; + const rnd = ((r % 1000) / 1000) - 0.5; // -0.5..0.5 + const reveal = ((r >>> 7) % 1000) / 1000; // per-band entry time + if (lp < reveal * 0.5) continue; // bands shred in over time + const jit = rnd * (50 + 260 * lp) * lp; // x-shred grows with lp + ctx.globalAlpha = Math.min(1, 0.4 + lp * 1.0); + ctx.drawImage(loopCanvas, 0, by, W, bh, jit, by, W, bh); + } + if (lp > 0.6) { // flash-bulb white blowout + const fp = (lp - 0.6) / 0.4; + ctx.globalAlpha = Math.min(1, fp * fp * 1.2); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, W, H); + } + ctx.restore(); + } + + const buf = canvas.toBuffer("raw"); + if (!writeOk) break; + if (!ff.stdin.write(buf)) { + await new Promise((res) => ff.stdin.once("drain", res)); + } + const pct = (f + 1) / FRAMES; + progress.update(pct * 100, { done: f + 1, total: FRAMES }); + if (pct >= nextLog) { + const elapsed = (Date.now() - t0) / 1000; + const eta = elapsed / pct - elapsed; + process.stdout.write(`\r rendering… ${(pct * 100).toFixed(0)}% elapsed ${elapsed.toFixed(0)}s eta ${eta.toFixed(0)}s `); + nextLog += 0.05; + } +} +process.stdout.write("\n"); + +ff.stdin.end(); +await new Promise((res) => ff.on("close", res)); +progress.end(); +console.log(`✓ ${OUT.replace(REPO + "/", "")}`); diff --git a/pop/chillwave/helpabeach.youtube.txt b/pop/chillwave/helpabeach.youtube.txt index 5c7ce4d557..d11a305ba3 100644 --- a/pop/chillwave/helpabeach.youtube.txt +++ b/pop/chillwave/helpabeach.youtube.txt @@ -1,11 +1 @@ -helpabeach — a 2:31 chillwave / ambient instrumental, 84 bpm, a minor pentatonic. freesound cc0 calm-ocean bed, all-sine intro rollers, sub-octave sine bells, soft pad, and an in-render formant computer-voice on wordless nonsense phonemes from ~0:47. no chimes, all-sine. from the pixsies body of work. - -what you're watching is the song's storyline as nine clinic panels — a quiet visit to rhizome health, a small fictional aesthetic.computer clinic. tide in → drift → swell → undertow → ebb. the audio, the visuals, the whole machine were built inside aesthetic.computer. - -artist · aesthetic dot computer -made with · aesthetic.computer - -▸ listen · https://open.spotify.com/track/3jzlAylJQLSsNIXjnEY1e8 -▸ visit · https://aesthetic.computer - #chillwave #ambient #visualizer #aestheticcomputer #pixsies #instrumental diff --git a/pop/dance/trancenwaltz.youtube.txt b/pop/dance/trancenwaltz.youtube.txt index 653883e97a..3402998a19 100644 --- a/pop/dance/trancenwaltz.youtube.txt +++ b/pop/dance/trancenwaltz.youtube.txt @@ -1,11 +1 @@ -trancenwaltz — a dark trance-waltz in 3/4, a small war arc folded into a minute and a half. from the pixsies body of work. - -what you're watching is the song's own score, scrolling — every kick, sub, bell and breath drawn as it fires. nothing here is stock footage. the audio, the visuals, the whole machine were built inside aesthetic.computer. - -artist · aesthetic dot computer -made with · aesthetic.computer - -▸ listen · https://open.spotify.com/track/3PIPwPqptVlWy71rCEhQum -▸ visit · https://aesthetic.computer - #trance #waltz #visualizer #aestheticcomputer #electronic diff --git a/pop/dance/trancepenta.youtube.txt b/pop/dance/trancepenta.youtube.txt index 4976e28c99..208f3260cc 100644 --- a/pop/dance/trancepenta.youtube.txt +++ b/pop/dance/trancepenta.youtube.txt @@ -1,11 +1 @@ -trancepenta — a 5/4 chill-trance instrumental, 126 bpm, dorian. galloping intro, theremin solo arc, jeffrey-hum wandering through nine pitches, supersaw walls crescendoing to a 1:33 stamp, then a dice-roll tumble in the vortex. from the pixsies body of work. - -what you're watching is the song's own score, scrolling — every kick, sub, theremin and hum drawn as it fires. nothing here is stock footage. the audio, the visuals, the whole machine were built inside aesthetic.computer. - -artist · aesthetic dot computer -made with · aesthetic.computer - -▸ listen · https://open.spotify.com/track/4SVH80CTkq2BihSlSyiJhG -▸ visit · https://aesthetic.computer - #trance #chilltrance #electronic #visualizer #aestheticcomputer #pixsies diff --git a/pop/marimba/bin/gen-sections.mjs b/pop/marimba/bin/gen-sections.mjs index 1727bd3b67..ff578e4acc 100644 --- a/pop/marimba/bin/gen-sections.mjs +++ b/pop/marimba/bin/gen-sections.mjs @@ -23,11 +23,15 @@ // // Output: pop/marimba/out/marimbaba-p-cover.png (portrait hero) // pop/marimba/out/marimbaba-p-sec--.png (10 panels) +// Landscape variant (--landscape): +// pop/marimba/out/marimbaba-yt-cover.png (16:9 hero) +// pop/marimba/out/marimbaba-yt-sec--.png (10 panels) // // Usage: // node pop/marimba/bin/gen-sections.mjs # cached // node pop/marimba/bin/gen-sections.mjs --force # regen all // node pop/marimba/bin/gen-sections.mjs --only wow2 # one panel +// node pop/marimba/bin/gen-sections.mjs --landscape # 1536x1024 -yt set for YouTube import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -48,7 +52,13 @@ for (let i = 2; i < process.argv.length; i++) { else { flags[a.slice(2)] = next; i++; } } const FORCE = flags.force === true; -const SIZE = "1024x1536"; // portrait 9:16-ish for the story cut +// --landscape → 1536x1024 wide 3:2 / 16:9 set with "-yt" filename infix, +// used by the YouTube widescreen visualizer fork +// (preview-score-marimbaba-yt.mjs). Default stays portrait 9:16 for the +// existing insta-story cut. +const LANDSCAPE = flags.landscape === true; +const SIZE = LANDSCAPE ? "1536x1024" : "1024x1536"; +const TAG = LANDSCAPE ? "-yt" : "-p"; mkdirSync(`${LANE}/out`, { recursive: true }); // ── identity refs (mirrors marimba/bin/gen-illy.mjs) ───────────────── @@ -85,6 +95,10 @@ function loadOpenAIKey() { const PORTRAIT_NOTE = `PORTRAIT OVERRIDE — recompose for a TALL vertical 9:16 frame (NOT the square album-cover crop). PULL THE CAMERA BACK and slightly UP — a wide, roomy shot: the WHOLE setting of this beat is visible around the figures, the figures sitting/standing comfortably WITHIN it, never cropped tight. keep the colored-pencil + gouache medium on warm cream paper, the confident hatching + striping, diegetic light only (warm desk-lamp / window light), and the calm somber late-night lullaby mood — only the framing widens.`; +// ── LANDSCAPE recomposition — widen the album-cover crop horizontally ─ +const LANDSCAPE_NOTE = +`LANDSCAPE OVERRIDE — recompose for a WIDE 3:2 / 16:9 frame (NOT square, NOT tall). PULL THE CAMERA BACK so the WHOLE late-night study / setting of this beat stretches horizontally across the frame: desk, lamp, bookshelf, window, armchair, doorway all visible across the width, with generous LEFT and RIGHT breathing space around the figures, NEVER cropped tight, NEVER tall. FIGURES sit centered along the lower-and-mid band so the upper-third of the frame has cream-paper / wall / shelf breathing room, allowing space for YouTube visualizer chrome (title, score-train, progress bar) to sit without occluding their faces or the typewriter. keep the colored-pencil + gouache medium on warm cream paper, the confident hatching + striping, diegetic light only (warm desk-lamp / window light), and the calm somber late-night lullaby mood — only the framing widens.`; + // ── shared scene law (distilled from marimbaba.illy.txt) ───────────── const MEDIUM = `A colored-pencil + gouache drawing on warm cream paper. confident hatching and striping build tone — tapered pencil edges, visible paper grain, optical mixing in the knit textures. diegetic light only — a warm desk-lamp glow, no overlay shine. NOT a photograph, NOT cinematic, NOT neon. a hushed late-night world — but the two men's EXPRESSIONS and MOOD genuinely SHIFT from beat to beat as the little story moves: read the EMOTION called out in this beat's description and let their faces and posture show it clearly and distinctly. peers at one shared eye-line, neither centered as a hero; neither looks at the camera.`; @@ -144,9 +158,14 @@ function safeName(n) { return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } function build(sectionBeat, tightCrop) { - const portrait = tightCrop ? "" : `\n\n${PORTRAIT_NOTE}`; + // tightCrop=true → portrait cover keeps the locked album-cover crop + // (no orientation note). For landscape mode the cover IS widened, so + // tightCrop is ignored and LANDSCAPE_NOTE is always appended. + const orient = LANDSCAPE + ? `\n\n${LANDSCAPE_NOTE}` + : (tightCrop ? "" : `\n\n${PORTRAIT_NOTE}`); return [MEDIUM, JEFFREY, GATES, SELECTRIC, sectionBeat, PALETTE, AVOID].join("\n\n") - + portrait + "\n"; + + orient + "\n"; } const apiKey = loadOpenAIKey(); @@ -233,18 +252,18 @@ const wants = (name) => !onlySet || onlySet.has(name); const jobs = []; if (wants("cover")) - jobs.push({ prompt: build(COVER_VARIANT, true), out: `${LANE}/out/marimbaba-p-cover.png`, label: "marimbaba-p cover" }); + jobs.push({ prompt: build(COVER_VARIANT, true), out: `${LANE}/out/marimbaba${TAG}-cover.png`, label: `marimbaba${TAG} cover` }); for (let i = 0; i < SECTION_ORDER.length; i++) { const name = SECTION_ORDER[i]; if (!wants(name)) continue; jobs.push({ prompt: build(SECTION_VARIANTS[name], false), - out: `${LANE}/out/marimbaba-p-sec-${i}-${safeName(name)}.png`, - label: `marimbaba-p §${i} ${name}`, + out: `${LANE}/out/marimbaba${TAG}-sec-${i}-${safeName(name)}.png`, + label: `marimbaba${TAG} §${i} ${name}`, }); } -progress.begin({ type: "illy", label: `marimbaba-p · ${jobs.length} panels` }); +progress.begin({ type: "illy", label: `marimbaba${TAG} · ${jobs.length} panels` }); let done = 0; for (const job of jobs) { await generate(job.prompt, job.out, job.label); diff --git a/pop/marimba/bin/preview-score-marimbaba-yt.mjs b/pop/marimba/bin/preview-score-marimbaba-yt.mjs new file mode 100644 index 0000000000..38fd9ea6ef --- /dev/null +++ b/pop/marimba/bin/preview-score-marimbaba-yt.mjs @@ -0,0 +1,1309 @@ +#!/usr/bin/env node +// marimba/bin/preview-score.mjs — storyline visualizer for marimbaba. +// +// Renders the 10 help-call section panels (gen-sections.mjs) into a +// 9:16 Instagram-Story mp4. Built on the shared engine +// (pop/lib/preview-shared.mjs + cover-engine.mjs). Features: +// +// • THE STRING + SOUND ELEMENTS — a verlet-physics playhead string +// down the centre; the marimba's 161 note events (struct.json, +// emitted by render-marimbaba.mjs) ride past it as pitch-coloured +// blocks across 4 voice lanes (rosewood / bass / kalimba / +// vibraphone). Each note PLUCKS the string as it crosses. +// • 3-LAYER BACKLIGHT — (1) a warm glow rising from BEHIND the +// figures [screen], (2) a contrast vignette darkening the +// periphery so they read backlit, not lit-on-top [multiply], +// (3) a per-figure halo on the form bboxes [screen]. +// • FACE ZOOM in + out — the camera eases between a wide framing +// and a tight face crop twice per section, alternating jeffrey / +// gates, so it breathes in and out across the story. +// • TRANSITIONS — 6 fresh transition types (iris / blinds / push / +// zoom-punch / pixel-dissolve / diagonal-wipe), cycled across the +// 9 section boundaries — none of them the trancenwaltz cross- +// dissolve / slide-fade / shred-glitch. +// • side pals watermarks, bouncing title, segmented progress bar, +// timecode. +// +// Usage: +// node pop/marimba/bin/render-marimbaba.mjs # → audio + struct.json +// node pop/marimba/bin/gen-sections.mjs # → the 10 panels +// node pop/marimba/bin/preview-score.mjs # → the mp4 + +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { createCanvas, loadImage } from "canvas"; +import * as progress from "../../lib/render-progress.mjs"; +import { getNoteColorForOctave } from "../../../system/public/aesthetic.computer/lib/note-colors.mjs"; +import { + checkYwftAvailable, decodeAudioMono, computeRmsEnvelope, + prerenderTitleChars, magickRenderText, drawCoverKenBurns, + drawTitleBounce, spawnFFmpegEncode, AUDIO_SR_DEFAULT, +} from "../../lib/preview-shared.mjs"; +import { makeVerletString, hexToRgb } from "../../lib/cover-engine.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const REPO = resolve(LANE, "../.."); + +const flags = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (!a.startsWith("--")) continue; + const next = process.argv[i + 1]; + if (next === undefined || next.startsWith("--")) flags[a.slice(2)] = true; + else { flags[a.slice(2)] = next; i++; } +} + +const SLUG = flags.slug || "marimbaba"; +const TITLE = flags.title || SLUG; +// YT fork: always landscape, always chromed (no --reel branch). Uses +// the "-yt" landscape illy set from gen-sections.mjs --landscape. +const COVER = flags.cover || `${LANE}/out/${SLUG}-yt-cover.png`; +const AUDIO = flags.audio || `${LANE}/out/${SLUG}.mp3`; +const STRUCT = flags.struct || `${LANE}/out/${SLUG}.struct.json`; +const OUT = flags.out || `${LANE}/out/${SLUG}-preview-score-yt.mp4`; +const FPS = Number(flags.fps ?? 30); +const SIZE = flags.size || "1920x1080"; +const [W, H] = SIZE.split("x").map(Number); +const REEL = false; // YT cut is always chromed +// --start T --frames N → tiny test render (~1s) so layout tweaks can +// be checked in seconds instead of an 11-min full render. +const START_T = Number(flags.start ?? 0); +const FRAMES_OVERRIDE = flags.frames ? Number(flags.frames) : null; + +checkYwftAvailable(); +for (const [name, p] of [["audio", AUDIO], ["struct", STRUCT]]) { + if (!existsSync(p)) { + console.error(`✗ ${name} missing: ${p.replace(REPO + "/", "")}`); + if (name === "struct") console.error(` emit it: node pop/marimba/bin/render-marimbaba.mjs --no-open`); + process.exit(1); + } +} + +const struct = JSON.parse(readFileSync(STRUCT, "utf8")); +const DURATION = struct.totalSec; +const FRAMES = Math.ceil(DURATION * FPS); +const SECTIONS = struct.sections.slice().sort((a, b) => a.startSec - b.startSec); +console.log(`▸ ${SLUG} storyline visualizer · ${W}x${H} · ${DURATION.toFixed(1)}s · ${FRAMES} frames · ${SECTIONS.length} sections`); + +// ── audio decode + envelope ────────────────────────────────────────── +console.log(" decoding audio …"); +const { audio, sr: audioSr, audioPeak } = decodeAudioMono(AUDIO, AUDIO_SR_DEFAULT); +const envelope = computeRmsEnvelope(audio, audioSr, FPS, DURATION); +function envAt(t) { + const idx = Math.floor(t * FPS); + return (idx < 0 || idx >= envelope.length) ? 0 : envelope[idx]; +} + +// ── voice lanes — the marimba's sound elements ─────────────────────── +// 5 voices from struct.events. Each lane gets a base colour; individual +// notes are tinted toward their notepat pitch-colour. +const LANES = [ + { key: "rosewood", color: "#e8a13c" }, // warm amber — the singer / lead + { key: "vibraphone", color: "#7fb3c8" }, // soft blue — dream-haze pad + { key: "kalimba", color: "#ffd24a" }, // bright gold — twinkles + { key: "bass", color: "#c8643c" }, // deep terracotta — the rocking pulse + { key: "bubbles", color: "#b88ed4" }, // soft violet — SDT bubble accents +]; +const laneEvents = {}; +for (const L of LANES) { + const evs = (struct.events?.[L.key] || []).slice().sort((a, b) => a.t - b.t); + // STACKROW assignment — events that overlap in time within the same + // lane get bumped onto a sub-row so they don't draw on top of each + // other. Greedy: each event goes into the first row whose previous + // event has already ended. + const rowEndT = []; + let maxRows = 1; + for (const ev of evs) { + let row = 0; + while (row < rowEndT.length && rowEndT[row] > ev.t + 1e-4) row++; + ev.stackRow = row; + const evEnd = ev.t + (ev.dur || 0.25); + if (row >= rowEndT.length) rowEndT.push(evEnd); + else rowEndT[row] = evEnd; + if (row + 1 > maxRows) maxRows = row + 1; + } + L.maxStackRows = maxRows; + laneEvents[L.key] = evs; +} +const nEvents = Object.values(laneEvents).reduce((s, a) => s + a.length, 0); +console.log(` sound elements: ${nEvents} note events across ${LANES.length} voice lanes`); + +// bass onsets drive the "sharpen" punch (no kick in a lullaby). +const punchTimes = laneEvents.bass.map((e) => e.t); +function punchAt(t) { + let e = 0; + for (const pt of punchTimes) { + if (pt > t + 0.04) break; + const dt = t - pt; + const v = dt < 0 ? Math.max(0, 1 + dt / 0.03) : Math.exp(-dt / 0.5); + if (v > e) e = v; + } + return e; +} + +// ── notepat pitch colour ───────────────────────────────────────────── +const NOTE_NAMES = ["c","c#","d","d#","e","f","f#","g","g#","a","a#","b"]; +function midiToNotepatRgb(midi) { + if (!Number.isFinite(midi)) return [220, 220, 210]; + const noteIdx = ((midi % 12) + 12) % 12; + const octave = Math.floor(midi / 12) - 1; + const c = getNoteColorForOctave(NOTE_NAMES[noteIdx], octave); + return Array.isArray(c) ? c : [c.r ?? 220, c.g ?? 220, c.b ?? 210]; +} + +// ── marimbaba identity ─────────────────────────────────────────────── +// Warm lullaby pastels — one tint per story section, the help call's +// changing emotion (anticipation → hello → relief → cozy → uncertain → +// wonder → effort → teamwork → triumph → drowsy calm). +const SECTION_TINTS = { + hush1: "rgba(120,140,190,1)", // cool dusk — anticipation + hush2: "rgba(150,170,210,1)", // dusk blue — the doorway hello + twinkle1: "rgba(214,200,165,1)", // warming oat — relief + twinkle2: "rgba(238,228,205,1)", // warm cream — cozy + wow1: "rgba(206,176,150,1)", // muted clay — uncertainty + wow2: "rgba(244,206,128,1)", // bright amber — wonder + baba1: "rgba(196,176,150,1)", // tense taupe — anxious effort + baba2: "rgba(176,196,168,1)", // sage — teamwork + sleep1: "rgba(236,196,160,1)", // warm glow — triumph + sleep2: "rgba(214,180,180,1)", // soft rose — drowsy close +}; +const TITLE_PALETTE = ["#f0e6cd", "#a6bcd6", "#b0c4a8", "#eec48c", "#d6b4b4"]; +const BACKLIGHT_RGB = "255,196,116"; // deeper warm desk-lamp amber (was too pale) + +// ── YWFT title + per-second timecode ───────────────────────────────── +console.log(" rasterizing YWFT …"); +const assetsDir = AUDIO.replace(/\.mp3$/, ".assets"); +mkdirSync(assetsDir, { recursive: true }); + +const titleFontSize = 96; +// shadowColor: null — bake NO shadow into the pre-rendered chars, so +// when we source-in tint each glyph we get a CLEAN letter alpha mask. +// (We draw our own controlled black shadow + colored seep passes per +// frame in drawPalsTitleChars.) +const { chars: titleChars, totalWidth: titleTotalW } = await prerenderTitleChars({ + text: TITLE, ptSize: titleFontSize, palette: TITLE_PALETTE, + shadowColor: null, assetsDir, +}); +// ── Title/pals geometry (shared so the pals stamp lands SNUG against +// the first char of each column with no animation overlap) ────────── +const PALS_S = 145; // pals stamp size (square) +const PALS_HALF = PALS_S / 2; +const PALS_EDGE_X = 100; // cx of pals (left side) — nudged further from the edge +const CHARS_EDGE_X = PALS_EDGE_X + 12; // chars sit just inward of the pals +const CHAR_SCALE = 0.52; +const CHAR_SPAN = titleTotalW * CHAR_SCALE; // vertical length of the rotated text column +const BOUNCE_BUF = 26; // vertical gap pals → first char (more breathing) +const LEFT_CHARS_CY = H * 0.82 - 16; // bottom-left group nudged UP toward centre (+16px) +const RIGHT_CHARS_CY = H * 0.18 + 32 + 16; // top-right group nudged DOWN toward centre (+32+16px) +// LEFT column reads top-down → 'm' at TOP of column; pals sits JUST +// above it. RIGHT column reads bottom-up → 'm' at BOTTOM; pals sits +// JUST below it. Both auto-snug, no manual y values to nudge. +const LEFT_PALS_CY = LEFT_CHARS_CY - CHAR_SPAN / 2 - BOUNCE_BUF - PALS_HALF; +const RIGHT_PALS_CY = RIGHT_CHARS_CY + CHAR_SPAN / 2 + BOUNCE_BUF + PALS_HALF; +const TITLE_TOP_Y = 104; + +const tcFontSize = 60; +const tcCache = new Map(); +async function getTcImg(text) { + if (tcCache.has(text)) return tcCache.get(text); + const safe = text.replace(/[^0-9]/g, "_"); + const img = await magickRenderText(text, { + ptSize: tcFontSize, fill: "rgba(255,253,242,0.97)", + outPath: `${assetsDir}/tc.${safe}.png`, + }); + const shadow = await magickRenderText(text, { + ptSize: tcFontSize, fill: "rgba(0,0,0,1)", + outPath: `${assetsDir}/tc.${safe}.shadow.png`, + }); + const entry = { img, shadow }; + tcCache.set(text, entry); + return entry; +} +const totMm = Math.floor(DURATION / 60); +const totSs = Math.floor(DURATION - totMm * 60).toString().padStart(2, "0"); +for (let s = 0; s <= Math.ceil(DURATION); s++) { + const mm = Math.floor(s / 60); + const ss = (s - mm * 60).toString().padStart(2, "0"); + await getTcImg(`${mm}:${ss} / ${totMm}:${totSs}`); +} + +// ── canvas + section panels ────────────────────────────────────────── +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); +// two offscreen layers for the transitions (prev panel / cur panel) +const offA = createCanvas(W, H), offACtx = offA.getContext("2d"); +const offB = createCanvas(W, H), offBCtx = offB.getContext("2d"); + +function safeName(n) { + return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} +let coverImg = existsSync(COVER) ? await loadImage(COVER) : null; +const sectionImgs = []; +let haveImgs = 0; +for (let i = 0; i < SECTIONS.length; i++) { + // YT fork: load the -yt landscape panel set. + const p = `${LANE}/out/${SLUG}-yt-sec-${i}-${safeName(SECTIONS[i].name)}.png`; + if (existsSync(p)) { sectionImgs[i] = await loadImage(p); haveImgs++; } + else if (coverImg) sectionImgs[i] = coverImg; + else { + console.error(`✗ section panel missing and no cover fallback: ${p.replace(REPO + "/", "")}`); + console.error(` generate panels first: node pop/marimba/bin/gen-sections.mjs --landscape`); + process.exit(1); + } +} +console.log(` section panels: ${haveImgs}/${SECTIONS.length}`); +const imgW = sectionImgs[0].width, imgH = sectionImgs[0].height; + +// ── figure + face bboxes (forms.json) ──────────────────────────────── +let FORMS = {}; +try { + // YT fork: prefer landscape-tuned forms (-yt-forms.json) if present; + // portrait bboxes are wrong for 1536×1024 landscape panels, so skip + // them if no -yt forms exist (graceful fallback: no zoom/backlight). + const ytFp = `${LANE}/${SLUG}-yt-forms.json`; + const fp = `${LANE}/${SLUG}-forms.json`; + if (existsSync(ytFp)) FORMS = JSON.parse(readFileSync(ytFp, "utf8")).sections || {}; + else if (existsSync(fp)) FORMS = {}; // skip portrait forms on landscape +} catch { /* no forms → centre fallback */ } +// Supports old shape `figure: [x,y,w,h]` and new shape +// `figure: { figure: [...], face: [...] }`. Backwards-compatible. +function _toBox(v) { + if (!v) return null; + if (Array.isArray(v)) return { x: v[0], y: v[1], w: v[2], h: v[3] }; + if (Array.isArray(v.figure)) return { x: v.figure[0], y: v.figure[1], w: v.figure[2], h: v.figure[3] }; + return null; +} +function _toFace(v) { + if (!v || Array.isArray(v)) return null; + if (Array.isArray(v.face)) return { x: v.face[0], y: v.face[1], w: v.face[2], h: v.face[3] }; + return null; +} +function figureBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return [_toBox(f.jeffrey), _toBox(f.gates)].filter(Boolean); +} +function faceBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return [_toFace(f.jeffrey), _toFace(f.gates)].filter(Boolean); +} +const FRAME_ASPECT = W / H; +// Grow a box about its centre to the frame's W:H aspect so the zoom +// fills without side-crop. +function fitFrameAspect(b) { + let { x, y, w, h } = b; + if (w / h > FRAME_ASPECT) { const nh = w / FRAME_ASPECT; y -= (nh - h) / 2; h = nh; } + else { const nw = h * FRAME_ASPECT; x -= (nw - w) / 2; w = nw; } + return { x, y, w, h }; +} +// Use the explicit face box if forms.json carries one; otherwise +// derive a generous frame-aspect box centred on the figure's head. +function faceBoxFor(face, figure) { + if (face) return fitFrameAspect(face); + let w = figure ? Math.max(figure.w * 0.82, imgW * 0.42) : imgW * 0.42; + let h = w / FRAME_ASPECT; + if (h > imgH) { h = imgH; w = h * FRAME_ASPECT; } + const cx = figure ? figure.x + figure.w / 2 : imgW / 2; + const cy = figure ? figure.y + figure.h * 0.18 : imgH * 0.3; + let x = Math.max(0, Math.min(imgW - w, cx - w / 2)); + let y = Math.max(0, Math.min(imgH - h, cy - h / 2)); + return { x, y, w, h }; +} +const WHOLE_BOX = { x: 0, y: 0, w: imgW, h: imgH }; +function lerpBox(a, b, t) { + return { + x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, + w: a.w + (b.w - a.w) * t, h: a.h + (b.h - a.h) * t, + }; +} + +// ── pals watermark — two SIDE stamps, faithful to chillwave/dance ──── +// rasterize a sharp + a slightly-blurred copy, then tint each per +// frame to the current section colour blended with a slow hue cycle; +// stamp with multiply/burn/overlay + a source-over crisp top pass + +// an LED-pulse screen glow driven by the audio amplitude. Two stamps +// hug the LEFT-BOTTOM and RIGHT-TOP edges, rotated 90° with a +// looping wiggle + swivel locked to the video timeline. +const PALS_WM_SIZE = 212; +let palsImg = null, palsBlur = null; +const wmCanvas = createCanvas(8, 8); +const wmCtx = wmCanvas.getContext("2d"); +{ + const svg = `${REPO}/system/public/purple-pals.svg`; + const png = `${assetsDir}/pals-watermark.png`; + const blurPng = `${assetsDir}/pals-watermark-blur.png`; + if (existsSync(svg)) { + const r = spawnSync("rsvg-convert", ["-w", String(PALS_WM_SIZE * 2), "-h", String(PALS_WM_SIZE * 2), "-o", png, svg]); + if (r.status === 0 && existsSync(png)) { + palsImg = await loadImage(png); + // a whisper of edge softening so the vector stamps sit in the + // hand-drawn world (not a gaussian ghost — just kills the hard edge). + const rb = spawnSync("magick", [png, "-channel", "A", "-blur", "0x1.5", "+channel", blurPng]); + palsBlur = (rb.status === 0 && existsSync(blurPng)) ? await loadImage(blurPng) : palsImg; + } + } + console.log(` pals watermark: ${palsImg ? "loaded" : "MISSING (skipped)"}`); +} +function hslToRgb(h, s, l) { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} +// Compute the pals's current frame colour (section tint blended with +// the slow hue cycle) — shared by drawWatermark AND +// drawPalsTitleChars so the climbing chars track the pals exactly. +function palsFrameColor(audioT) { + const u = audioT * FPS / FRAMES; + const hue = ((u * 360 * 4) % 360 + 360) % 360; + const hRgb = hslToRgb(hue, 0.9, 0.62); + const [sr, sg, sb] = sectionTcRgb(audioT); + return [ + Math.round(sr * 0.6 + hRgb[0] * 0.4), + Math.round(sg * 0.6 + hRgb[1] * 0.4), + Math.round(sb * 0.6 + hRgb[2] * 0.4), + ]; +} +// Re-tint a pre-rendered title-char glyph (whose pixels have a baked +// drop shadow) to a target rgb via source-in alpha mask. Uses its own +// canvas (separate from wmCanvas) so tinting + drawing don't trample. +const charTintCanvas = createCanvas(2, 2); +const charTintCtx = charTintCanvas.getContext("2d"); +function tintCharGlyph(img, rgb) { + const w = img.width, h = img.height; + charTintCanvas.width = w; charTintCanvas.height = h; + charTintCtx.globalCompositeOperation = "source-over"; + charTintCtx.clearRect(0, 0, w, h); + charTintCtx.drawImage(img, 0, 0); + charTintCtx.globalCompositeOperation = "source-in"; + charTintCtx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + charTintCtx.fillRect(0, 0, w, h); + charTintCtx.globalCompositeOperation = "source-over"; + return charTintCanvas; +} +function palsTinted(rgb, src) { + const ww = src.width, wh = src.height; + wmCanvas.width = ww; wmCanvas.height = wh; + wmCtx.clearRect(0, 0, ww, wh); + wmCtx.globalCompositeOperation = "source-over"; + wmCtx.drawImage(src, 0, 0); + wmCtx.globalCompositeOperation = "source-in"; + wmCtx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + wmCtx.fillRect(0, 0, ww, wh); + wmCtx.globalCompositeOperation = "source-over"; + return wmCanvas; +} +function drawWatermark(audioT) { + if (!palsImg) return; + const s = 145; + // PERFECT-LOOP GEOMETRY: position / wiggle / swivel / hue locked to + // the full video timeline (u: 0 → 1 across all frames), INTEGER + // harmonics, so the last frame's pals sit exactly where frame 0's do. + const u = audioT * FPS / FRAMES; + const TAU = Math.PI * 2; + const hue = ((u * 360 * 4) % 360 + 360) % 360; // 4 hue cycles + const hRgb = hslToRgb(hue, 0.9, 0.62); + const [sr, sg, sb] = sectionTcRgb(audioT); + const col = [ + Math.round(sr * 0.6 + hRgb[0] * 0.4), + Math.round(sg * 0.6 + hRgb[1] * 0.4), + Math.round(sb * 0.6 + hRgb[2] * 0.4), + ]; + // LED pulse: audio amplitude brightens + saturates the pals. + const env = Math.min(1, envAt(audioT)); + const glow = env * env; + const hotRgb = hslToRgb(hue, 1.0, Math.min(0.88, 0.60 + 0.34 * glow)); + const ledCol = [ + Math.round(col[0] + (hotRgb[0] - col[0]) * glow), + Math.round(col[1] + (hotRgb[1] - col[1]) * glow), + Math.round(col[2] + (hotRgb[2] - col[2]) * glow), + ]; + const wig = 13 * Math.sin(TAU * 30 * u) + 4 * Math.sin(TAU * 10 * u); + const swiv = 0.05 * Math.sin(TAU * 19 * u) + 0.025 * Math.sin(TAU * 38 * u); + const inset = s * 0.34; + // Diagonal pals AT the corner edges — the title text starts NEXT + // to each pals (the logo is to the LEFT of where the text starts). + const spots = [ + // PALS snug against the beginning of each text column: + // left column reads top-down → pals just ABOVE the top 'm' + // right column reads bottom-up → pals just BELOW the bottom 'm' + // Tight gap so the logo + first letter read as a single unit. + // pals cx + cy computed from the title geometry — SNUG against the + // first char of each column, with a small bounce-buffer so the + // per-char bounce never overlaps the stamp. + { cx: PALS_EDGE_X - wig, cy: LEFT_PALS_CY, rot: Math.PI / 2 + swiv }, + { cx: W - PALS_EDGE_X + wig, cy: RIGHT_PALS_CY, rot: -Math.PI / 2 - swiv }, + ]; + // pushed deep INTO the image — heavy multiply/burn/overlay, only a + // whisper of straight-source pass so it stains in. + const passes = [ + ["multiply", 0.78], ["color-burn", 0.42], + ["overlay", 0.58], ["source-over", 0.06], + ]; + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.cx, sp.cy); + ctx.rotate(sp.rot); + // tight dark drop shadow (real black tinted glyph, offset) + palsTinted([0, 0, 0], palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(wmCanvas, -s / 2 + 3, -s / 2 + 4, s, s); + // 4-pass seep + palsTinted(col, palsBlur); + for (const [op, a] of passes) { + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + // crisp top pass so the logo out-reads its own shadow + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.30; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + // LED pulse — additive bloom + crisp hot core on loud hits + if (glow > 0.001) { + palsTinted(ledCol, palsBlur); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.78 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + palsTinted(ledCol, palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.18 + 0.46 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + ctx.restore(); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + // After each pals stamp, write the small "marimbaba" chars OVER it, + // rotated −90° so they read UP the side of the frame (matches the + // trancepenta-yt landscape chrome). + drawPalsTitleChars(audioT); +} +// "marimbaba" chars climbing VERTICALLY (rotated −90°) BESIDE each +// pals stamp — left pals at bottom: text-column to its RIGHT, +// right pals at top: text-column to its LEFT. Each char re-tinted +// per frame to the pals's section+hue colour so type + logo read +// as one family. +function drawPalsTitleChars(audioT) { + if (!palsImg) return; + const s = 145; // matches drawWatermark + const u = audioT * FPS / FRAMES; + const TAU = Math.PI * 2; + const wig = 13 * Math.sin(TAU * 30 * u) + 4 * Math.sin(TAU * 10 * u); + const inset = s * 0.34; + const charScale = CHAR_SCALE; + const span = titleTotalW * charScale; + const palsRgb = palsFrameColor(audioT); + // Chars share the SAME x-axis as the pals stamp (centred on the + // same vertical line so logo + type line up). + const spots = [ + { charsCx: CHARS_EDGE_X - wig, cy: LEFT_CHARS_CY, rot: Math.PI / 2 }, + { charsCx: W - CHARS_EDGE_X + wig, cy: RIGHT_CHARS_CY, rot: -Math.PI / 2 }, + ]; + const startX = -span / 2; // centre the column on cy + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.charsCx, sp.cy); + ctx.rotate(sp.rot); + for (let i = 0; i < titleChars.length; i++) { + const ch = titleChars[i]; + if (!ch.img) continue; + const x = startX + ch.prefixWidth * charScale; + const dw = ch.img.width * charScale; + const dh = ch.img.height * charScale; + const charEnv = envAt(audioT - i * 0.03); + const lift = 4 * Math.sin(audioT * 4.0 + i * 0.8) * (0.3 + charEnv); + const y = -dh / 2 + lift; + // 1) shadow stamp — IDENTICAL to the pals shadow: solid BLACK, + // alpha 0.26, offset (3, 4). Matches the logo's look exactly. + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(tintCharGlyph(ch.img, [0, 0, 0]), x + 3, y + 4, dw, dh); + ctx.restore(); + // 2) 4-pass SEEP — same blend stack the pals uses, so the type + // soaks INTO the illustration the same way the logo does. + const charPasses = [ + ["multiply", 0.78], + ["color-burn", 0.42], + ["overlay", 0.58], + ["source-over", 0.06], + ]; + for (const [op, a] of charPasses) { + ctx.save(); + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + } + // 3) crisp top pass — denser than the pals's 0.30 so the smaller + // type still reads at the same on-screen density as the logo. + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.46; + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + // 4) LED screen pulse on loud chars (mirrors the pals LED bloom) + if (charEnv > 0.45) { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.46 * Math.min(1, (charEnv - 0.45) / 0.55); + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + } + } + ctx.restore(); + } +} +// Brighten the section tint toward white by how far through the +// section we are — so the watermark colour reads both WHICH section +// and HOW FAR (progress), exactly like the chillwave/trancenwaltz cut. +function sectionTcRgb(audioT) { + const i = sectionIndexAt(audioT); + const s = SECTIONS[i]; + let [r, g, b] = tintRgb(SECTION_TINTS[s.name] || "rgba(255,253,242,1)"); + const span = Math.max(0.001, s.endSec - s.startSec); + const lp = Math.max(0, Math.min(1, (audioT - s.startSec) / span)); + const k = 0.30 * lp; + r = Math.round(r + (255 - r) * k); + g = Math.round(g + (255 - g) * k); + b = Math.round(b + (255 - b) * k); + return [r, g, b]; +} + +// ── the verlet string ──────────────────────────────────────────────── +const PLAYHEAD_X = Math.round(W / 2); +const PX_PER_SEC = 150; // lullaby scroll speed +const _vs = makeVerletString(ctx, { W, H, playheadX: PLAYHEAD_X, duration: DURATION }); + +// 5 lanes spread EVENLY across the canvas height (along the string) +// so the tracks don't bunch toward one edge — they ride the string +// from top to bottom. +const LANE_TOP = 240, LANE_BOTTOM = H - 200; +const LANE_H = (LANE_BOTTOM - LANE_TOP) / LANES.length; +const laneCenterY = {}; +LANES.forEach((L, i) => { laneCenterY[L.key] = LANE_TOP + i * LANE_H + LANE_H / 2; }); + +// Groove radius — note blocks curve about the canvas centre. The +// chillwave/trancenwaltz ~85%·minDim gives the strong 3D-projection +// arc the user likes (clear record-groove feel). +const GROOVE_R = Math.round(Math.min(W, H) * 0.85); + +// ── illustration distortion UNDER the bent string ─────────────────── +// Ported faithfully from chillwave's warpUnderString: snapshot the +// upright frame, counter-rotate it by -θ into a big square buffer, +// then redraw a tall strip near the playhead under +θ with each row +// shifted by the local string deflection. Two rotations cancel for the +// CONTENT (illustration stays upright, never spins) while ONLY the +// distortion field under the string lives in the string's rotated +// frame — so the picture visibly bends where the string is bent. +// Narrower strip (120 instead of 200) + more bands (24 vs 7) = the +// warp reads as a LOCAL geometric bend at the string, not a global +// strip-shift across the picture. WU_STR much gentler than the +// trancenwaltz default (0.85) so the perspective shift stays subtle +// — the picture barely flexes with the string instead of dragging +// dramatically. +const WU_HALF = 120, WU_W = WU_HALF * 2, WU_STEP = 4, WU_BANDS = 24; +const WU_STR = 0.28, WU_BW = WU_W / WU_BANDS; +const wuWin = new Float64Array(WU_BANDS); +for (let b = 0; b < WU_BANDS; b++) { + wuWin[b] = Math.sin(Math.PI * ((b + 0.5) / WU_BANDS)) ** 2; +} +const WU_DSZ = Math.ceil(Math.hypot(W, H)) + 4; +const wuSnap = createCanvas(W, H), wuSnapC = wuSnap.getContext("2d"); +const wuCR = createCanvas(WU_DSZ, WU_DSZ), wuCRC = wuCR.getContext("2d"); +const ROT_CX = Math.round(W / 2), ROT_CY = Math.round(H / 2); +function warpUnderString(theta) { + const { devPeak } = _vs.deflection(); + if (Math.abs(devPeak) < 2) return; // string ~straight → skip + // 1) snapshot the upright frame (illustration + backlight) + wuSnapC.clearRect(0, 0, W, H); + wuSnapC.drawImage(canvas, 0, 0); + // 2) counter-rotate it by −θ, centred in the big square buffer + wuCRC.setTransform(1, 0, 0, 1, 0, 0); + wuCRC.clearRect(0, 0, WU_DSZ, WU_DSZ); + wuCRC.translate(WU_DSZ / 2, WU_DSZ / 2); + wuCRC.rotate(-theta); + wuCRC.translate(-W / 2, -H / 2); + wuCRC.drawImage(wuSnap, 0, 0); + wuCRC.setTransform(1, 0, 0, 1, 0, 0); + // 3) redraw the strip under +θ, each band shifted by the local + // string deflection (content upright, strip rotated with the string) + const sox = (WU_DSZ - W) / 2, soy = (WU_DSZ - H) / 2; + const x0 = Math.round(PLAYHEAD_X - WU_HALF); + ctx.save(); + ctx.translate(ROT_CX, ROT_CY); + ctx.rotate(theta); + ctx.translate(-ROT_CX, -ROT_CY); + for (let y = 0; y < H; y += WU_STEP) { + const dx = (needleXAt(y) - PLAYHEAD_X) * WU_STR; + for (let b = 0; b < WU_BANDS; b++) { + const sx = b * WU_BW; + const shift = dx * wuWin[b]; + ctx.drawImage( + wuCR, sox + x0 + sx, soy + y, WU_BW + 1, WU_STEP, + x0 + sx + shift, y, WU_BW + 1, WU_STEP, + ); + } + } + ctx.restore(); +} + +// ── 3-LAYER BACKLIGHT ──────────────────────────────────────────────── +// Drawn onto whatever ctx the panel was drawn to (main OR an offscreen +// transition layer) so the light travels with the panel through a +// transition. Layer 1: warm glow rising from BEHIND the figures +// [screen]. Layer 2: contrast vignette — darkens the periphery so the +// lit centre reads backlit, not glow-on-top [multiply]. Layer 3 (the +// per-figure halo) is drawn by the caller via drawFormBacklight. +// ── STAINED-GLASS TRANSMISSION BACKLIGHT (trancenwaltz mechanism) ─── +// The illustration is treated as a stained-glass panel: each pixel's +// LUMINANCE determines how much light passes through. Bright / light- +// pencilled regions transmit (glow with the backlight colour); dark +// linework + shadows act as leaded came — they block the light and +// stay punchy. This is why the light reads as actually coming from +// BEHIND the figures, not stamped on top. Computed once per panel +// image (cached on a WeakMap), then composited per frame. +const _transmissionMasks = new WeakMap(); +function getTransmissionMask(img) { + let m = _transmissionMasks.get(img); + if (m) return m; + const iw = img.width, ih = img.height; + const tmp = createCanvas(iw, ih); + const tctx = tmp.getContext("2d"); + tctx.drawImage(img, 0, 0, iw, ih); + const id = tctx.getImageData(0, 0, iw, ih); + const px = id.data; + // Transmission curve: smoothstep across [LO, HI] in luminance, then + // raised to GAMMA so the dark half is crushed toward zero (lead lines + // fully block light → high contrast on darks). A small FLOOR lets a + // whisper of colour bleed through near-blacks so the panel doesn't + // read as dead matte. + // Cap max transmission at 0.65 so the brightest pixels (white + // typewriter, white paper, lamp highlight) don't blow out to pure + // white when the warm glow is composited additively. + const LO = 0.30, HI = 0.92, GAMMA = 1.7, FLOOR = 0.04, CAP = 0.65; + for (let i = 0; i < px.length; i += 4) { + const L = (0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2]) / 255; + let s = (L - LO) / (HI - LO); + s = s < 0 ? 0 : s > 1 ? 1 : s; + s = s * s * (3 - 2 * s); + const tA = Math.min(CAP, FLOOR + (1 - FLOOR) * Math.pow(s, GAMMA)); + px[i] = 255; px[i + 1] = 255; px[i + 2] = 255; + px[i + 3] = Math.round(tA * 255); + } + tctx.putImageData(id, 0, 0); + _transmissionMasks.set(img, tmp); + return tmp; +} +// Shared offscreen for the gated glow. +const glowCanvas = createCanvas(W, H); +const glowCtx = glowCanvas.getContext("2d"); + +// LAYER 1 — strong contrast vignette: deeply darken everything except +// a tight pool around where the figures sit. Near-black corners. +function drawVignette(c, intensity) { + const gx = W / 2, gy = H * 0.55; + c.save(); + c.globalCompositeOperation = "multiply"; + const vg = c.createRadialGradient(gx, gy, H * 0.10, gx, gy, H * 0.72); + vg.addColorStop(0, "rgba(255,255,255,1)"); + // WARM-TINTED darkening — amber/brown mid + deep-amber edges (not + // neutral grey), so the dark surround reads as night-lamp shadow, + // not cold blue. Preserves the original colored-pencil warmth. + vg.addColorStop(0.45, `rgba(168,118,72,${(0.62 + 0.12 * intensity).toFixed(3)})`); + vg.addColorStop(1, `rgba(36,20,10,${(0.94).toFixed(3)})`); + c.fillStyle = vg; + c.fillRect(0, 0, W, H); + c.restore(); + c.globalCompositeOperation = "source-over"; +} +// LAYER 2 — TRANSMITTED light (stained-glass): stamp a hot radial glow +// at each face region, gate it by the panel's transmission mask, then +// composite additively. Light shines THROUGH the bright illustration +// pixels at the face regions; dark linework blocks it. Reads as light +// genuinely coming from behind the figures, never as an overlay. +function drawTransmittedBacklight(c, sectionImg, xform, faces, intensity, audioT = 0) { + if (!faces || !faces.length || !xform || intensity <= 0.01) return; + const k = Math.min(1, intensity); + // Candle dance — tiny per-frame XY jitter on the glow source so the + // transmitted light visibly flutters where it falls through the + // illustration's bright pixels. + const jx = 3.5 * Math.sin(audioT * 17.3) + 1.5 * Math.sin(audioT * 41.0); + const jy = 3.5 * Math.cos(audioT * 21.7) + 1.5 * Math.cos(audioT * 37.0); + // Stamp the glow into the offscreen buffer at face positions. + glowCtx.globalCompositeOperation = "source-over"; + glowCtx.clearRect(0, 0, W, H); + for (const f of faces) { + const cx = xform.x + (f.x + f.w / 2) * xform.scale + jx; + const cy = xform.y + (f.y + f.h / 2) * xform.scale + jy; + const radius = Math.max(f.w, f.h) * xform.scale * 1.4; + if (radius <= 1) continue; + const g = glowCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); + // All WARM (no white core) — so the glow tints the bright pixels + // with the lamp colour instead of blowing them out to pure white. + // The desk-lamp warmth matches the illustration's own diegetic light. + g.addColorStop(0.0, `rgba(${BACKLIGHT_RGB},${(0.78 * k).toFixed(3)})`); + g.addColorStop(0.32, `rgba(${BACKLIGHT_RGB},${(0.58 * k).toFixed(3)})`); + g.addColorStop(0.65, `rgba(${BACKLIGHT_RGB},${(0.30 * k).toFixed(3)})`); + g.addColorStop(1.0, `rgba(${BACKLIGHT_RGB},0)`); + glowCtx.globalCompositeOperation = "lighter"; + glowCtx.fillStyle = g; + glowCtx.fillRect( + Math.max(0, cx - radius), Math.max(0, cy - radius), + Math.min(W, radius * 2), Math.min(H, radius * 2), + ); + } + // GATE by the transmission mask — drawn at the same xform that drew + // the panel, so the mask aligns pixel-perfect with the illustration. + glowCtx.globalCompositeOperation = "destination-in"; + const mask = getTransmissionMask(sectionImg); + glowCtx.drawImage(mask, xform.x, xform.y, + sectionImg.width * xform.scale, sectionImg.height * xform.scale); + glowCtx.globalCompositeOperation = "source-over"; + // Composite onto the scene — softer additive wash so the illustration + // colours (cream paper, sage knit, blue shirt) still read. + c.save(); + c.globalCompositeOperation = "lighter"; + c.globalAlpha = 0.45; + c.drawImage(glowCanvas, 0, 0); + c.globalAlpha = 0.12; + c.drawImage(glowCanvas, -4, -4, W + 8, H + 8); + c.restore(); + c.globalCompositeOperation = "source-over"; +} +// LAYER 3 — LEADED contrast: the inverse-mask pass keeps the darks +// punchy (dark linework reads as the "leading" of the stained glass). +// Stamp the same regions in a darker tone, destination-out by the mask +// (so only the DARK parts of the panel get this treatment), multiply +// onto the scene. +function drawLeadedContrast(c, sectionImg, xform, faces, intensity, audioT = 0) { + if (!faces || !faces.length || !xform || intensity <= 0.01) return; + const k = Math.min(1, intensity); + // Out-of-phase candle jitter — the leaded layer flickers a touch + // differently from the transmitted layer so the two read as distinct. + const jx = 2.5 * Math.sin(audioT * 19.7 + 1.2) + 1.0 * Math.cos(audioT * 47.0); + const jy = 2.5 * Math.cos(audioT * 23.1 + 0.6) + 1.0 * Math.sin(audioT * 41.5); + glowCtx.globalCompositeOperation = "source-over"; + glowCtx.clearRect(0, 0, W, H); + for (const f of faces) { + const cx = xform.x + (f.x + f.w / 2) * xform.scale + jx; + const cy = xform.y + (f.y + f.h / 2) * xform.scale + jy; + const radius = Math.max(f.w, f.h) * xform.scale * 1.4; + if (radius <= 1) continue; + const g = glowCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); + g.addColorStop(0.0, `rgba(70,52,38,${(0.55 * k).toFixed(3)})`); + g.addColorStop(0.50, `rgba(70,52,38,${(0.30 * k).toFixed(3)})`); + g.addColorStop(1.0, "rgba(70,52,38,0)"); + glowCtx.globalCompositeOperation = "lighter"; + glowCtx.fillStyle = g; + glowCtx.fillRect( + Math.max(0, cx - radius), Math.max(0, cy - radius), + Math.min(W, radius * 2), Math.min(H, radius * 2), + ); + } + // Keep ONLY where the panel is DARK (inverse of transmission). + glowCtx.globalCompositeOperation = "destination-out"; + const mask = getTransmissionMask(sectionImg); + glowCtx.drawImage(mask, xform.x, xform.y, + sectionImg.width * xform.scale, sectionImg.height * xform.scale); + glowCtx.globalCompositeOperation = "source-over"; + c.save(); + c.globalCompositeOperation = "multiply"; + c.globalAlpha = 0.28; + c.drawImage(glowCanvas, 0, 0); + c.restore(); + c.globalCompositeOperation = "source-over"; +} + +// ── panel render (one section, with face-zoom + 3-layer light) ─────── +// zoomPad MUST be 1.0 — drawCoverKenBurns's zoom path only guarantees +// the frame is covered when pad ≤ 1 (pad > 1 shrinks sc → black gaps). +// envWobbleAmp = 0 + small wobbleAmp = SMOOTH camera, no amplitude +// reactivity. Only the backlight beats with the audio — the camera +// glides on its own slow pacing. +const KB = { breathAmp: 0.05, breathPeriodSec: 18, wobbleAmp: 4, envWobbleAmp: 0, zoomPad: 1.0 }; +// Smooth global zoom LFO — continuous across section boundaries (no +// per-section reset), no envelope coupling. ~3 gentle in/out pushes +// over the lullaby. Lower amplitude than v8 so the zoom never crops a +// figure off-frame; combined with bounding-box zoom (both heads), it +// just BREATHES between the pair instead of punching tight. +const ZOOM_CYCLES = 3; +function zoomAt(audioT) { + const swing = 0.5 - 0.5 * Math.cos(2 * Math.PI * ZOOM_CYCLES * audioT / DURATION); + return 0.04 + 0.30 * swing; +} +// Zoom target = bounding box of ALL faces in the section (so BOTH +// heads stay in frame at peak zoom), padded a touch and fit to the +// frame's aspect. The camera focuses BETWEEN the heads by default, +// never on a single face that crops the other one off. +function zoomTargetBox(faces, figs) { + const src = faces.length ? faces : figs; + if (!src.length) return WHOLE_BOX; + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const b of src) { + x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y); + x1 = Math.max(x1, b.x + b.w); y1 = Math.max(y1, b.y + b.h); + } + const padX = (x1 - x0) * 0.12, padY = (y1 - y0) * 0.12; + return fitFrameAspect({ + x: x0 - padX, y: y0 - padY, + w: (x1 - x0) + 2 * padX, h: (y1 - y0) + 2 * padY, + }); +} +// Render section `idx` (zoomed + 3-layer backlit) onto target ctx `c`. +// Returns the panel→frame xform. +function renderPanel(c, idx, audioT, env, punch) { + const name = SECTIONS[idx].name; + const figs = figureBoxes(name); + const faces = faceBoxes(name); + // Always focus on the CENTRE-POINT BETWEEN both heads (bounding box + // of all available faces) — both stay in frame at peak zoom, the + // camera just breathes in and out between them. + const z = zoomAt(audioT); + const zoomBox = lerpBox(WHOLE_BOX, zoomTargetBox(faces, figs), z); + const xform = drawCoverKenBurns(c, sectionImgs[idx], audioT, { + ...KB, env, punch, zoomBox, fillBackground: true, + }); + // ── 3-LAYER STAINED-GLASS BACKLIGHT — candle-flickered ──────────── + // Multi-freq noise gives the warm light a believable CANDLE + // FLUTTER; each layer has its own intensity envelope so the three + // layers visibly separate on hits + transitions. + const flicker = 1 + 0.20 * ( + 0.6 * Math.sin(audioT * 14.3 + idx * 0.7) + + 0.3 * Math.sin(audioT * 31.7 + idx * 1.3) + + 0.2 * Math.sin(audioT * 53.1 + idx * 0.4) + ); + const v_i = (0.80 + 0.45 * env) * flicker; // vignette deepens with audio + const t_i = (0.18 + 0.95 * env + 0.65 * punch) * flicker; // transmitted glow — strong env + const l_i = (0.10 + 0.55 * env + 0.45 * punch) * flicker; // leaded contrast — punchy darks + // 1: contrast vignette — darken the OUTER frame so the figures pop. + drawVignette(c, v_i); + // 2: TRANSMITTED light — gated by the panel's own luminance mask + // AT a tiny per-frame XY jitter (candle dance) — only shines + // through the bright illustration parts at each face region. + drawTransmittedBacklight(c, sectionImgs[idx], xform, faces, t_i, audioT); + // 3: LEADED contrast — the inverse-mask multiply pass that keeps the + // dark linework PUNCHY, like the lead between glass. + drawLeadedContrast(c, sectionImgs[idx], xform, faces, l_i, audioT); + return xform; +} + +// ── TRANSITIONS — 6 fresh types, cycled across the 9 boundaries ────── +const TRANS_S = 0.85; // fast changes +const TRANSITIONS = ["iris", "blinds", "push", "zoomPunch", "pixel", "diagonal"]; +function transitionForBoundary(idx) { return TRANSITIONS[(idx - 1) % TRANSITIONS.length]; } +const _pixCanvas = createCanvas(W, H), _pixCtx = _pixCanvas.getContext("2d"); +function applyTransition(kind, prevC, curC, p) { + // p 0→1. prevC/curC are the finished panel layers. Composites to main ctx. + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + if (kind === "iris") { + ctx.drawImage(prevC, 0, 0); + ctx.save(); + const r = p * Math.hypot(W, H) * 0.62; + ctx.beginPath(); ctx.arc(W / 2, H * 0.52, r, 0, Math.PI * 2); ctx.clip(); + ctx.drawImage(curC, 0, 0); + ctx.restore(); + } else if (kind === "blinds") { + ctx.drawImage(prevC, 0, 0); + const N = 9, bh = H / N; + for (let i = 0; i < N; i++) { + const local = Math.max(0, Math.min(1, p * 1.6 - i * (0.6 / N))); + if (local <= 0) continue; + const h = bh * local; + ctx.drawImage(curC, 0, i * bh, W, h, 0, i * bh, W, h); + } + } else if (kind === "push") { + const dy = Math.round(p * H); + ctx.drawImage(prevC, 0, -dy); + ctx.drawImage(curC, 0, H - dy); + } else if (kind === "zoomPunch") { + // prev zooms out + fades; cur punches in from oversized. + const ps = 1 + 0.5 * p, cs = 1.6 - 0.6 * p; + ctx.globalAlpha = 1 - p; + ctx.drawImage(prevC, (W - W * ps) / 2, (H - H * ps) / 2, W * ps, H * ps); + ctx.globalAlpha = Math.min(1, p * 1.4); + ctx.drawImage(curC, (W - W * cs) / 2, (H - H * cs) / 2, W * cs, H * cs); + ctx.globalAlpha = 1; + } else if (kind === "pixel") { + ctx.drawImage(prevC, 0, 0); + const cell = 84, cols = Math.ceil(W / cell), rows = Math.ceil(H / cell); + _pixCtx.clearRect(0, 0, W, H); + _pixCtx.drawImage(curC, 0, 0); + for (let r = 0; r < rows; r++) for (let cx = 0; cx < cols; cx++) { + let h = ((r * 73856093) ^ (cx * 19349663)) >>> 0; + const thr = ((h % 1000) / 1000) * 0.85; + if (p <= thr) continue; + ctx.drawImage(_pixCanvas, cx * cell, r * cell, cell, cell, cx * cell, r * cell, cell, cell); + } + } else { // diagonal wipe + ctx.drawImage(prevC, 0, 0); + ctx.save(); + const e = p * (W + H); + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(e, 0); ctx.lineTo(0, e); ctx.closePath(); + ctx.clip(); + ctx.drawImage(curC, 0, 0); + ctx.restore(); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; +} + +function sectionIndexAt(t) { + let idx = 0; + for (let i = 0; i < SECTIONS.length; i++) if (t >= SECTIONS[i].startSec) idx = i; + return idx; +} + +// ── lanes + string draw (the sound elements riding the string) ─────── +// GEOMETRY + HIGHLIGHTING faithfully ported from pop/dance/bin/ +// cover-video.mjs (trancenwaltz): +// • Per-block aGroove rotation — each column rotated about the disc +// centre by (bx − playhead) / GROOVE_R, so the lanes are STRAIGHT +// at the needle and arc more the further out — a real record groove. +// • Flash highlighting — the whole event pumps to FULL opacity for +// a 0.18 s flashWindow after trigger (the "now firing" pulse). +// • Per-column press/held/played alpha is the WAVEFORM-pixel layer +// riding under the flash bump. +// • Audio-peak waveform: each column's amplitude comes from the +// actual mix peak in its tiny time window (mini-DAW per event). +function needleXAt(y) { return _vs.needleXAt(y); } +function drawLanes(audioT) { + const halfSpan = (W * 0.62) / PX_PER_SEC; + const FLASH_WIN = 0.18; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + for (const L of LANES) { + const yCBase = laneCenterY[L.key]; + const laneRgb = hexToRgb(L.color); + // SUB-ROW layout — overlapping events spread vertically into + // stackRow slots so concurrent notes don't draw on top of each + // other. Cap visible rows at 3 to avoid crushing tiny bars. + const visibleRows = Math.min(L.maxStackRows ?? 1, 3); + const subH = (LANE_BOTTOM - LANE_TOP) / LANES.length / visibleRows; + for (const ev of laneEvents[L.key]) { + if (ev.t > audioT + halfSpan) break; + const dur = ev.dur || 0.25; + if (ev.t + dur < audioT - halfSpan) continue; + // Cap visible width so a long-sustain marimba note doesn't render + // as a 600-pixel slab (same trick trancenwaltz uses for pitched). + const visDur = Math.min(dur, 0.30); + const ex = PLAYHEAD_X + (ev.t - audioT) * PX_PER_SEC; + const ew = Math.max(4, visDur * PX_PER_SEC); + // place this event in its assigned sub-row (centred within the + // lane), so overlapping events don't stack on top of each other. + const rowIdx = Math.min(ev.stackRow ?? 0, visibleRows - 1); + const yC = yCBase - (LANE_BOTTOM - LANE_TOP) / LANES.length / 2 + + subH / 2 + rowIdx * subH; + // Per-event FLASH highlighting — whole event pumps brighter on + // trigger, decays over FLASH_WIN. + const sinceTrigger = audioT - ev.t; + let flash = 0; + if (sinceTrigger >= 0 && sinceTrigger < FLASH_WIN) flash = 1 - sinceTrigger / FLASH_WIN; + const isFuture = ev.t > audioT + 0.02; + const isPlayed = sinceTrigger >= FLASH_WIN; + let baseAlpha = 1.0; + if (isFuture) baseAlpha = 0.55; + else if (isPlayed) baseAlpha = 0.82; + if (flash > 0) baseAlpha = Math.min(1.0, baseAlpha + flash * 0.40); + // pitch colour, mostly the notepat note-colour, touch of voice colour + const nrgb = midiToNotepatRgb(ev.midi); + const cr = Math.round(nrgb[0] * 0.78 + laneRgb[0] * 0.22); + const cg = Math.round(nrgb[1] * 0.78 + laneRgb[1] * 0.22); + const cb = Math.round(nrgb[2] * 0.78 + laneRgb[2] * 0.22); + const rgb = `${cr},${cg},${cb}`; + // Cap fullH at the sub-row height so stacked bars stay in lane. + const fullH = Math.min(Math.max(20, subH - 6), 30 + 86 * Math.min(1, ev.gain ?? 0.5)); + const cols = Math.max(2, Math.floor(ew / 9)); + const blockW = Math.max(3, ew / cols - 2); + const bend = (needleXAt(yC) - PLAYHEAD_X) * 0.5; + // Audio waveform sampling — per column, pull peak from the mix. + const startSamp = Math.max(0, Math.floor(ev.t * audioSr)); + const endSamp = Math.min(audio.length - 1, Math.floor((ev.t + visDur) * audioSr)); + const spc = (endSamp - startSamp) / cols; + for (let c = 0; c < cols; c++) { + const s0 = startSamp + Math.floor(c * spc); + const s1 = Math.min(endSamp, startSamp + Math.floor((c + 1) * spc)); + let pk = 0; + for (let s = s0; s < s1; s++) { const a = Math.abs(audio[s]); if (a > pk) pk = a; } + pk = Math.min(1, (pk / audioPeak) * 1.6); + const tCol = ev.t + (c / cols) * visDur; + const dt = audioT - tCol; + let alpha; + if (dt < 0) alpha = 0.16; // future — dim + else if (dt < 0.05) alpha = 1.0; // PRESSED — at needle + else if (dt < 0.45) alpha = 1.0 - (dt - 0.05) / 0.40 * 0.55; // held — fade + else alpha = 0.42; // played — settled + // BAKE the event-level baseAlpha bump into the per-column alpha + // so the flash highlight pumps the whole bar in unison. + alpha *= baseAlpha; + const half = Math.max(2, (pk * fullH) / 2); + const bx = ex + (c / cols) * ew + bend; + // GROOVE CURVE — rotate this block about the disc centre by + // (bx − playhead) / GROOVE_R so the track arcs along the record. + // GROOVE_R bumped LARGE for a GENTLE arc (no polar-projection + // feel) — the bars curve subtly along the disc instead of + // bending sharply at the edges. + const aGroove = (bx - PLAYHEAD_X) / GROOVE_R; + ctx.save(); + ctx.translate(ROT_CX, ROT_CY); + ctx.rotate(aGroove); + ctx.translate(-ROT_CX, -ROT_CY); + ctx.fillStyle = `rgba(${rgb},${alpha.toFixed(3)})`; + ctx.fillRect(bx, yC - half, blockW, half * 2); + ctx.restore(); + } + } + } + ctx.restore(); + ctx.globalCompositeOperation = "source-over"; +} + +// A bright additive overdraw of the string so the playhead spine reads +// boldly over the illustration (the engine's own draw() is subtle). +// Paint the string body in the pals base colour on TOP of the engine's +// own draw, so the visible string colour MATCHES the logo family. The +// engine's underlying draw still provides the dark shadow + per-segment +// pluck highlights — this overlay just dominates the body colour. +function drawStringGlow(env, audioT) { + const [r, g, b] = palsFrameColor(audioT); + ctx.save(); + ctx.lineCap = "round"; + // 1) Coloured BODY pass — thin, translucent, source-over. + ctx.globalCompositeOperation = "source-over"; + ctx.strokeStyle = `rgba(${r},${g},${b},0.42)`; + ctx.lineWidth = 1.6; + ctx.beginPath(); + for (let y = -20; y <= H + 20; y += 10) { + const x = needleXAt(y); + if (y <= -20) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + // 2) Screen GLOW on top, env-driven so the string pulses with the + // audio while keeping its pals colour identity. Also thin. + ctx.globalCompositeOperation = "screen"; + ctx.strokeStyle = `rgba(${r},${g},${b},${(0.34 + 0.40 * env).toFixed(3)})`; + ctx.lineWidth = 3.4; + ctx.stroke(); + ctx.restore(); + ctx.globalCompositeOperation = "source-over"; +} + +// ── title ──────────────────────────────────────────────────────────── +// Title now flanks the SIDES like the pals watermarks: the letters of +// "marimbaba" stack vertically — first half down the LEFT edge, second +// half down the RIGHT edge — each bouncing and glowing with the audio +// envelope. No more horizontal banner across the top. +function drawTitle(audioT, env) { + const total = titleChars.length; + const half = Math.ceil(total / 2); + const charH = Math.ceil(titleFontSize * 1.05); + const leftH = half * charH; + const rightH = (total - half) * charH; + const leftY0 = Math.round((H - leftH) / 2); + const rightY0 = Math.round((H - rightH) / 2); + const insetLeft = 28; + const insetRight = 28; + for (let i = 0; i < total; i++) { + const ch = titleChars[i]; + if (!ch.img) continue; + const onLeft = i < half; + const slot = onLeft ? i : i - half; + const xBase = onLeft ? insetLeft : W - ch.img.width - insetRight; + const yBase = (onLeft ? leftY0 : rightY0) + slot * charH; + // per-char envelope sample — staggered like a spectrum bar, so + // letters react in sequence as the audio passes. + const charEnv = envAt(audioT - i * 0.04); + // small horizontal wiggle (toward / away from the edge) + vertical + // bounce, both audio-reactive but gentle. + const wig = (onLeft ? 1 : -1) * (3 + 8 * charEnv) * Math.sin(audioT * 2.6 + i * 0.9); + const lift = (8 + 26 * charEnv) * Math.sin(audioT * 3.2 + i * 0.7); + const x = xBase + wig; + const y = yBase + lift; + // glow shadow only on loud chars (cheap when sparse) + if (charEnv > 0.5) { + ctx.save(); + ctx.shadowColor = "rgba(255,236,170,0.92)"; + ctx.shadowBlur = 12 * Math.min(1, (charEnv - 0.5) / 0.5); + ctx.drawImage(ch.img, x, y); + ctx.restore(); + } else { + ctx.drawImage(ch.img, x, y); + } + } +} + +// ── progress bar + timecode ────────────────────────────────────────── +const PROGRESS_BAR_H = 22, PROGRESS_BAR_Y = H - PROGRESS_BAR_H; +function tintRgb(s) { + const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + return m ? [+m[1], +m[2], +m[3]] : [200, 200, 200]; +} +function drawProgressBar(audioT) { + ctx.save(); + const playedX = Math.max(0, audioT) / DURATION * W; + const lastI = SECTIONS.length - 1; + for (let si = 0; si < SECTIONS.length; si++) { + const sec = SECTIONS[si]; + const x0 = si === 0 ? 0 : (sec.startSec / DURATION) * W; + const x1 = si === lastI ? W : (sec.endSec / DURATION) * W; + const [r, g, b] = tintRgb(SECTION_TINTS[sec.name] || "rgba(200,200,200,1)"); + ctx.fillStyle = `rgba(${Math.round(r * 0.16)},${Math.round(g * 0.16)},${Math.round(b * 0.16)},0.85)`; + ctx.fillRect(x0, PROGRESS_BAR_Y, x1 - x0, PROGRESS_BAR_H); + const fx1 = Math.min(x1, playedX); + if (fx1 > x0) { + ctx.fillStyle = `rgba(${r},${g},${b},0.96)`; + ctx.fillRect(x0, PROGRESS_BAR_Y, fx1 - x0, PROGRESS_BAR_H); + } + ctx.fillStyle = "rgba(255,253,242,0.35)"; + ctx.fillRect(x1 - 1, PROGRESS_BAR_Y, 1, PROGRESS_BAR_H); + } + ctx.restore(); +} +const tcTint = createCanvas(8, 8), tcTintCtx = tcTint.getContext("2d"); +function drawTimecode(audioT) { + const sec = Math.min(Math.max(0, Math.floor(audioT)), Math.ceil(DURATION)); + const mm = Math.floor(sec / 60), ss = (sec - mm * 60).toString().padStart(2, "0"); + const entry = tcCache.get(`${mm}:${ss} / ${totMm}:${totSs}`); + if (!entry) return; + const { img, shadow } = entry; + const env = envAt(audioT); + const x = W - img.width - 32; + const y = PROGRESS_BAR_Y - img.height - 14 - 14 * env; + ctx.save(); + ctx.globalAlpha = 0.95; + ctx.drawImage(shadow, x + 3, y + 4); + ctx.drawImage(shadow, x + 2, y + 3); + ctx.globalAlpha = 1; + const [tr, tg, tb] = tintRgb(SECTION_TINTS[SECTIONS[sectionIndexAt(audioT)].name] || "rgba(255,253,242,1)"); + tcTint.width = img.width; tcTint.height = img.height; + tcTintCtx.clearRect(0, 0, img.width, img.height); + tcTintCtx.globalCompositeOperation = "source-over"; + tcTintCtx.drawImage(img, 0, 0); + tcTintCtx.globalCompositeOperation = "source-in"; + tcTintCtx.fillStyle = `rgb(${tr},${tg},${tb})`; + tcTintCtx.fillRect(0, 0, img.width, img.height); + ctx.drawImage(tcTint, x, y); + ctx.restore(); +} + +// ── render loop → ffmpeg ───────────────────────────────────────────── +mkdirSync(dirname(OUT), { recursive: true }); +// --frames N → TEST MODE: skip ffmpeg, write each rendered frame as a +// PNG to /tmp/marimba-test-NNNN.png. Pair with --start T to seek to a +// specific time. Lets layout tweaks be verified in seconds. +const TEST_MODE = FRAMES_OVERRIDE !== null; +const startFrame = Math.max(0, Math.floor(START_T * FPS)); +const endFrame = Math.min(FRAMES, startFrame + (FRAMES_OVERRIDE ?? FRAMES)); + +let ff = null; +if (!TEST_MODE) { + ff = spawnFFmpegEncode({ audioPath: AUDIO, w: W, h: H, fps: FPS, outPath: OUT }); + ff.on("error", (e) => { console.error(`✗ ffmpeg spawn failed: ${e.message}`); process.exit(1); }); +} + +progress.begin({ type: "video", label: `${SLUG} ${TEST_MODE ? "test" : "insta-story"} · ${endFrame - startFrame} frames` }); +console.log(` rendering frames ${startFrame}..${endFrame - 1} (${endFrame - startFrame} frames)${TEST_MODE ? " → /tmp/marimba-test-*.png" : ""} …`); +const t0 = Date.now(); +let prevNoteT = startFrame > 0 ? (startFrame / FPS) - 0.001 : -1; + +for (let f = startFrame; f < endFrame; f++) { + const audioT = f / FPS; + const env = Math.min(1, envAt(audioT)); + const punch = punchAt(audioT); + + // pluck the string for every note onset since the last frame + for (const L of LANES) { + const yC = laneCenterY[L.key]; + const lrgb = hexToRgb(L.color); + let amp = L.key === "bass" ? 19 : L.key === "rosewood" ? 13 : 9; + for (const ev of laneEvents[L.key]) { + if (ev.t <= prevNoteT) continue; + if (ev.t > audioT) break; + const sign = (Math.floor(ev.t * 7) % 2) ? 1 : -1; + _vs.pluck(yC, amp, sign, lrgb); + } + } + prevNoteT = audioT; + _vs.step(); + + // ── scene: panel + face-zoom + 3-layer backlight, with transitions ─ + const idx = sectionIndexAt(audioT); + const sec = SECTIONS[idx]; + const since = audioT - sec.startSec; + if (idx > 0 && since >= 0 && since < TRANS_S) { + let p = since / TRANS_S; + p = p * p * (3 - 2 * p); // smoothstep + renderPanel(offACtx, idx - 1, audioT, env, punch); + renderPanel(offBCtx, idx, audioT, env, punch); + applyTransition(transitionForBoundary(idx), offA, offB, p); + } else { + renderPanel(ctx, idx, audioT, env, punch); + } + + // ── the sound elements + the string, over the scene ─────────────── + // ── disc rotation — the string + lane note-blocks rotate as one + // record around the canvas centre. Slower than the chillwave / + // trancenwaltz 30 s period — about 1 full turn across the whole + // lullaby — so the lane curve reads as a calm, slow disc rather + // than a fast spin. + const theta = (audioT / DURATION) * Math.PI * 2; + // warpUnderString bends the storyline illustration where the string + // is bent — the picture stays UPRIGHT but the strip under the string + // slumps with the bend (chillwave's "string-vibe" effect). + warpUnderString(theta); + // Lanes + string rotate together as one disc. The illustration + // underneath stays upright (we don't wrap it). + _vs.withRotation(theta, () => { + drawLanes(audioT); + _vs.draw(); + drawStringGlow(env, audioT); + }); + + // ── HUD ─────────────────────────────────────────────────────────── + // drawWatermark draws BOTH the pals stamps and the small rotated + // "marimbaba" chars over each pals — the title now LIVES on the + // pals as side watermarks. Only the progress bar + timecode below + // are the "chrome" the reel strips. + // drawWatermark draws the pals stamps AND the rotated-90° "marimbaba" + // chars climbing up beside them (same side-stamp chrome as trancepenta-yt). + drawWatermark(audioT); + drawProgressBar(audioT); + drawTimecode(audioT); + + if (TEST_MODE) { + const png = canvas.toBuffer("image/png"); + const fname = `/tmp/marimba-test-${f.toString().padStart(4, "0")}.png`; + (await import("node:fs")).writeFileSync(fname, png); + } else { + const buf = canvas.toBuffer("raw"); + if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once("drain", r)); + } + + if (f % 30 === 0 || f === endFrame - 1) { + const done = f - startFrame + 1; + const total = endFrame - startFrame; + progress.update((done / Math.max(1, total)) * 100, { done, total }); + process.stdout.write(`\r frame ${done}/${total} `); + } +} +if (!TEST_MODE) ff.stdin.end(); +if (!TEST_MODE) await new Promise((res, rej) => { + ff.on("close", (code) => code === 0 ? res() : rej(new Error(`ffmpeg exited ${code}`))); +}); +progress.end(); +console.log(`\n✓ ${((Date.now() - t0) / 1000).toFixed(1)}s → ${OUT.replace(REPO + "/", "")}`); diff --git a/pop/marimba/marimbaba.youtube.txt b/pop/marimba/marimbaba.youtube.txt index 215c521877..a21b5e04ec 100644 --- a/pop/marimba/marimbaba.youtube.txt +++ b/pop/marimba/marimbaba.youtube.txt @@ -1,11 +1 @@ -marimbaba — a 1:24 lullaby for synthesized marimba, 56 bpm, f major, slow 3/4. rosewood marimba lead over a half-bar bass rocking-chair pulse, off-beat kalimba twinkles, motorless vibraphone dream-haze. instrumental — a syllabic hush-hush / twin-kle / wow-wow / ba-ba-bap / sleep-now is written into the score but stays unspoken; the marimba hums it. from the pixsies body of work. - -what you're watching is the song's storyline as ten colored-pencil + gouache panels: jeffrey's late-night computer help call with bill gates. approach the house → doorway hello → it clicks → hands-on → drowsy close. the audio, the visuals, the whole machine were built inside aesthetic.computer. - -artist · aesthetic dot computer -made with · aesthetic.computer - -▸ listen · https://open.spotify.com/track/1gopbVPw6LoinIpOANOnEG -▸ visit · https://aesthetic.computer - #lullaby #marimba #visualizer #aestheticcomputer #pixsies #instrumental -- 2.51.2