From 251e893ae4f1c11819a6004b8af1d8460485738e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 24 May 2026 16:25:01 -0400 Subject: [PATCH] pop: ship Spotify Canvases + YouTube uploads for the released pixsies singles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spotify Canvases (1080×1920, 8 s, silent, seamless loops, chrome-free via pop/dance/bin/canvas-loop.mjs): · marimbaba — 4-panel lullaby arc, slow cadence · helpabeach — 5-panel clinic-visit arc, --breath 0.07 so each held panel visibly pulses (was reading static at default ±2.5% zoom) · trancenwaltz — fresh 8-second cut from the 8 published sec-*.jpg · trancepenta — 8 felt-tattered portrait panels (regenerated to match the album cover's felt-puppet aesthetic via the new cover-prompt.felt-portrait.template.txt + gen-illys-penta-felt.sh) canvas-loop.mjs gains a --breath flag so the breathing-zoom amplitude is tunable per track. YouTube uploads (all public on the AC channel, descriptions in each lane, thumbnails pre-compressed to fit YouTube's 2 MB cap): · marimbaba → https://youtu.be/byt_NnRhbs4 · helpabeach → https://youtu.be/maQ8GRZcS90 · trancepenta → https://youtu.be/Em_lvTYET7M marimbaba + helpabeach upload as 1080×1920 vertical (YouTube handles 9:16 natively; per-lane landscape renderers are still TBD). trancepenta YT visualizer rebuilt felt-to-felt: · gen-trancepenta-sections.mjs MEDIUM/JEFFREY/ZUCK/PIXSIES/AVOID rewritten from "candid photograph with felt-cozy texture" (which gpt-image-2 read as photoreal) to explicit felt-puppet language matching pop/dance/cover-prompt.txt — Aardman / Isle-of-Dogs craft, sharp focus only (per new no-blur memory), bearded felt jeffrey, felt mark with Harvard hoodie, LED-bead pixsies, TATTERED constant with frayed cuffs / cut-out holes / faded hoodie print · sequential job loop replaced with a concurrency-3 worker pool (17 panels: ~50 min → ~17 min) · preview-score-trancepenta-yt.mjs vignette layer removed — the "deeply darken everything except a tight pool" overlay was crushing the edges of the felt panels so the painted maritime mural + side shelves + edge pixsies were unreadable; the transmitted-backlight + leaded-contrast layers already shape the figures, so the vignette wasn't pulling weight · old photoreal panels + the photoreal mp4 are backed up under pop/dance/out/_yt-photoreal-backup/ in case of revert toolchain/youtube/yt.mjs gains a \`thumbnail \` subcommand for retro-fixing a thumbnail when the upload-time attempt fails (e.g. the source jpg was over the 2 MB cap, which broke marimbaba on the first upload). Co-Authored-By: Claude Opus 4.7 (1M context) --- pop/RELEASES.md | 57 + pop/chillwave/helpabeach.youtube.txt | 11 + pop/dance/bin/canvas-loop.mjs | 7 +- .../cover-prompt.felt-portrait.template.txt | 24 + pop/dance/bin/gen-illys-penta-felt.sh | 64 + pop/dance/bin/gen-trancepenta-sections.mjs | 306 ++++ .../bin/preview-score-trancepenta-yt.mjs | 1333 +++++++++++++++++ pop/marimba/marimbaba.youtube.txt | 11 + toolchain/youtube/yt.mjs | 27 + 9 files changed, 1839 insertions(+), 1 deletion(-) create mode 100644 pop/chillwave/helpabeach.youtube.txt create mode 100644 pop/dance/bin/cover-prompt.felt-portrait.template.txt create mode 100755 pop/dance/bin/gen-illys-penta-felt.sh create mode 100644 pop/dance/bin/gen-trancepenta-sections.mjs create mode 100644 pop/dance/bin/preview-score-trancepenta-yt.mjs create mode 100644 pop/marimba/marimbaba.youtube.txt diff --git a/pop/RELEASES.md b/pop/RELEASES.md index 46a1f6664a..3995f27a00 100644 --- a/pop/RELEASES.md +++ b/pop/RELEASES.md @@ -24,6 +24,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.) - **DistroKid dashboard:** https://distrokid.com/dashboard/album/?albumuuid=772E43F5-D367-44A9-A7B4A1FA4E57FBD9 (admin-only) - **Master:** `~/Documents/Working Desktop/marimbaba-DISTROKID/marimbaba-MASTER.wav` @@ -85,6 +86,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.) - **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 @@ -301,6 +303,11 @@ DistroKid has a "request Spotify for Artists" shortcut for new artists. - audio · https://assets.aesthetic.computer/pop/trancepenta.mp3 (320 k mp3 of the radio-balanced master, 190.69 s) - cover · https://assets.aesthetic.computer/pop/trancepenta.jpg (3000², felt-character hero crop with multi-section lighting) - canvas/IG · https://assets.aesthetic.computer/pop/trancepenta-canvas.mp4 *(pending the other agent's render — see vertical-video session)* + - youtube · https://youtu.be/Em_lvTYET7M (1920×1080 full-chrome visualizer — uploaded 2026-05-24. The native-landscape 16-panel YT illy set + cover at `pop/dance/out/trancepenta-yt-{sec-*,cover}.png` were regenerated 2026-05-24 from felt-puppet prompts (Aardman / Isle-of-Dogs craft language, tattered felt clothes, bearded jeffrey, felt mark + felt pixsies with prominent LED-bead tells) so the YT visualizer matches the album cover's felt aesthetic. The first re-render had a heavy contrast vignette crushing the panel edges; that layer was removed from `preview-score-trancepenta-yt.mjs` (function kept as dead code) and the file re-rendered with bright edges. Photoreal originals stashed under `pop/dance/out/_yt-photoreal-backup/`.) +- **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). - 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/`) @@ -337,6 +344,56 @@ DistroKid has a "request Spotify for Artists" shortcut for new artists. per-slice once compiled into ffmpeg, or a new `place-penta-autotune.mjs`) +## solafiya — MASTERING + +- **Lane:** `pop/jungle/` · spicy latina jungle (ragga sub-format), 1:40 + · 176 BPM felt half-time (~88) · key A · sunlit minor-pentatonic-with- + major-third warmth. Hybrid AC-kit break (chopped/shuffled + `percussion.mjs` web + synthesized break-stab burst at phrase ends — + no Winstons, no Amen sample), deep dub sub on offbeat push, warm + golden sinepower pad, reggae skank stab, sparse dub sirens / airhorns, + AC bell + ding + meow + marimba + sineloop + shrill + throat + ornamentation. Three sequential verses, no overlap. Part of the + *pixsies* body. First **fía** solo lead — her live ragga toast forced- + aligned through the WORLD pipeline into vocal / vocalAd / vocalDuet / + vocalH / throat lanes, baked into the mix. +- **Artist:** fía (TBC — could be `fía & Aesthetic Dot Computer` for + store-page coherence with the other pixsies singles; ask @jeffrey) +- **Status:** MASTERING — final master baked 2026-05-23; awaiting + @jeffrey's ears + creative direction before DistroKid upload. Cover + + README in place. +- **Master:** `~/Documents/Working Desktop/solafiya-DISTROKID/solafiya-MASTER.wav` + — 16-bit/44.1 kHz stereo WAV, 100.18 s. **−13.5 LUFS · −1.0 dBTP · + LRA 2.3** (jungle wants tight; source mix is already heavily + compressed at −19.4 LUFS / LRA 2.2 — no extra dynamic range to + reclaim). Master chain: highpass 28 → 250 Hz mud trim → 4.2 k + presence +2 → high-shelf 8.5 k +5 (air restore — render high-cuts at + ~8.6 k for HC vibe) → high-shelf 12.5 k +2.5 → loudnorm I=−14 + TP=−1.5 LRA=8 → alimiter 0.95. Pre-bright A/B copy at + `solafiya-MASTER-preBright.wav`. (Durable home is + `~/Documents/Working Desktop/` — Desktop auto-cleans, + [[feedback_desktop_autocleaned]].) +- **Cover:** `…/solafiya-DISTROKID/solafiya-cover-3000.jpg` — 3000², + Pixar-style fía + 7 different-breed kittens on a golden-hour beach, + matcha + closed laptop + white W210 Mercedes + bounding dog + + polychrome sparkles. Lanczos upscale of `pop/jungle/out/solafiya.illy.png` + (1024² gpt-image-2 source, prompt at `pop/jungle/solafiya.illy.txt`). +- **DistroKid folder:** `~/Documents/Working Desktop/solafiya-DISTROKID/` + — MASTER.wav, MASTER-preBright.wav, cover-3000.jpg, mp3 (320 k + + embedded cover), README.md (submission-form fields + open questions). +- **Reconstructable** ($0, deterministic): `node pop/jungle/bin/render.mjs + --slug solafiya` (cached) then `bash pop/jungle/bin/bake-solafiya.sh` + → master chain above. `BAKE_FORCE=1` to re-bake from a fresh render. +- **Open before submitting:** (1) **artist credit** — `fía` solo vs + `fía & Aesthetic Dot Computer`; (2) **lyrics** — only + forced-aligned word JSON exists (`solafiya-fia-words.json`), no + canonical `.txt`; (3) **canvas / vertical video** — 19 lane raw + buffers + 6 section illys are baked, so `preview-score.mjs` / + `preview-spin.mjs` can produce a 9:16 IG cut + Spotify Canvas loop + (not yet built). + +--- + ## hellsine — WIP (concept track, first cut) - **Lane:** `pop/hellsine/` · the *all-sine* concept track — every voice diff --git a/pop/chillwave/helpabeach.youtube.txt b/pop/chillwave/helpabeach.youtube.txt new file mode 100644 index 0000000000..5c7ce4d557 --- /dev/null +++ b/pop/chillwave/helpabeach.youtube.txt @@ -0,0 +1,11 @@ +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/bin/canvas-loop.mjs b/pop/dance/bin/canvas-loop.mjs index 7232667a67..24f3da324c 100644 --- a/pop/dance/bin/canvas-loop.mjs +++ b/pop/dance/bin/canvas-loop.mjs @@ -24,6 +24,10 @@ // --dur SECONDS default 6 (Spotify allows 3–8) // --fps N default 30 // --xfade SECONDS cross-dissolve length (default: auto ≈ 0.22) +// --breath AMP breathing-zoom amplitude (default 0.025; bump to +// 0.06–0.10 when panels are visually similar so each +// held panel has a perceptible pulse instead of +// looking like a still frame) // --out PATH output .mp4 import { spawn } from "node:child_process"; @@ -149,9 +153,10 @@ function slitTransition(i, j, g, zoom, t) { ctx.restore(); } +const BREATH = Number(flags.breath ?? 0.025); function drawFrame(t) { // shared breathing zoom — seamless over the whole loop (period = DUR) - const zoom = 1.025 + 0.025 * Math.sin((2 * Math.PI * t) / DUR); + const zoom = 1.0 + BREATH + BREATH * Math.sin((2 * Math.PI * t) / DUR); const seg = t / segDur; const i = Math.floor(seg) % N; const local = t - Math.floor(seg) * segDur; diff --git a/pop/dance/bin/cover-prompt.felt-portrait.template.txt b/pop/dance/bin/cover-prompt.felt-portrait.template.txt new file mode 100644 index 0000000000..0305b2a04c --- /dev/null +++ b/pop/dance/bin/cover-prompt.felt-portrait.template.txt @@ -0,0 +1,24 @@ +A HAND-CRAFTED FELT TABLEAU with a SCARY EDGE — portrait panel, 2:3 (1024×1536), tall vertical orientation composed so it also crops cleanly to a centered square. Two CRAFTED-FELT CHARACTERS sit inside a real photographic environment (the after-hours grocery store stays photoreal — wood shelves, painted maritime mural, fog, dim store lights, kraft bags). The FIGURES ALONE are made of FELT: visible wool fibre texture on every surface of their skin / hair / clothing, slightly fuzzy outlines where each body meets the photographic background, soft 3-D forms sculpted from felt. Think a high-end stop-motion film still (Wes Anderson's "Isle of Dogs" / Aardman) with felt puppets placed inside a real grocery-store photograph — but this is the GRIMM-LATER-ACT of that film: things have gone wrong, the lighting is contracted, the fog is thicker, and the felt puppets' clothes are visibly TATTERED. NOT cartoon, NOT plush-toy, NOT cute — refined felt-CRAFT realism leaning unsettling. Editorial peer-horizontality: jeffrey and Mark share the SAME eye-line, neither centered as a hero; both absorbed in the laptop, NEITHER looks at the camera. ABSOLUTELY NO app / phone / story / UI chrome and NO LEGIBLE laptop or phone screen content (a diffuse colour glow IS allowed; no UI / text / icons / wordmarks ever). + +★ SCARY / UNSETTLING MOOD — push the atmosphere further than uncanny: the store is darker than a normal closing-time shot, the fog is heavier and lower so it pools around their feet and creeps over the counter, the security lights are dimmer + spaced wider so shadow pockets form in the aisles. Long shadows fall across the felt faces, deepening the eye sockets so the bead pupils catch only small glints of light. A faint sense of dread sits in the room — like they've been in this store for hours longer than they should have been, and the air has thickened. The PALS lid back-glow is the only saturated light source — everything else recedes. NO blood, NO gore, NO body-horror, NO obvious monsters — the scary is in the air + the worn clothing + the shadow, not in any single shocking element. + +★ TATTERED CLOTHES — both felt puppets' outfits show visible WEAR + DAMAGE rendered in felt: + · jeffrey's pale-blue felt button-down: small FRAYS at the cuffs + collar, a few exposed threads hanging loose, a small TEAR on the right shoulder seam with a couple of felt fibres pulled out, the placket slightly ragged, his felt sleeve cuff has a partly-undone hem. The pinstripes are still readable but a couple of pinstripe felt-strips have come unstuck and lift slightly off the fabric. + · Mark's navy felt HOODED SWEATSHIRT: a few small HOLES (real felt cutouts) at the hem + on one sleeve, the HARVARD-style chest print has FADED + lost a couple of letter edges, hanging threads at the seams, hood drawstring frayed at the ends, a worn patch on one elbow. The hoodie reads "been through a long night" — worn-down, but still wearable. + · The damage is REAL FELT damage (frayed fibres, loose threads, cut-out holes), NOT painted-on dirt or photo-grime. Clean craft execution of distressed clothing. + +★ JEFFREY (LEFT) — a felt-crafted puppet of the real jeffrey from the attached reference photographs, recognizable but tense: medium-length brown felt-yarn hair (slightly mussed, a couple of strands falling forward), short beard sculpted from short cut felt fibres, hazel felt eyes with glossy bead pupils — those bead pupils catch the {{LID_GLOW}} screen-spill, the rest of the face in shadow. His face is a smooth pale-flesh felt with subtle cheek-pink. He is concentrating hard — felt mouth in a tight closed line, brow drawn together (a thin felt seam works as the worry line). His right index finger gently extended toward the screen. OUTFIT: pale-blue felt long-sleeve shirt with hand-applied darker-blue felt pin-stripes (some lifting slightly off — see TATTERED above), a small embroidered YELLOW felt bear emblem at the chest, a single bright YELLOW Sailor-Pro-Gear-shape FOUNTAIN PEN clipped at the chest pocket — a real fountain pen (NOT felt, NOT a ballpoint, NOT a click-pen): glossy enamel CYLINDRICAL barrel in saturated yellow, smooth CAP with gold-coloured CLIP, distinctive flat-top with a knurled grip-ring near the cap edge. NO brand logo, NO anchor mark, NO wordmark — just clean yellow enamel + gold trim. The cap stays ON. A pair of soft RED plastic GLASSES hangs casually off the shirt — one temple/earpiece hooked over the placket so the glasses dangle freely against his chest. NO string, NO cord, NO lanyard, NO chain. No glasses on his face. Wide-leg medium-cobalt felt trousers (only the upper visible in this crop). + +★ MARK (RIGHT) — a felt-crafted puppet of a VERY YOUNG mark zuckerberg, age ~20-21, EXACTLY as in the attached zuck-2005-*.jpg + zuck-2007-*.jpg references (use them together — the 2005 dorm shot for outfit + the 2007 Hackathon close-ups for face shape, freckles, the cowlick + forehead curl). His face is pale flesh felt with a handful of darker felt FRECKLES across the nose + cheekbones, a slight felt overbite at his closed-ish mouth, slightly curly light-brown felt-yarn hair with a clear cowlick + forehead curl (also slightly mussed), wide-set hazel felt eyes with bead pupils — wide + worried-glassy in the dim screen-light. OUTFIT: a navy-blue felt HOODED SWEATSHIRT with the faded HARVARD-style collegiate arch in dark-crimson felt letters (legible but soft-edged, with FADING + worn patches — see TATTERED above), over a small charcoal felt tee collar. NO name badge, NO lanyard, NO clerk identifier of any kind. He looks OVERWHELMED — earnest student energy turned anxious, brow softly furrowed. + +★ THE PALS LAPTOP — there is EXACTLY ONE laptop in the whole picture: Mark's PALS laptop. It is photographic / real (NOT felt — a normal small ac laptop, matte aluminium-feel finish, lid angled so the SCREEN faces jeffrey + Mark, camera sees the LID BACK). The LID shows ONLY the PALS logo from the attached pals-logo.png — render PIXEL-FAITHFUL: EXACTLY TWO Keith-Haring-style hand-drawn outline-people side by side, simple continuous outline strokes, no extra figures, no extra lines, no shading, no fill (just the outline glyph), centred on the lid, glowing through prismatic colours. NO apple logo, NO whistlegraph doodle, NO stickers, NO additional graphics. The laptop is unscathed (it's the lifeline in this scary scene — the only thing still working). The PALS lid back-glow has a DOMINANT {{LID_GLOW}} cast in this panel, refracting into the fog around the laptop. + +★ THE PIXSIES — a few small felt characters in the periphery (visible at the bottom edge and faintly between jeffrey + Mark's shoulders in the background). Felt-crafted, child-sized, diverse mix. The UNCANNY TELLS are MORE PROMINENT than in the regular cover — the LED beads under their felt skin glow noticeably (a clear pinprick of cyan-green light at a temple, behind an ear, in an eye), one or two hairline seams visible on faces, slight off-human proportion in the heads. Watching, eerily still. The pixsies hold NO laptops — only Mark has the one PALS machine. + +★ THE ENVIRONMENT — stays PHOTOGRAPHIC: dim after-hours Trader Joe's, but darker than the bright cover. Cedar plank ceiling barely lit, wood-panelled shelves receding into shadow, painted maritime mural on the back wall (the colours muted, the painted boats nearly lost in the dark), heavy fog pooling through the aisles, kraft bags on the counter, only one or two distant security lights still on. The photographic environment HOLDS the felt figures in heightened shadow. + +★ SECTION LIGHTING — this panel's section colour wash is {{SECTION_LIGHTING}}, pooling in the fog and bouncing softly off the wood panels behind the figures. The prismatic lighting from trancepenta's 8-section arc still bathes the room but the hues are DEEPER + DIMMER, each section pool acting as a small atmospheric well in the fog. Overlap gently in the fog, no neon. The screen-spill on the felt faces is matte (felt absorbs light) but the bead pupils catch the {{LID_GLOW}} as a small shining glint. + +★ CLEAN GRAPHIC ACCURACY — the PALS logo is the exact two-people outline glyph, the HARVARD print is readable even though faded, no random noise glyphs. + +AVOID — photoreal skin on the figures (the figures are FELT — wool fibre texture, fuzzy meet-edges with the photo backdrop); cartoonish / kid's-craft / plush-toy look; cinematic or neon glow; lasers; melodramatic horror (no blood / gore / monsters / overt horror tropes); ANY app/phone/social-UI chrome; any LEGIBLE laptop or phone screen content (UI / text / icons / wordmarks — diffuse abstract screen-spill fine); any readable brand wordmark or store-name text except the soft HARVARD-style print on Mark's hoodie; ANY Dell / second laptop / extra devices / laptops in the pixsies' hands (exactly ONE laptop — Mark's PALS); the modern older Zuckerberg (he is YOUNG, early-Facebook); hero-pose centering; figures looking at or acknowledging the camera; ANY brand logo / wordmark on the yellow pen; ANY name badge / lanyard / clerk identifier on Mark; ANY glasses cord / chain / string on jeffrey; ANY damage on the laptop itself (only the clothes are tattered, the PALS laptop is intact). diff --git a/pop/dance/bin/gen-illys-penta-felt.sh b/pop/dance/bin/gen-illys-penta-felt.sh new file mode 100755 index 0000000000..de2eea3bf6 --- /dev/null +++ b/pop/dance/bin/gen-illys-penta-felt.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# gen-illys-penta-felt.sh — regenerate the trancepenta storyline panels +# in FELT-TATTERED mode (matching the felt cover at +# `trancepenta-cover-final/cover-prompt.txt`). Writes a per-section +# `cover-prompt.felt.txt` from the template + section colour, then runs +# gen-promo with `--prompt-file cover-prompt.felt.txt --variant vfelt`. +# Concurrency 3 on the 8 GB machine. +# usage: bash gen-illys-penta-felt.sh +set -u +REPO="/Users/jas/aesthetic-computer" +SECROOT="$HOME/Documents/Working Desktop/gens/trancepenta-sections" +TEMPLATE="$REPO/pop/dance/bin/cover-prompt.felt-portrait.template.txt" +LOG="$HOME/Documents/Working Desktop/gens/trancepenta-genillys-vfelt.log" +: > "$LOG" +GEN="$REPO/marketing/bin/gen-promo.mjs" + +# section | LID_GLOW | SECTION_LIGHTING (richer phrase for the wash) +specs=( + "intro|drained slate-blue|a drained slate-blue wash, low and cold like a power-saving fluorescent that's been on too long" + "break1|cold cyan-grey|a cold cyan-grey wash, the kind of colour your skin goes under a freezer-aisle light" + "build1|dim sick teal|a dim sick-teal wash, fog-heavy and faintly sour, the lighting of a long stalled night" + "drop1|cold cyan-blue|a cold cyan-blue wash, dominant on the laptop, slicing forward through the fog like a small searchlight" + "break2|bruised violet-grey|a bruised violet-grey wash, the bruise-toned hush of a store after the last customer is long gone" + "build2|acid grey-green|an acid grey-green wash low on the floor, a sour reflection rising from spilled fluorescents" + "drop2|cold steel-blue|a cold steel-blue wash, the DOMINANT central beam on the laptop, harder and more clinical than drop1" + "outro|ashen grey-blue|an ashen grey-blue wash, fully dimmed, the lights drained — almost the colour of held breath" +) + +stamp_prompt() { + local sec="$1" lid="$2" sect="$3" + local out="$SECROOT/$sec/cover-prompt.felt.txt" + # NB: sed with delimiters that don't clash with prose. + sed -e "s|{{LID_GLOW}}|${lid}|g" -e "s|{{SECTION_LIGHTING}}|${sect}|g" "$TEMPLATE" > "$out" + echo "▸ stamped $sec → $out (LID=$lid)" >> "$LOG" +} + +run_one() { + local sec="$1" + echo "▸ START $sec $(date +%H:%M:%S)" >> "$LOG" + node "$GEN" "$SECROOT/$sec" \ + --prompt-file cover-prompt.felt.txt \ + --variant vfelt --size 1024x1536 --force --no-mirror \ + >> "$LOG" 2>&1 \ + && echo "✓ DONE $sec $(date +%H:%M:%S)" >> "$LOG" \ + || echo "✗ FAIL $sec (exit $?) $(date +%H:%M:%S)" >> "$LOG" +} + +# 1) stamp prompts (cheap, sequential) +for spec in "${specs[@]}"; do + IFS='|' read -r sec lid sect <<< "$spec" + stamp_prompt "$sec" "$lid" "$sect" +done + +# 2) regen (concurrency 3 to stay under 8 GB pressure) +i=0 +for spec in "${specs[@]}"; do + IFS='|' read -r sec _ _ <<< "$spec" + run_one "$sec" & + i=$((i+1)) + if (( i % 3 == 0 )); then wait -n 2>/dev/null || wait; fi +done +wait +echo "=== ALL FELT GENS COMPLETE $(date +%H:%M:%S) ===" >> "$LOG" +grep -E '✓ DONE|✗ FAIL' "$LOG" diff --git a/pop/dance/bin/gen-trancepenta-sections.mjs b/pop/dance/bin/gen-trancepenta-sections.mjs new file mode 100644 index 0000000000..49064e1dbb --- /dev/null +++ b/pop/dance/bin/gen-trancepenta-sections.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node +// dance/bin/gen-trancepenta-sections.mjs — generate the TRANCEPENTA +// storyline panel set for the YOUTUBE landscape visualizer: a 16:9-ish +// (1536×1024) cover + 16 panels (2 per struct section). +// +// Concept (jas, 2026-05-23): +// +// jeffrey + his small MAGICAL PIXSIES walk into Trader Joe's late +// afternoon. They shop the aisles together, push a red cart, end +// at the checkout where young MARK ZUCKERBERG is the cashier. Mark +// scans their groceries, then spots the iconic PALS laptop tucked +// under jeffrey's arm. jeffrey casually opens it on the counter and +// shows it to him. They SHAKE HANDS — jeffrey LEAVES the laptop +// with Mark as a gift. jeffrey + pixsies walk out and PACK THE CAR +// in the parking lot. Across the 16 panels the store PROGRESSIVELY +// DARKENS — late afternoon → dusk → twilight → night → fully dim +// + dismal by the end. FELT-COZY texture throughout (chunky knits, +// wool jumpers, soft store textiles, felt-painted aesthetic). The +// pixsies are SMALLER-THAN-ADULT magical humans with subtle synthetic +// tells — not super-soldiers, not children, somewhere whimsical. +// +// Output: pop/dance/out/trancepenta-yt-cover.png (landscape hero) +// pop/dance/out/trancepenta-yt-sec--.png (16 panels) + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import * as progress from "../../lib/render-progress.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const POP = resolve(LANE, ".."); +const REPO = resolve(POP, ".."); + +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 FORCE = flags.force === true; +const SIZE = "1536x1024"; // landscape 3:2 (for 1920x1080 YT) +mkdirSync(`${LANE}/out`, { recursive: true }); + +// ── identity refs ──────────────────────────────────────────────────── +const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot-2k`; +const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`; +const ZUCK_DIR = `${homedir()}/Documents/Working Desktop/gens/_zuck-refs`; +const TP_REFS_DIR = `${homedir()}/Documents/Working Desktop/gens/trancepenta-sections/intro/refs`; +const COVER_REF = `${homedir()}/Documents/Working Desktop/trancepenta-cover-3000.jpg`; +const REFS = [ + `${SHOOT_DIR}/jeffery-av--07.jpg`, + `${SHOOT_DIR}/jeffery-av--01.jpg`, + `${SHOOT_DIR}/jeffery-av--04.jpg`, + `${ARCHIVE_DIR}/2018-12-02_Bq4ckGFFNtW.jpg`, + `${ARCHIVE_DIR}/2020-09-02_CEpxlO2FOvD.jpg`, + `${ZUCK_DIR}/zuck-2005-a.jpg`, + `${ZUCK_DIR}/zuck-2005-crop.jpg`, + `${TP_REFS_DIR}/zuck-2007-frick.jpg`, + `${TP_REFS_DIR}/zuck-2007-goldberg.jpg`, + `${TP_REFS_DIR}/pals-logo.png`, + // (released cover ref REMOVED — its multi-element composition was + // nudging gpt-image-2 toward collage outputs.) +].filter((p) => { + if (existsSync(p)) return true; + console.warn(` ⚠ ref missing, dropping: ${p}`); + return false; +}); + +function loadOpenAIKey() { + if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; + const vault = `${REPO}/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env`; + if (existsSync(vault)) { + for (const line of readFileSync(vault, "utf8").split("\n")) { + if (line.startsWith("OPENAI_API_KEY=")) { + return line.slice("OPENAI_API_KEY=".length).trim().replace(/^['"]|['"]$/g, ""); + } + } + } + throw new Error("OPENAI_API_KEY not set and not found in vault devcontainer.env"); +} + +// ── LANDSCAPE framing override ─────────────────────────────────────── +const LANDSCAPE_NOTE = +`LANDSCAPE OVERRIDE — WIDE horizontal 3:2 / 16:9 frame (NOT a tall portrait or square). pull the camera back so the WHOLE Trader Joe's space breathes around jeffrey + Mark + the pixsies — wide cinematic establishing room shots, the WHOLE aisle / checkout / parking lot visible. figures live in a horizontal mid-band, never cropped tight. keep the felt-cozy texture, the photographic-with-felt-painted feel, and the section's progressive darkening.`; + +// ── core scene law ──────────────────────────────────────────────────── +// FELT-TABLEAU style (matches the album cover at +// `gens/trancepenta-cover-final/cover-prompt.txt`): the figures are +// crafted-felt puppets placed inside a real photographic environment. +// Aardman / Isle-of-Dogs craft quality, NOT cute, NOT plush. Clothes +// are visibly tattered. Same arc as before (daytime → night), but the +// medium is felt throughout. +const MEDIUM = +`A HAND-CRAFTED FELT TABLEAU — figures alone are CRAFTED-FELT puppets placed inside a real photographic environment. The PHOTO of the store stays photoreal (wood shelves, painted maritime mural, store lights, kraft bags). The FIGURES are MADE OF FELT: visible wool fibre texture on every surface of skin / hair / clothing, slightly fuzzy outlines where each body meets the photographic background, soft 3-D forms sculpted from felt. high-end stop-motion film still craft quality — Aardman-studio / Isle-of-Dogs felt-puppet realism. NOT cartoon, NOT plush-toy, NOT cute, NOT kid's-craft. Editorial peer-horizontality: jeffrey + Mark + the pixsies share an everyday eye-line. NEITHER figure looks at the camera or acknowledges the viewer; everyone absorbed in their task. EVERYTHING IN SHARP FOCUS — no motion blur, no out-of-focus blur, no depth-of-field haze. NO app / phone / UI chrome and NO LEGIBLE laptop or phone screen content (a soft slate-blue colour glow IS allowed; never legible UI / text / icons / wordmarks).`; + +const SETTING = +`SETTING — a Trader Joe's with cedar-plank ceiling, wood-panelled shelving stocked with generic goods, hand-lettered chalkboard signs, the painted maritime/island mural on the back wall, buckets of cut flowers, a red shopping cart, brown kraft bags. Daytime business hours but the light progressively DARKENS across the visit — late afternoon at the start, into dusk through the aisles, into twilight + closing-time at the register, into NIGHT + a faintly dismal stillness by the time they're packing the car outside. KEEP ALL PACKAGING + SIGNAGE GENERIC AND ILLEGIBLE — no readable real brand wordmarks anywhere. Mark works the daytime cashier shift; jeffrey + the pixsies are his everyday customers, shopping the aisles and ending at his line.`; + +const JEFFREY = +`JEFFREY — a felt-crafted puppet of the real jeffrey from the attached reference photographs: medium-length brown felt-yarn hair, short BEARD sculpted from short cut felt fibres, hazel felt eyes with glossy bead pupils, smooth pale-flesh felt face with subtle cheek-pink. Felt mouth, expressions sculpted with sewn-seam micro-stitches (a slight smile-seam, a faint worry-line seam at the brow). His easygoing everyday-shopper energy. OUTFIT (felt throughout): pale-blue felt long-sleeve button-down with hand-applied darker-blue felt pinstripes, small embroidered YELLOW felt bear emblem low on the chest, a single bright YELLOW Sailor-Pro-Gear FOUNTAIN PEN clipped at the chest pocket — a real fountain pen (NOT felt, NOT a ballpoint): glossy enamel cylindrical barrel in saturated yellow, smooth CAP with gold CLIP, flat-top with knurled grip-ring, cap stays ON, NO brand logo. Soft RED plastic glasses hooked over the placket so they dangle freely against his chest (NO cord, NO chain, NO lanyard). Wide-leg medium-cobalt felt trousers, worn black canvas-felt low-top sneakers. Soft canvas-felt MESSENGER / SHOULDER BAG slung across his body; in earlier beats his small PALS LAPTOP tucked under his arm or peeking from the bag.`; + +const ZUCK = +`THE CASHIER = a felt-crafted puppet of a VERY YOUNG mark zuckerberg, age ~20-21, matching the attached real reference photos (zuck-2005-*.jpg + zuck-2007-*.jpg): pale flesh felt face with darker felt FRECKLES across the nose + cheekbones, slight felt overbite at his closed-ish mouth, slightly curly light-brown felt-yarn hair with a clear cowlick + forehead curl, wide-set hazel felt eyes with bead pupils. NOT the modern older Zuckerberg. OUTFIT (felt throughout): navy-blue felt HOODED SWEATSHIRT with a faded HARVARD-style collegiate arch in dark-crimson felt letters (legible but soft-edged), plain blue felt jeans. Cashier touch: a simple Trader Joe's pin-on felt NAME BADGE + a thin LANYARD worn over the hoodie. Working a normal cashier shift — scanning + bagging groceries earnestly. Shy + deadpan, never looks at the camera.`; + +const TATTERED = +`★ TATTERED FELT CLOTHES — both jeffrey's + Mark's felt outfits show visible WEAR + DAMAGE rendered in the felt itself: + · jeffrey's pale-blue felt button-down: small FRAYS at cuffs + collar, a few exposed threads hanging loose, a small TEAR on the right shoulder seam with felt fibres pulled out, the placket slightly ragged. A couple of pinstripe felt-strips have come unstuck and lift slightly off the fabric. + · Mark's navy felt hooded sweatshirt: a few small HOLES (real felt cutouts) at the hem + on one sleeve, the HARVARD print FADED with worn patches + a couple of letter edges lost, hanging threads at seams, hood drawstring frayed at the ends, a worn patch on one elbow. + · the pixsies' chunky-knit jumpers + scarves: each pixsie has its own small wear — a loose thread, a frayed cuff, a tiny moth-hole. +The damage is REAL FELT damage (frayed fibres, loose threads, cut-out holes), NOT painted-on dirt or photo-grime. Clean craft execution of distressed clothing — these felt puppets have lived in this story for a while.`; + +const LAPTOP = +`THE LAPTOP — EXACTLY ONE laptop in the whole picture: jeffrey's PALS LAPTOP. It is photographic / real (NOT felt — a normal small ac laptop, matte aluminium-feel finish). Match the TRANCEPENTA COVER ART exactly: the lid is a clean coloured panel with the PALS LOGO rendered LARGE + CENTRED + BRIGHT NEON-BLUE BACK-LIT — TWO Keith-Haring-style outline figures (a smaller one + a slightly taller one, side by side, hand-drawn outline, each with a round head, simple body, arms held out) glowing in a saturated cyan/electric-blue against the muted lid colour. The PALS logo is PROMINENT (about 55-65% of the lid width). Use the attached pals-logo.png AS THE REFERENCE for the exact figure shapes — never "JOY" text or any other symbol, NEVER an Apple logo. EARLY beats: laptop closed under arm or in bag. CHECKOUT-onward: open on the counter with a soft cool slate-blue screen glow spilling on jeffrey + Mark's felt faces from below; screen content UNREADABLE. LATE beats: jeffrey leaves the open laptop on the counter for Mark. The laptop is the only thing in the frame that is UNDAMAGED (only the clothes are tattered — the PALS machine is intact).`; + +const PIXSIES = +`THE PIXSIES — small felt-crafted characters around jeffrey, ~3/4 to 4/5 his height, felt puppets like him + Mark, with MORE PROMINENT uncanny tells than regular humans: the LED beads under their felt skin glow noticeably — a clear pinprick of cyan-green light at a temple, behind an ear, in an eye — one or two hairline seams visible on faces, slight off-human proportion in the heads. NATURAL HUMAN ears — never pointed, never elf, never animal. Wardrobe (felt throughout): cozy felt + wool — chunky-knit jumpers in muted earth tones, soft felted beanies, felt scarves, small canvas-felt bags. Each pixsie wears something slightly tattered (see TATTERED FELT CLOTHES). They are jeffrey's everyday companions out shopping with him — pushing the red cart, carrying brown kraft bags, browsing flowers, helping at the counter, packing the car. Quietly curious, gently uncanny, content. NOT super-soldiers, NOT children, NOT cute mascots — small uncanny felt-puppet adults.`; + +const AVOID = +`AVOID — photoreal skin or hair on the figures (the figures are FELT — wool fibre texture, fuzzy meet-edges with the photo backdrop); cartoonish / kid's-craft / plush-toy look; cinematic-glossy or neon-poster mood; 3D-render look; ANY motion blur or out-of-focus / depth-of-field blur (everything sharp); pointed elf / animal ears on pixsies; CHILD-SIZED or "cute mascot" pixsies; ADULT super-soldier-bodybuilder pixsies; any "JOY" text on the laptop lid; any readable wordmark / typography on the laptop lid (only the PALS two-figure logo); an Apple / Dell / brand logo on any device; readable real-world brand wordmarks anywhere in the store; ANY app/phone/UI chrome; legible laptop screen content (only diffuse slate-blue glow); a second laptop anywhere; the modern older Zuckerberg (he is YOUNG, early-Facebook era); hero-pose centering; jeffrey or Mark looking at the camera; CLEAN-SHAVEN jeffrey (he must have the short felt beard); ANY damage on the laptop itself (only the clothes are tattered).`; + +// ── 16 VIZ BEATS — 2 per struct section ───────────────────────────── +// Names map: -. Renderer's SLIDES array maps each +// beat to a half-section time window. Progressive darkening across the +// arc: late-afternoon (intro) → dusk (build1) → twilight (drop1) → +// night (drop2) → fully dim & dismal (outro). +const SECTION_ORDER = [ + "intro-a", "intro-b", + "break1-a", "break1-b", + "build1-a", "build1-b", + "drop1-a", "drop1-b", + "break2-a", "break2-b", + "build2-a", "build2-b", + "drop2-a", "drop2-b", + "outro-a", "outro-b", +]; + +const SECTION_VARIANTS = { + // INTRO — late afternoon arrival + "intro-a": +`BEAT 1 (intro-a) · "approach" · LATE AFTERNOON warm light. OUTSIDE the front of the open Trader Joe's, golden hour. jeffrey + four-or-five small magical pixsies walking up the terracotta-tiled entryway. jeffrey has the PALS laptop tucked closed under one arm. soft cozy felt vibes on their clothing — chunky-knit jumpers + felted hats. the painted "TRADER" mural visible above the sliding doors, just opening for them.`, + "intro-b": +`BEAT 2 (intro-b) · "into the store" · just-past-golden-hour warm interior. they have STEPPED THROUGH the doors. jeffrey + pixsies inside the entryway, taking in the store. one pixsie reaches for a red shopping cart. warm overhead lights, cedar-plank ceiling visible above. felted store textiles, an air of cozy bustle. light just starting to soften.`, + // BREAK1 — produce / flowers, late afternoon turning dim + "break1-a": +`BEAT 3 (break1-a) · "the flowers" · soft late-afternoon light cooling. jeffrey + a few pixsies at the cut-flower buckets. one pixsie selecting a small bunch of dahlias with cozy concentration, another tucking flowers into the red cart. soft felted scarves wrapped around small necks. the store light a touch dimmer than the entry beat — clouds outside, day going.`, + "break1-b": +`BEAT 4 (break1-b) · "produce" · light dimming further toward early dusk. jeffrey + pixsies in the produce section, picking apples + bagging citrus + selecting bread. cart is filling. the chunky-knit jumpers + wool textures vivid in the warm-cool mix of fluorescent + window light. laptop still tucked under jeffrey's arm.`, + // BUILD1 — snack aisle, dusk + "build1-a": +`BEAT 5 (build1-a) · "the snack aisle" · DUSK now, the warm window light tipping cool. deeper into the store — a snack / dry-goods aisle. jeffrey + pixsies pulling boxes off shelves into the cart. one pixsie compares two boxes thoughtfully, another piles snacks into the cart. interior lights stronger as outside dims.`, + "build1-b": +`BEAT 6 (build1-b) · "wine + drinks" · twilight outside, store amber inside. moving through a beverage aisle — pixsies handling boxed drinks, a small bottle of olive oil, a tin of nuts. cart full. jeffrey reaches for a tall bottle on a high shelf, laptop secured under the other arm.`, + // DROP1 — at checkout, twilight + "drop1-a": +`BEAT 7 (drop1-a) · "the line" · early TWILIGHT outside, store overhead lights take over. jeffrey + pixsies arrive at the checkout. Mark (young Zuckerberg, name badge + lanyard) stands at his register actively SCANNING the first items pulled from the cart. jeffrey waits at the counter end with the laptop still tucked under his arm. pixsies hover at the cart helping unload onto the conveyor belt.`, + "drop1-b": +`BEAT 8 (drop1-b) · "scanning groceries" · twilight outside fully cool blue. Mark continues scanning — a box of crackers, a bunch of bananas — bagging into brown kraft sacks. jeffrey patient, half-watching. pixsies organizing the next pile of groceries onto the belt, their felted gear softly visible.`, + // BREAK2 — Mark notices the laptop, dim store evening + "break2-a": +`BEAT 9 (break2-a) · "the spot" · early NIGHT outside (deep blue), store overheads only. mid-scan Mark has PAUSED — a box of crackers half-raised in one hand — because he just SPOTTED the small PALS LAPTOP under jeffrey's arm. eyebrows up, curious half-question forming. jeffrey's noticed Mark noticing — soft half-smile, lifts his arm so Mark can see the PALS logo clearly.`, + "break2-b": +`BEAT 10 (break2-b) · "the question" · the store dimmer still, work-lights pooling. Mark sets the crackers down, leans forward over the register asking softly about the laptop. jeffrey nods, starts to slide it from under his arm. the pixsies pause their bagging, glancing over with quiet interest. small felted hands stilled.`, + // BUILD2 — the reveal, dim + "build2-a": +`BEAT 11 (build2-a) · "the reveal" · NIGHT outside, the store growing distinctly dim except for a single overhead spot over the counter. jeffrey has SLID THE PALS LAPTOP OUT and is OPENING it on the counter beside the bagged groceries. cool slate-blue screen-spill catches under his jaw + on Mark's pale face. Mark leans across the register, transfixed. the cyan glow is THE PRIMARY light now.`, + "build2-b": +`BEAT 12 (build2-b) · "explaining" · the only light now is the cool laptop slate-blue + a faint overhead. dimmer + slightly dismal around them — the store closing in around the counter. jeffrey mid-explanation, pointing at the keyboard, gesture mid-air. Mark concentrated, brow up. pixsies cluster closer to watch, their felted shapes silhouetted in the cyan light.`, + // DROP2 — the handshake + gift, nearly dark + "drop2-a": +`BEAT 13 (drop2-a) · "the handshake" · the store NEARLY DARK around them — only the laptop's slate-blue spill + a low overhead pool. jeffrey + Mark SHAKING HANDS across the counter, a firm friendly handshake. the laptop sits open between them, glowing cyan. groceries bagged in kraft sacks around them. a moment of real exchange.`, + "drop2-b": +`BEAT 14 (drop2-b) · "leaving the laptop" · same near-dark, the cyan glow dominant. jeffrey is SLIDING THE OPEN PALS LAPTOP across the counter TOWARD Mark, leaving it as a gift. Mark's hand reaching to accept it. the PALS logo softly visible back-glowing on the lid. pixsies stand by approvingly, faintly lit by the cyan. quiet warmth.`, + // OUTRO — packing the car at night, dismal + "outro-a": +`BEAT 15 (outro-a) · "to the car" · OUTSIDE now in the parking lot, FULLY NIGHT, a dim parking-lot lamppost the only light. jeffrey + pixsies walking from the store to the parked car (small hatchback / station wagon), kraft bags in arms + in the red cart. a thin dismal stillness in the air — the store warm behind them but small in the distance.`, + "outro-b": +`BEAT 16 (outro-b) · "packing" · same NIGHT, same dim lamppost. they are PACKING the car — tailgate open, pixsies handing brown kraft bags into the trunk + arranging flowers carefully + folding the last sack inside. jeffrey lifts the last bag in. through the store window in the far distance you can JUST see Mark at his register hunched over the open PALS laptop, a small cyan glow. a contented but slightly dismal night close.`, +}; + +function safeName(n) { + return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} +// SINGLE-SCENE PREAMBLE — explicit anti-collage instruction first, +// because gpt-image-2 was reading the long multi-paragraph prompt as a +// request to compose a 9-panel storyboard of the whole arc into ONE +// image. This forces it to render a SINGLE cinematic frame. +const SINGLE_FRAME = +`A SINGLE LANDSCAPE PHOTOGRAPH — ONE coherent cinematic frame, ONE moment in time, ONE composition. NOT a collage. NOT a comic strip. NOT a multi-panel grid. NOT a storyboard. NOT split-screen. The entire image is ONE continuous scene captured from ONE camera position at ONE moment.`; +function build(sectionBeat) { + return [SINGLE_FRAME, LANDSCAPE_NOTE, sectionBeat, MEDIUM, SETTING, JEFFREY, ZUCK, TATTERED, LAPTOP, PIXSIES, AVOID].join("\n\n") + "\n"; +} + +const apiKey = loadOpenAIKey(); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function buildForm(promptText) { + const fd = new FormData(); + fd.append("model", "gpt-image-2"); + fd.append("prompt", promptText); + fd.append("size", SIZE); + fd.append("quality", "high"); + fd.append("n", "1"); + for (const ref of REFS) { + const buf = readFileSync(ref); + const lower = ref.toLowerCase(); + const ext = lower.endsWith(".png") ? "png" : lower.endsWith(".webp") ? "webp" : "jpeg"; + fd.append("image[]", new Blob([buf], { type: `image/${ext}` }), ref.split("/").pop()); + } + return fd; +} + +async function generate(promptText, outPath, label) { + const rel = outPath.replace(REPO + "/", ""); + if (existsSync(outPath) && !FORCE) { + console.log(`✓ cached → ${rel}`); + return; + } + console.log(`▸ ${label} · ${SIZE} · ${REFS.length} refs`); + 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(); + 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}`); + return; + } catch (e) { + 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`); + return; + } + } +} + +const onlySet = typeof flags.only === "string" + ? new Set(flags.only.split(",").map((x) => x.trim().toLowerCase())) + : null; +const wants = (name) => !onlySet || onlySet.has(name); + +const jobs = []; +// Cover = the build2-a "reveal" moment (matches the released cover composition). +if (wants("cover")) + jobs.push({ prompt: build(SECTION_VARIANTS["build2-a"]), out: `${LANE}/out/trancepenta-yt-cover.png`, label: "trancepenta-yt 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]), + out: `${LANE}/out/trancepenta-yt-sec-${i}-${safeName(name)}.png`, + label: `trancepenta-yt §${i} ${name}`, + }); +} + +progress.begin({ type: "illy", label: `trancepenta-yt · ${jobs.length} panels` }); +let done = 0; +// Concurrency 3 — gpt-image-2 server-side is per-request anyway, and 8 GB +// machine can hold three in-flight HTTPS uploads + base64 decodes. +const POOL = 3; +let cursor = 0; +async function worker() { + while (true) { + const i = cursor++; + if (i >= jobs.length) return; + const job = jobs[i]; + await generate(job.prompt, job.out, job.label); + progress.update((++done / jobs.length) * 100); + } +} +await Promise.all(Array.from({ length: POOL }, () => worker())); +progress.end(); +console.log(`\n✓ trancepenta YT panel set — ${jobs.length} job(s) · 16 beats · the long blue hum`); diff --git a/pop/dance/bin/preview-score-trancepenta-yt.mjs b/pop/dance/bin/preview-score-trancepenta-yt.mjs new file mode 100644 index 0000000000..f9d7b0fb5a --- /dev/null +++ b/pop/dance/bin/preview-score-trancepenta-yt.mjs @@ -0,0 +1,1333 @@ +#!/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 || "trancepenta"; +const TITLE = flags.title || SLUG; +// trancepenta master + struct live in the Working-Desktop bake dir. +const TP_OUT = `${process.env.HOME}/Documents/Working Desktop/twi-out`; +// YT cut: uses the trancepenta-yt-* landscape panel set + 16 slides. +const COVER = flags.cover || `${LANE}/out/${SLUG}-yt-cover.png`; +const AUDIO = flags.audio || `${TP_OUT}/${SLUG}.mp3`; +const STRUCT = flags.struct || `${TP_OUT}/${SLUG}-MASTER-preBright.wav.assets/struct.json`; +const OUT = flags.out + || `${LANE}/out/${SLUG}-preview-score-yt.mp4`; +const FPS = Number(flags.fps ?? 30); +const SIZE = flags.size || "1920x1080"; // landscape YouTube default +const [W, H] = SIZE.split("x").map(Number); +// --reel → IG-reel cut: no title, no segmented progress bar, no +// timecode (the pals stay). Output file gets a `-reel` infix so the +// chromed insta-story and the chrome-free reel can both coexist. +const REEL = flags.reel === true; +// --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); +// 16-SLIDE override — each struct section split in HALF into two viz +// slides named `
-a` / `
-b`. Doubles the visual pace +// (the 4th struct section was way too long as a single image). The +// audio-lane events still come from struct.events directly; only the +// IMAGE cycling uses these 16 slides. +const SECTIONS = (() => { + const out = []; + const src = struct.sections.slice().sort((a, b) => a.startSec - b.startSec); + for (const s of src) { + const mid = (s.startSec + s.endSec) / 2; + out.push({ name: `${s.name}-a`, startSec: s.startSec, endSec: mid }); + out.push({ name: `${s.name}-b`, startSec: mid, endSec: s.endSec }); + } + return out; +})(); +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: "kick", color: "#c84a3c" }, // deep terracotta — gabber kick punch + { key: "sub", color: "#2a4a72" }, // oceanic deep blue — sub bass + { key: "lead", color: "#e8a13c" }, // warm amber — lead (harbour lamp colour) + { key: "bells", color: "#a8dde8" }, // pale ice — bells (foam / mist sparkle) + { key: "piano", color: "#9bb09a" }, // soft sage — piano (kelp / drift) +]; +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`); + +// KICK onsets drive the "sharpen" punch (the gabber kick is the beat). +const punchTimes = laneEvents.kick.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). +// 16 tints — PROGRESSIVE DARKENING across the arc (late afternoon to +// dismal night). Each struct section's -a / -b sub-slides each get +// their own colour so the bottom progress bar reads the descent. +const SECTION_TINTS = { + "intro-a": "rgba(230,170,110,1)", // late-afternoon warm amber + "intro-b": "rgba(210,150,100,1)", // dipping toward dusk + "break1-a": "rgba(180,150,130,1)", // dusty pink-grey + "break1-b": "rgba(160,140,130,1)", // cooling further + "build1-a": "rgba(140,140,140,1)", // neutral dusk + "build1-b": "rgba(120,130,140,1)", // dusky teal-grey + "drop1-a": "rgba(100,120,140,1)", // slate + "drop1-b": "rgba(90,110,140,1)", // deepening twilight + "break2-a": "rgba(80,90,120,1)", // bruised violet-blue + "break2-b": "rgba(70,80,110,1)", // night creeping in + "build2-a": "rgba(60,70,100,1)", // deep storm-blue + "build2-b": "rgba(50,60,90,1)", // dark cool + "drop2-a": "rgba(40,55,90,1)", // near-night blue + "drop2-b": "rgba(35,50,85,1)", // night blue + "outro-a": "rgba(28,38,70,1)", // dismal night + "outro-b": "rgba(20,28,55,1)", // fully dim blue-black +}; +const TITLE_PALETTE = ["#a6bcd6", "#7fb3c8", "#b0c4a8", "#eec48c", "#d6b4b4"]; +const BACKLIGHT_RGB = "255,196,116"; // warm harbour-lamp / cottage-window light + +// ── 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++) { + 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`); + 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 { + const fp = `${LANE}/${SLUG}-yt-forms.json`; + if (existsSync(fp)) FORMS = JSON.parse(readFileSync(fp, "utf8")).sections || {}; +} 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; +} +// Iterate over ANY figure keys in the section object (jeffrey + zuck +// for trancepenta, jeffrey + gates for marimba, etc) — not hardcoded. +function figureBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return Object.values(f).map(_toBox).filter(Boolean); +} +function faceBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return Object.values(f).map(_toFace).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 (like vertical + // movie-poster spine text). Each char individually bounces + glows + // with the audio envelope, staggered. + 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 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 + // (vignette removed — was crushing the panel edges; figures already pop + // enough via the transmitted backlight + leaded contrast layers.) + // 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(audioT); + // trancepenta reel keeps the segmented progress bar (different from + // the marimba reel which strips it). Timecode still off in reel. + drawProgressBar(audioT); + if (!REEL) 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 new file mode 100644 index 0000000000..215c521877 --- /dev/null +++ b/pop/marimba/marimbaba.youtube.txt @@ -0,0 +1,11 @@ +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 diff --git a/toolchain/youtube/yt.mjs b/toolchain/youtube/yt.mjs index ce83bc3a8b..862c7389f5 100644 --- a/toolchain/youtube/yt.mjs +++ b/toolchain/youtube/yt.mjs @@ -409,12 +409,39 @@ async function doDelete() { die(`delete ${res.status}: ${body}`); } +// ── set thumbnail on an existing video ────────────────────────────── +// Useful when the thumbnail upload at video-upload time fails (e.g. the +// source JPG was >2 MB and YouTube rejected it). Run the same call the +// upload path uses, but against an already-published videoId. +async function doThumbnail() { + const id = positional[0]; + const img = positional[1]; + if (!id || !img) die(`usage: yt.mjs thumbnail `); + const imgPath = resolve(process.cwd(), img); + if (!existsSync(imgPath)) die(`thumbnail not found: ${imgPath}`); + const at = await accessToken(); + const res = await fetch( + `https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${encodeURIComponent(id)}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${at}`, + "Content-Type": imgPath.endsWith(".png") ? "image/png" : "image/jpeg", + }, + body: readFileSync(imgPath), + }); + if (!res.ok) die(`thumbnail ${res.status}: ${await res.text()}`); + console.log(`✓ thumbnail set on ${id}`); + console.log(` watch · https://youtu.be/${id}`); +} + // ── dispatch ───────────────────────────────────────────────────────── const COMMANDS = { auth: doAuth, whoami: doWhoami, upload: doUpload, "update-meta": doUpdateMeta, + thumbnail: doThumbnail, delete: doDelete, }; -- 2.51.2