diff --git a/pop/maytrax/bin/beat-lights.mjs b/pop/maytrax/bin/beat-lights.mjs index 2bb436a160..f6674a5e2a 100644 --- a/pop/maytrax/bin/beat-lights.mjs +++ b/pop/maytrax/bin/beat-lights.mjs @@ -1,23 +1,31 @@ #!/usr/bin/env node -// beat-lights.mjs — the neon rig, driven by the score itself. +// beat-lights.mjs — the neon rig and the grade, driven by the score itself. // // render-femrag-plusplus.mjs writes every musical hit it plays to // out/.events.json (kick, snare, hat, bell, sub, donk, riser, with -// exact times and gains). This reads that feed and paints a light-overlay -// video: colored pools that BLAST from the frame edges the way the felted -// neon tubes in the rafters would if they were wired to the mixer. +// exact times and gains). This reads that feed and builds the light — no +// model, no API, no cost, and exactly in time, because it reads the same +// event list the audio was rendered from rather than guessing at it with a +// beat detector. // -// It is locally generated — no model, no API, no cost — and it is exactly -// in time because it is reading the same event list the audio was rendered -// from, not a beat-detector's guess about it. +// THREE-LAYER SEPARATION, the marimbaba/trancenwaltz treatment +// (pop/marimba/bin/motion-score-marimbaba-yt.mjs). Light stacked in three +// passes so the subject reads BACKLIT rather than washed over: +// 1. BEHIND — a coloured bloom rising from behind the figure [screen], +// hue-cycling, punched by the kick and swelled by the sub. +// 2. CONTRAST — a vignette darkening the periphery [multiply] that +// BREATHES with the beat, so the middle separates from the edges. +// 3. RIM — a tight hot halo hugging the centre mass [screen], snapped by +// the snare, which is what actually reads as separation. +// Layer 2 is the one that does the work: without it the glow sits ON the +// picture; with it the picture sits INSIDE the glow. // -// The overlay is rendered small (the light is all soft gradients, so it -// upscales cleanly) and streamed to ffmpeg as raw video, then screen-blended -// over the cut. Nothing large ever touches the disk. +// Also: HUE SHIFTING across the whole grade, and a PIXEL-DISSOLVE punch on +// every section boundary read from .struct.json. // -// node pop/maytrax/bin/beat-lights.mjs # overlay only -// node pop/maytrax/bin/beat-lights.mjs --over # + composite -// node pop/maytrax/bin/beat-lights.mjs --opacity .5 +// node pop/maytrax/bin/beat-lights.mjs --over +// node pop/maytrax/bin/beat-lights.mjs --over --dur 60 --opacity .5 +// node pop/maytrax/bin/beat-lights.mjs --over --no-pixel import { existsSync, readFileSync } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; @@ -39,10 +47,10 @@ for (let i = 2; i < process.argv.length; i++) { } const FPS = 24; -const W = 320, H = 180; // overlay resolution — soft light upscales fine +const W = 320, H = 180; // soft light upscales cleanly const OPACITY = Number(flags.opacity ?? .55); -// --from trims the overlay to a slice of the track (for the reel cut). const FROM = Number(flags.from ?? 0); +const PIXEL = flags["no-pixel"] !== true; const eventsPath = `${OUT}/${SLUG}.events.json`; if (!existsSync(eventsPath)) { @@ -50,13 +58,10 @@ if (!existsSync(eventsPath)) { process.exit(1); } const feed = JSON.parse(readFileSync(eventsPath, "utf8")); -const DUR = Number(flags.dur ?? feed.seconds) ; +const DUR = Number(flags.dur ?? feed.seconds); const FRAMES = Math.ceil(DUR * FPS); // ── the rig ──────────────────────────────────────────────────────────────── -// Each instrument owns a lamp: a color, a place in the frame, and a decay. -// `spread` is how far the pool reaches from its anchor (in frame heights), -// `decay` how many seconds the flash takes to fall away. const LAMPS = { boom: { rgb: [255, 150, 40], from: "bottom", spread: .95, decay: .20, gain: 5.2 }, snare: { rgb: [255, 60, 190], from: "sides", spread: .85, decay: .16, gain: 5.0 }, @@ -66,29 +71,25 @@ const LAMPS = { bell: { rgb: [255, 225, 150], from: "top", spread: .45, decay: .09, gain: 1.1 }, riser: { rgb: [255, 255, 255], from: "all", spread: 1.3, decay: .50, gain: 1.6 }, }; +// Which instruments drive which of the three layers. +const BEHIND = new Set(["boom", "sub", "riser"]); +const RIM = new Set(["snare", "donk"]); -// Bucket events into frames so each frame only walks its own recent hits. -// A hit can still light frames after it (its decay tail), so each event is -// filed into every frame its tail reaches. const perFrame = Array.from({ length: FRAMES }, () => []); -let placed = 0; for (const e of feed.events) { const lamp = LAMPS[e.i]; if (!lamp) continue; - // A riser lights its whole duration, not just its onset. const span = e.i === "riser" ? (e.dur || 0) : 0; - const start = Math.floor(e.t * FPS); - const end = Math.ceil((e.t + span + lamp.decay * 3) * FPS); - for (let f = Math.max(0, start); f < Math.min(FRAMES, end); f++) { - perFrame[f].push(e); - } - placed++; + const start = Math.floor((e.t - FROM) * FPS); + const end = Math.ceil((e.t - FROM + span + lamp.decay * 3) * FPS); + for (let f = Math.max(0, start); f < Math.min(FRAMES, end); f++) perFrame[f].push(e); } -// Precompute each pixel's distance from each anchor, so the per-frame work is -// just an envelope multiply rather than a distance calculation. +// Distance fields, precomputed so the inner loop is an envelope multiply. +// `centre` is radial from the middle — the behind-bloom and the rim both +// key off it, which is what puts the light around the figure. const FIELD = {}; -for (const key of new Set(Object.values(LAMPS).map((l) => l.from))) { +for (const key of [...new Set(Object.values(LAMPS).map((l) => l.from)), "centre"]) { const f = new Float32Array(W * H); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { @@ -99,7 +100,8 @@ for (const key of new Set(Object.values(LAMPS).map((l) => l.from))) { case "top": d = v; break; case "left": d = u; break; case "sides": d = Math.min(u, 1 - u) * 2; break; - default: d = Math.min(Math.min(u, 1 - u), Math.min(v, 1 - v)) * 2; break; + case "centre": d = Math.hypot((u - .5) * 2, (v - .52) * 2); break; + default: d = Math.min(Math.min(u, 1 - u), Math.min(v, 1 - v)) * 2; } f[y * W + x] = d; } @@ -107,90 +109,155 @@ for (const key of new Set(Object.values(LAMPS).map((l) => l.from))) { FIELD[key] = f; } -const frame = Buffer.alloc(W * H * 3); +// Slow hue rotation over the whole piece, the trancenwaltz/marimbaba trick. +function hueSpin(t) { + const a = (t / DUR) * Math.PI * 2 * 3; // three cycles across the cut + return [Math.cos(a), Math.cos(a - 2.094), Math.cos(a - 4.189)].map((c) => .72 + .28 * c); +} + +const buf = Buffer.alloc(W * H * 3); const acc = new Float32Array(W * H * 3); -function renderFrame(fi) { - acc.fill(0); +// layer: "glow" (screen: behind + rim) or "vign" (multiply: contrast) +function renderFrame(fi, layer) { const t = fi / FPS + FROM; + if (layer === "vign") { + // Layer 2 — the contrast pass. Dark at the edges, open in the middle, + // and the squeeze BREATHES with whatever is hitting. + let punch = 0; + for (const e of perFrame[fi]) { + if (!BEHIND.has(e.i) && !RIM.has(e.i)) continue; + const lamp = LAMPS[e.i]; + punch += Math.exp(-Math.max(0, t - e.t) / lamp.decay) * (e.gain ?? .1) * 3.2; + } + punch = Math.min(1, punch); + const centre = FIELD.centre; + for (let p = 0, n = W * H; p < n; p++) { + const d = centre[p]; + // 1 in the middle → falls off outward; deeper falloff on a hit. + const k = Math.max(0, 1 - Math.pow(d / (1.32 - .22 * punch), 2.1)); + const v = 70 + 185 * k; // never crush fully to black + const o = p * 3; + buf[o] = buf[o + 1] = buf[o + 2] = v; + } + return buf; + } + // Layers 1 + 3 — behind-bloom and rim, both additive. + acc.fill(0); + const spin = hueSpin(t); for (const e of perFrame[fi]) { const lamp = LAMPS[e.i]; const span = e.i === "riser" ? (e.dur || 0) : 0; const since = t - e.t; if (since < 0) continue; - // Inside a sustained event the envelope holds at 1; after it, it decays. - const past = Math.max(0, since - span); - const env = Math.exp(-past / lamp.decay); + const env = Math.exp(-Math.max(0, since - span) / lamp.decay); const amp = env * (e.gain ?? .1) * lamp.gain; if (amp < .004) continue; - const field = FIELD[lamp.from]; const [r, g, b] = lamp.rgb; + // edge wash — the rig in the rafters + const field = FIELD[lamp.from]; for (let p = 0, n = W * H; p < n; p++) { - // Light falls off with distance from the lamp's edge. const fall = Math.max(0, 1 - field[p] / lamp.spread); - const k = fall * fall * amp; + const k = fall * fall * amp * .8; if (k < .002) continue; const o = p * 3; - acc[o] += r * k; acc[o + 1] += g * k; acc[o + 2] += b * k; + acc[o] += r * k * spin[0]; acc[o + 1] += g * k * spin[1]; acc[o + 2] += b * k * spin[2]; + } + // layer 1 — bloom rising from BEHIND the figure + if (BEHIND.has(e.i)) { + const c = FIELD.centre; + for (let p = 0, n = W * H; p < n; p++) { + const k = Math.max(0, 1 - c[p] / .95) ** 2.4 * amp * .55; + if (k < .002) continue; + const o = p * 3; + acc[o] += r * k * spin[0]; acc[o + 1] += g * k * spin[1]; acc[o + 2] += b * k * spin[2]; + } + } + // layer 3 — tight rim hugging the centre mass + if (RIM.has(e.i)) { + const c = FIELD.centre; + for (let p = 0, n = W * H; p < n; p++) { + const ring = 1 - Math.min(1, Math.abs(c[p] - .58) / .17); + if (ring <= 0) continue; + const k = ring * ring * amp * .5; + const o = p * 3; + acc[o] += r * k * spin[0]; acc[o + 1] += g * k * spin[1]; acc[o + 2] += b * k * spin[2]; + } } } for (let i = 0, n = W * H * 3; i < n; i++) { const v = acc[i]; - frame[i] = v >= 255 ? 255 : v <= 0 ? 0 : v; // implicit floor + buf[i] = v >= 255 ? 255 : v <= 0 ? 0 : v; } - return frame; + return buf; } -const overlayPath = `${OUT}/${SLUG}-beat-lights.mp4`; -console.log(`▸ beat-lights · ${FRAMES} frames · ${DUR.toFixed(1)}s · ${placed} events wired`); - -const ff = spawn("ffmpeg", [ - "-hide_banner", "-loglevel", "error", "-y", - "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "pipe:0", - "-vf", "scale=1280:720:flags=bicubic,gblur=sigma=6", - "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", - overlayPath, -], { stdio: ["pipe", "inherit", "inherit"] }); - -let done; -const finished = new Promise((r) => { done = r; }); -ff.on("close", (code) => done(code)); - -for (let f = 0; f < FRAMES; f++) { - if (!ff.stdin.write(Buffer.from(renderFrame(f)))) { - await new Promise((r) => ff.stdin.once("drain", r)); +async function bake(layer, path) { + const ff = spawn("ffmpeg", [ + "-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "pipe:0", + "-vf", `scale=1280:720:flags=bicubic,gblur=sigma=${layer === "vign" ? 10 : 6}`, + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", path, + ], { stdio: ["pipe", "inherit", "inherit"] }); + const closed = new Promise((r) => ff.on("close", r)); + for (let f = 0; f < FRAMES; f++) { + if (!ff.stdin.write(Buffer.from(renderFrame(f, layer)))) { + await new Promise((r) => ff.stdin.once("drain", r)); + } } - if (f % 480 === 0) process.stdout.write(`\r ${Math.round(f / FRAMES * 100)}% `); + ff.stdin.end(); + if (await closed !== 0) { console.error(`✗ ${layer} encode failed`); process.exit(1); } + console.log(` ✓ layer ${layer}`); } -ff.stdin.end(); -const code = await finished; -process.stdout.write("\r"); -if (code !== 0) { console.error("✗ overlay encode failed"); process.exit(1); } -console.log(`✓ ${overlayPath}`); + +const glowPath = `${OUT}/${SLUG}-beat-glow.mp4`; +const vignPath = `${OUT}/${SLUG}-beat-vign.mp4`; +console.log(`▸ beat-lights · ${FRAMES} frames · ${DUR.toFixed(1)}s · 3-layer separation`); +await bake("glow", glowPath); +await bake("vign", vignPath); // ── composite ────────────────────────────────────────────────────────────── const over = flags.over === true ? `${OUT}/${SLUG}-shakeout-yt.mp4` : flags.over; -if (!over) { - console.log(` (pass --over to screen-blend it onto a cut)`); - process.exit(0); -} +if (!over) { console.log(" (pass --over to grade a cut)"); process.exit(0); } if (!existsSync(over)) { console.error(`✗ no cut at ${over}`); process.exit(1); } -const litPath = over.replace(/\.mp4$/, "-lit.mp4"); -console.log(` compositing over ${over.split("/").pop()} at opacity ${OPACITY} …`); -// Match the overlay to whatever frame the cut actually is — the same rig -// serves the landscape film and the 9:16 reel. + const probe = spawnSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", over], { encoding: "utf8" }); const [vw, vh] = (probe.stdout || "1280x720").trim().split("x").map(Number); -console.log(` target frame ${vw}x${vh}`); + +// PIXEL-DISSOLVE punch on each section boundary, read from the struct the +// cut was actually built from — a short pixelize burst, marimbaba-style. +let pixExpr = ""; +const structPath = flags.struct || `${OUT}/${SLUG}.struct.json`; +if (PIXEL && existsSync(structPath)) { + const st = JSON.parse(readFileSync(structPath, "utf8")); + const cuts = st.sections.map((s) => s.startSec - FROM) + .filter((t) => t > .2 && t < DUR - .2); + if (cuts.length) { + pixExpr = cuts.map((t) => `between(t,${t.toFixed(2)},${(t + .18).toFixed(2)})`).join("+"); + console.log(` pixel-dissolve on ${cuts.length} boundaries`); + } +} +const fit = `scale=${vw}:${vh}:force_original_aspect_ratio=increase,crop=${vw}:${vh}`; +const chain = [ + `[1:v]${fit},format=yuv420p[g]`, + `[2:v]${fit},format=yuv420p[v]`, + // layer 2 first (multiply) so the picture sits INSIDE the light, + // then layer 1+3 (screen) on top. + `[0:v][v]blend=all_mode=multiply:all_opacity=0.9[base]`, + `[base][g]blend=all_mode=screen:all_opacity=${OPACITY}` + + (pixExpr ? `[lit];[lit]pixelize=w=24:h=24:enable='${pixExpr}'[out]` : `[out]`), +].join(";"); + +const litPath = over.replace(/\.mp4$/, "-lit.mp4"); +console.log(` grading ${over.split("/").pop()} · ${vw}x${vh} · opacity ${OPACITY} …`); const mix = spawnSync("ffmpeg", [ "-hide_banner", "-loglevel", "error", "-y", - "-i", over, "-i", overlayPath, - "-filter_complex", - `[1:v]scale=${vw}:${vh}:force_original_aspect_ratio=increase,crop=${vw}:${vh},format=yuv420p[o];[0:v][o]blend=all_mode=screen:all_opacity=${OPACITY}[v]`, - "-map", "[v]", "-map", "0:a?", + "-i", over, "-i", glowPath, "-i", vignPath, + "-filter_complex", chain, + "-map", "[out]", "-map", "0:a?", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", litPath, ], { stdio: ["ignore", "inherit", "inherit"] }); -if (mix.status !== 0) { console.error("✗ composite failed"); process.exit(1); } +if (mix.status !== 0) { console.error("✗ grade failed"); process.exit(1); } console.log(`✓ ${litPath}`); diff --git a/pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs b/pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs index 6ae0714bbf..a872604966 100644 --- a/pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs +++ b/pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs @@ -38,7 +38,18 @@ const SLUG = "femrag-plusplus"; const REEL_DIR = `${OUT}/reel`; mkdirSync(REEL_DIR, { recursive: true }); +// Each section is SPLIT into halves. Seedance drifts over a 13-second take +// and packs far more movement into 6–7 seconds, so halving the shots doubles +// the cut rate AND raises the motion density — at identical billed seconds, +// and reusing the panel we already generated for that section. const REEL_SECTIONS = ["drop1a", "drop1b", "breakdown", "buildup2", "drop2a"]; +const SPLITS = { + drop1a: ["hands", "grid"], + drop1b: ["chase", "spin"], + breakdown: ["window", "creep"], + buildup2: null, // already short — one shot + drop2a: ["launch", "hammer"], +}; // Slice the reel's struct out of the film's own struct, rebased to zero, so // the reel inherits the real arrangement instead of restating it. @@ -56,10 +67,16 @@ if (picked.length !== REEL_SECTIONS.length) { const t0 = picked[0].startSec; const REEL_END = picked.at(-1).endSec; const structPath = `${REEL_DIR}/${SLUG}-reel.struct.json`; -writeFileSync(structPath, JSON.stringify({ - totalSec: REEL_END - t0, - sections: picked.map((s) => ({ name: s.name, startSec: s.startSec - t0, endSec: s.endSec - t0 })), -}, null, 2)); +const sections = []; +for (const s of picked) { + const parts = SPLITS[s.name]; + const a = s.startSec - t0, b = s.endSec - t0; + if (!parts) { sections.push({ name: s.name, startSec: a, endSec: b }); continue; } + const mid = a + (b - a) / 2; + sections.push({ name: `${s.name}-${parts[0]}`, startSec: a, endSec: mid }); + sections.push({ name: `${s.name}-${parts[1]}`, startSec: mid, endSec: b }); +} +writeFileSync(structPath, JSON.stringify({ totalSec: REEL_END - t0, sections }, null, 2)); // The audio: the same slice of the master, cut once and cached. const reelAudio = `${REEL_DIR}/${SLUG}-reel.mp3`; @@ -75,7 +92,7 @@ if (!existsSync(reelAudio)) { } const MEDIUM_MOTION = [ - `A needle-felt music video at 144 BPM — every motion is DANCE, driven and rhythmic, landing on beats rather than drifting. The wool bunny never goes limp between moves; he is always either loading or releasing.`, + `A needle-felt music video at 144 BPM — every motion is DANCE, driven and rhythmic, landing on beats rather than drifting. The wool bunny never goes limp between moves; he is always either loading or releasing.\n\nMOTION DENSITY — this is a SHORT, FAST shot and it must be BUSY from the first frame to the last. Something moves in every single frame. At 144 BPM there are roughly TWO AND A HALF BEATS PER SECOND, so the bunny changes direction, weight or pose several times per second — quick, snappy, percussive movements, not one slow graceful gesture held across the take. Do NOT ease in, do NOT drift, do NOT hold a static pose: the shot opens already at full speed and stays there. The camera also keeps moving throughout.`, `EAR COUNT — ABSOLUTE, EVERY SINGLE FRAME: the bunny has EXACTLY TWO EARS, attached at two fixed points on the top of his head. TWO. Never three, never four. When an ear swings fast it must stay ONE continuous ear that moves — it must NEVER split into two, echo, ghost, smear into a duplicate, or leave a second copy behind at its old position. LIGHT CONTINUITY — the many differently-coloured LEDs inside his body and inside both ears stay lit and stay in the SAME physical places for the whole shot; the rainbow glow may pulse in brightness with the beat but never migrates, goes out, or turns into a flat overlay. The neon tubes in the rafters are fixed physical fixtures — they may change brightness on the beat but never move or drift.`, FRAMING_IG_STORY_PORTRAIT_MOTION, @@ -85,50 +102,68 @@ LIGHT CONTINUITY — the many differently-coloured LEDs inside his body and insi // Portrait re-framings of the same five moments. Vertical wants the camera // closer and the action stacked, so these are tighter than the wide cut. const SHOTS = { - drop1a: { + // Each shot is ~6.7s and must be BUSY end to end — see MOTION DENSITY. + "drop1a-hands": { + motion: +`FIRST-PERSON POV, opening the reel already at full speed. The camera is the bunny's own eyes looking down at the felted laptop. His own two forepaws HAMMER the felted keycaps — fast, percussive, several distinct strikes per second, paws lifting clear and slamming back down, the wool of the keys visibly compressing under each hit and springing back. The rainbow LEDs under the wool of his forearms FLASH brighter on every strike, so his arms strobe with the rhythm. The camera bobs and jitters slightly with each hit — small, human, never smooth. No face, no second bunny.`, + }, + "drop1a-grid": { + motion: +`FIRST-PERSON POV, still hammering, now pushed in CLOSE on the glowing note grid itself. The coloured tiles on the felt screen FIRE in fast rhythmic patterns — different tiles lighting and going dark several times a second, columns and rows flickering on the beat like a sequencer running. His paws keep striking in and out of the bottom of frame at speed, and the light thrown back onto them changes colour with every tile that fires. The camera pushes in steadily on the grid across the shot. Relentless, fast, no pauses.`, + }, + "drop1b-chase": { + motion: +`THIRD-PERSON VIDEO-GAME CHASE CAM, like Grand Theft Auto or Roblox — the camera whips around from behind the laptop to find him already up and dancing, then FOLLOWS him at speed. He is mid-shakeout the whole shot: body twisting hard left and right, front paws thrown wide and snapped back, hind paws stamping, his TWO long ears whipping out and cracking back several times per second, LEDs streaking as they swing. The camera swings and catches up with him repeatedly, never settling. No HUD, no crosshair, no overlay.`, + }, + "drop1b-spin": { motion: -`FIRST-PERSON POV, opening the reel at full power: the camera is the bunny's own eyes looking down at the felted laptop. His own two forepaws hammer the felted keycaps in rhythm — real strikes, paws lifting and landing on the beat, the wool of the keys compressing under each hit. The coloured tiles on the felt screen light and change as he plays, throwing shifting light up onto his paws, and the rainbow LEDs under the wool of his forearms flare brighter with every strike. Above the laptop, up the tall frame, the A-frame rafters carry the neon rig blasting magenta, cyan and lime. The camera breathes slightly with his head — small, human — and NEVER shows his face or a second bunny.`, +`THIRD-PERSON GAME CAM, now ORBITING him fast — the camera arcs a full half-circle around the bunny across the shot, so the speaker stack, the gable window and the rafter peak all sweep past behind him with strong parallax. He spins with it, turning on the spot, front paws flung out, his TWO long ears flying horizontally with the rotation and blazing rainbow. Loose wool wisps whip off him. Fast, continuous, dizzying — the camera never stops moving and neither does he.`, }, - drop1b: { + "breakdown-window": { motion: -`THIRD-PERSON VIDEO-GAME CHASE CAM, like Grand Theft Auto or Roblox — the camera floats behind and just above the bunny and FOLLOWS him as he dances, holding him in the middle band of the tall frame with the room running away ahead and the rafters climbing the top of the frame. He is mid-shakeout: body twisting, front paws thrown wide, his TWO long ears whipping out to either side, the chains of coloured LEDs inside them streaking as they swing. His rainbow glow pulses with the beat and throws moving coloured pools onto the rug. Smooth trailing game-camera motion — no HUD, no crosshair, no interface overlay; this is still a photograph of felt.`, +`HARD CUT to OUTSIDE, through the gable window, and the energy drops to nothing. The camera sits still out in the cold night, the dark mullions fixed across the tall frame. Inside, small and distant, the bunny rocks in slow heavy half-time with one paw on the big speaker cone — the ONLY slow shot in the reel, and it lands as relief. The cone visibly pushes outward against his paw and relaxes on each bass note, his rainbow glow swelling and dimming with it. Held, patient, almost still.`, }, - breakdown: { + "breakdown-creep": { motion: -`FROM OUTSIDE, THROUGH THE GABLE WINDOW, holding nearly still. The camera stays out in the cold night looking in through the felted panes, the dark mullions fixed across the tall frame. Inside, small and distant, the bunny rocks in slow heavy half-time with one paw on the big speaker cone; the cone visibly pushes outward against his paw and relaxes, a slow physical pulse, and his whole body rocks with it. His rainbow glow is the only light moving in the house — separate coloured blooms breathing through his wool, both long ears hanging low and lit. The camera creeps almost imperceptibly closer to the glass. Enormous and patient.`, +`Still outside looking in, but now the camera CREEPS steadily toward the glass across the whole shot, closing the distance so the window fills more and more of the tall frame and the bunny grows from small to clearly readable. Inside he keeps rocking in half-time, but his rainbow LEDs begin to pulse FASTER and brighter as the shot goes on — the wind-up starting while we are still outside. The pulse rate climbs steadily. Tension building through glass.`, }, buildup2: { - morphTo: "drop2a", physical: "extreme", contacts: ["front paws → rug", "hind paws → rug", "ears → pinned flat along his spine"], invariants: ["exactly one bunny throughout", "exactly two ears throughout"], beats: [ - { at: 0, action: "tight on the bunny, crouching all the way down, chin near his knees, both front paws pressed flat to the rug" }, - { at: .4, action: "his two ears pin flat back along his spine and lock there; his whole wool body gathers and stills" }, - { at: .75, action: "hind paws load and the rug compresses visibly beneath them; the rainbow LEDs inside him surge toward white" }, - { at: 1, action: "he fires upward off both hind paws, ears tearing up off his spine, the camera dropping back and down as he clears the rug" }, + { at: 0, action: "hard cut inside, tight on the bunny — he is already crouching down fast, chin dropping toward his knees" }, + { at: .45, action: "his two ears pin flat back along his spine and lock; his whole body gathers and compresses" }, + { at: .8, action: "the rainbow LEDs inside him surge and strobe toward white, faster and faster; the rug compresses under his hind paws" }, + { at: 1, action: "he fires upward off both hind paws and clears the rug, ears tearing up off his spine" }, ], motion: -`The wind-up, on the SAME single bunny, framed tall. He crushes down into the rug with his two ears pinned flat and everything gathered, the rainbow light inside him surging brighter and faster — then launches, and the camera falls back and down the tall frame as he rises out of the crouch, landing exactly on the final frame at the top of the leap.`, +`The wind-up, tight and fast. He crushes down into the rug with his two ears pinned flat, the rainbow light inside him strobing quicker and quicker as he loads — then FIRES upward and clears frame. The camera pushes in hard as he compresses and drops back as he launches. Short, violent, no drift.`, }, - drop2a: { + "drop2a-launch": { physical: "extreme", contacts: ["hind paws → airborne at the apex", "rug → folded and kicked beneath him"], invariants: ["exactly two ears, both present", "one bunny only"], beats: [ - { at: 0, action: "apex of the leap — body arched back, all four paws flung out, both ears whipping up behind his head" }, - { at: .25, action: "he falls, paws gathering under him, ears streaming upward against the drop" }, - { at: .5, action: "hind paws slam the rug and it folds; loose wool bursts off him and the neon rig blasts to full" }, - { at: 1, action: "he rebounds into the hardest dancing of the reel — bigger arcs, harder direction changes, ears cracking through their swings" }, + { at: 0, action: "apex of the leap — body arched, all four paws flung out, both ears whipping up behind his head" }, + { at: .3, action: "he falls fast, paws gathering under him, ears streaming upward" }, + { at: .55, action: "hind paws slam the rug, it folds, wool bursts off him and the whole neon rig blasts to full" }, + { at: 1, action: "he rebounds instantly into hard fast dancing — no pause on the landing at all" }, ], motion: -`THE DROP, shot low and tall so the A-frame peak and the whole neon rig fill the top of the frame above him. He lands, folds the rug, and tears into the hardest dancing of the reel — faster turns, wider paw throws, both long ears whipping through their arcs and blazing rainbow, his whole body a lantern throwing coloured light onto the rug. Loose wool wisps hang and swirl in the coloured air. The camera stays low and pushes in slightly.`, +`THE DROP, low and tall so the A-frame peak and the neon rig fill the top of the frame. He lands hard, folds the rug, and rebounds instantly — no settle, no pause. Loose wool bursts off him on the impact and hangs in the coloured air. The camera shakes on the landing and pushes in.`, + }, + "drop2a-hammer": { + motion: +`The hardest, fastest dancing in the reel — full-body shakeout at maximum speed. He changes direction several times per second: hips whipping, front paws thrown wide and snapped back, hind paws stamping the rug, his TWO long ears cracking through big arcs and reversing, blazing rainbow, wool wisps flying off him continuously. The neon rig strobes at full blast across the rafters above. The camera pushes in and shakes with the beat, never still for a frame. Ends still going — no wind-down.`, }, }; // Reel panels resolve by NAME, like the wide cut, so renumbering is harmless. function panelFor(name) { - const suffix = `-${name}.png`; + // "drop1a-grid" and "drop1a-hands" both come from the drop1a panel. + const base = Object.keys(SPLITS).find((k) => name === k || name.startsWith(`${k}-`)) || name; + const suffix = `-${base}.png`; const hit = readdirSync(OUT) .filter((f) => f.startsWith(`${SLUG}-reel-sec-`) && f.endsWith(suffix)) .sort(); diff --git a/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs b/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs index dfd0f5e54c..acbde319f3 100644 --- a/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs +++ b/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs @@ -92,6 +92,7 @@ const ELECTRIC = · CIRCUITRY IN THE WOOL — fine enameled COPPER and colored magnet WIRE is needle-felted directly INTO his body, running across his chest, shoulders, back and down his limbs in deliberate looping CIRCUIT TRACES, stitched flush into the fibers like veins under skin. tiny felted solder-blobs and little brass eyelets sit where traces meet. the wire is a craft material here — hand-couched into wool, sometimes lifting a millimeter clear of the surface, never printed-on graphics. · LEDS INSIDE HIM, IN FULL RAINBOW — many small bright LEDs are embedded UNDERNEATH the felt, inside his body and inside each ear, and they light him FROM WITHIN. every LED is a DIFFERENT COLOR — magenta, cyan, lime, hot orange, violet, gold, rose — scattered all through him, so he reads as a RAINBOW MULTIGLOW rather than one warm color. wool is translucent when backlit, so each colored light BLOOMS THROUGH the fibers as its own soft round patch of that hue, and where two colored patches overlap in the wool their colors mix and bleed into a third. you can see the discrete POINT of each LED as a hot colored core inside its own soft glowing patch — never a flat even wash, never a single uniform tint, never an overlay. · HIS EARS LIGHT UP IN COLOR — each ear has a run of differently-colored LEDs inside it, so when an ear swings past a dark background the whole ear glows through from the inside as a chain of separate colored blooms — cyan next to magenta next to lime — the wool going translucent with the fiber structure visible against each light, like a hand held over a torch. +· HIS EYES ARE RGB — his two bead eyes are no longer plain black beads: they are lit LED eyes, each one a bright saturated RGB colour glowing from inside the bead like a tiny indicator lamp. The two eyes may be DIFFERENT colours from each other (one cyan and one magenta, or lime and violet) and they shift hue with the music. Each glowing eye still sits centred INSIDE its own round red lens — the lit eye makes the rim law easier to read, never an excuse to break it. The glow from his eyes throws a faint coloured cast onto his own cheeks and the inside of the red rims. · THE GLASSES CATCH IT — the red felt rims pick up the internal glow from below and read hot red. · THE PROPS PERSIST — the felted LAPTOP he made the beat on stays in the room for the WHOLE film. It remains open on the green rug where he left it, screen still lit and glowing, visible somewhere in every wide shot — never cleared away, never closed, never teleported. Same for the amps, the pedal boxes and the yarn cables: once a prop is in the room it stays in the room, in the same place, for the rest of the story. · THE TETHER — a slim felted battery pack sits at the small of his back, and a colored wire tail trails off him across the floor to the speaker stack: he is PLUGGED IN to the sound system, part of the circuit.`; @@ -109,10 +110,21 @@ const SCREEN_LAW = · across the BOTTOM, the note grid: TWO side-by-side BANKS separated by a narrow white gutter, each bank EXACTLY 4 TILES WIDE and 3 TILES TALL — twelve flat rectangular candy-pastel tiles per bank, packed edge to edge like postage stamps, a small dark letter in each tile's top-left corner and a small faint label in its bottom-left. the top row runs salmon, rust-orange, peach, olive; the middle row yellow, mint-green, teal, cornflower-blue; the bottom row violet, lilac, magenta, orchid. the two banks repeat the same colors. never crop, merge, or duplicate a row or column — always exactly 4 across and 3 down per bank. Render all of it as flat hand-felted color fields with fuzzy wool edges — a felted REPRODUCTION of that interface, glowing. The small letters and labels may stay soft and ILLEGIBLE (they are tiny felt marks, not typography), but the layout, the proportions and the tile colors must match the reference. NO invented interface, NO waveform art, NO abstract equalizer, NO logos or wordmarks anywhere.`; + +// Sound made physical: the cabinets are a colour-coded wall and the music +// itself comes OUT of them as felted objects. Same idea as the note-colour +// palette the marimbaba score uses — pitch has a colour here. +const SOUND_LAW = +`THE SPEAKER WALL IS COLOUR-CODED — every single felted speaker cabinet is a DIFFERENT COLOUR. No two cabinets share one. They are dyed-wool candy colours matching the note tiles on his screen — salmon, rust-orange, peach, olive, butter-yellow, mint-green, teal, cornflower-blue, violet, lilac, magenta, orchid — each a solid flat felted body with an oatmeal wool cone in it, stacked and leaning together so the whole wall reads as a bank of coloured blocks, like a chord laid out. Their little indicator lights and knobs pick up the same colours. NOT a wall of black boxes. +SOUND MADE VISIBLE — the music is physically COMING OUT of the cones, as hand-felted objects in the room: +· WAVES — concentric RINGS of felt push out of each speaker cone into the air, one inside the next like ripples frozen mid-expansion, getting bigger and thinner and more transparent as they travel out from the cone; each ring is dyed the same colour as the cabinet it came from, so a magenta cabinet throws magenta rings. +· NOTES — small felted MUSIC NOTES tumble and fly out of the cones on those waves and drift through the room — simple stitched quarter-notes and eighth-notes with round heads and little stems and flags, each one a different candy colour, some spinning, some caught mid-tumble, scattered through the air around him and lit by the neon. +These are REAL FELT OBJECTS in the diorama with fuzzy fibrous edges and their own soft shadows — hand-made props hanging in the air, NOT drawn graphics, NOT glowing 2D overlays, NOT digital effects composited on top.`; + // The room: @jeffrey's old Ashland A-frame, felted. const WORLD = `THE ROOM — a needle-felted miniature of an A-FRAME HOUSE INTERIOR (modeled on the reference photo sheet): steep TRIANGULAR A-frame geometry is the defining shape of every wide shot — massive exposed timber rafters felted in dark walnut wool running up and meeting in a peak overhead, the sloped ceiling planes closing in on both sides, honey-and-amber WOOD PLANK walls and a wood plank FLOOR felted in warm ochre wool with visible board seams, and a big window set into the gable end. a round green wool rug sits on the floor. the SAME room throughout the film — same rafters, same plank floor, same gable window — progressively taken over by the sound system. -THE SOUND SYSTEM GROWS — hand-felted speaker cabinets accumulate along the walls and up under the rafters until they are a leaning tower; thick colored yarn CABLES snake everywhere across the plank floor and up into the beams, plugging into him.`; +THE SOUND SYSTEM GROWS — the colour-coded felted speaker cabinets accumulate along the walls and up under the rafters until they are a leaning tower; thick colored yarn CABLES snake everywhere across the plank floor and up into the beams, plugging into him.`; const LIGHT = `LIGHT — this is a LIGHT SHOW and it is all PHYSICALLY BUILT, never a filter. The A-frame rafters are strung with practical lighting the way a real party rigs a room: @@ -122,7 +134,7 @@ const LIGHT = · contrast is high and the room is DARK between the lights — deep shadow in the rafter peak, hot saturated color below. every light source is a real fixture visible in frame or just outside it.`; const PALETTE = -`PALETTE — off-white and soft-grey wool bunny lit from inside in warm white and amber; round red glasses; copper and enamel-green wire traces; honey, ochre and walnut felted wood; charcoal speaker cabinets with oatmeal cones; and the neon rig in saturated magenta, cyan, acid-green and hot orange. the WOOL stays matted and real — the saturation lives in the light, not in a filter over the image.`; +`PALETTE — off-white and soft-grey wool bunny lit from inside in warm white and amber; round red glasses; copper and enamel-green wire traces; honey, ochre and walnut felted wood; speaker cabinets in a dozen different candy colours with oatmeal cones, and felted sound-waves and music notes in those same colours; and the neon rig in saturated magenta, cyan, acid-green and hot orange. the WOOL stays matted and real — the saturation lives in the light, not in a filter over the image.`; const AVOID = `AVOID — any 3d-render, clay, plastic, or smooth plush-toy look (this is real matted needle-felted wool, and the light is real fixtures); ANY human person anywhere (the bunny is the only character); a second bunny, a mirror, or any doubled figure; MORE THAN TWO EARS on the bunny (no third ear, no doubled or echoed ear); empty red glasses rims; any readable text, wordmark, signage, band name, or logo of any kind; any post-processing look — no bloom filter over the whole frame, no lens flares, no laser beams as solid cones, no light shafts drawn as objects, no glowing outline traced around the character; motion-blur streaks or drawn speed-lines; the character looking at or acknowledging the camera; any real-world brand.`; @@ -251,6 +263,7 @@ function build(beat) { FRAME_NOTE, BUNNY, ELECTRIC, + SOUND_LAW, WORLD, LIGHT, PALETTE,