From 460eb4c07601ffd13e05ac657c91e79ea7cb9a2f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 13 Aug 2026 19:20:20 -0400 Subject: [PATCH] pop/maytrax: SHAKEOUT vertical reel (9:16, 60s) + ratio-aware assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reel is the same film, not a second one. `--reel` on the sections generator re-shoots five beats at 1024x1536 against their own LANDSCAPE panel as a reference, so the room, props, lighting and character are identical and only the camera's aspect changed. It covers the track's first FIVE sections, which land on exactly 60.0s — POV on the laptop, the game chase cam, the breakdown seen from outside through the gable window, the wind-up, and the big drop. Cutting on real section boundaries means the audio slice is a clean musical phrase instead of a fade at an arbitrary time, and the reel driver slices its struct out of the film's own struct rather than restating the arrangement. motion-pipeline: the assemble step hardcoded scale=1280:720, so a 9:16 lane was silently squashed into a landscape frame on the way through the trim (and the Ken Burns fallback did the same). The output frame now follows cfg.ratio — 720x1280 for 9:16, 960x960 for 1:1, unchanged for 16:9. beat-lights: probes the target cut and scales/crops the overlay to fit, so one rig serves both the landscape film and the vertical reel; --dur and --from let it cover a slice of the track. Co-Authored-By: Claude Opus 5 (1M context) --- pop/lib/motion-pipeline.mjs | 10 +- pop/maytrax/bin/beat-lights.mjs | 14 +- .../bin/gen-motion-femrag-plusplus-reel.mjs | 149 ++++++++++++++++++ .../gen-sections-femrag-plusplus-shakeout.mjs | 44 ++++-- 4 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs diff --git a/pop/lib/motion-pipeline.mjs b/pop/lib/motion-pipeline.mjs index fb4b26fffe..da16dff786 100644 --- a/pop/lib/motion-pipeline.mjs +++ b/pop/lib/motion-pipeline.mjs @@ -207,7 +207,11 @@ export async function runMotionCli(cfg, flags = parseFlags()) { const AUDIO = flags.audio ? resolve(process.cwd(), String(flags.audio)) : cfg.audio; const FINAL = flags.out ? resolve(process.cwd(), String(flags.out)) : cfg.finalOut; - console.log(" trimming picked takes to exact section lengths …"); + // The output frame follows the lane's ratio — a 9:16 lane must not be + // squashed into a landscape frame on the way through the trim. + const [FW, FH] = (cfg.ratio || "16:9") === "9:16" ? [720, 1280] + : (cfg.ratio || "16:9") === "1:1" ? [960, 960] : [1280, 720]; + console.log(` trimming picked takes to exact section lengths … (${FW}x${FH})`); const clipSeconds = (p) => { const r = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", p], { encoding: "utf8" }); @@ -247,7 +251,7 @@ export async function runMotionCli(cfg, flags = parseFlags()) { "-y", "-i", picked, ...(excess > 0.01 ? ["-ss", excess.toFixed(3)] : []), "-t", s.exact.toFixed(3), - "-vf", "scale=1280:720,fps=24", "-an", + "-vf", `scale=${FW}:${FH},fps=24`, "-an", "-c:v", "libx264", "-preset", "medium", "-crf", "17", "-pix_fmt", "yuv420p", t, ], { stdio: ["ignore", "ignore", "pipe"] }); @@ -257,7 +261,7 @@ export async function runMotionCli(cfg, flags = parseFlags()) { console.log(` ⚠ ${s.name}: no take — Ken Burns fallback (${frames}f)`); res = spawnSync("ffmpeg", [ "-y", "-loop", "1", "-i", s.image, - "-vf", `scale=7680:-2,zoompan=z='1+0.10*on/${frames}':x='iw/2-(iw/zoom)/2':y='ih/2-(ih/zoom)/2':d=${frames}:s=1280x720:fps=24`, + "-vf", `scale=7680:-2,zoompan=z='1+0.10*on/${frames}':x='iw/2-(iw/zoom)/2':y='ih/2-(ih/zoom)/2':d=${frames}:s=${FW}x${FH}:fps=24`, "-frames:v", String(frames), "-an", "-c:v", "libx264", "-preset", "medium", "-crf", "17", "-pix_fmt", "yuv420p", t, diff --git a/pop/maytrax/bin/beat-lights.mjs b/pop/maytrax/bin/beat-lights.mjs index a141da5f9e..2bb436a160 100644 --- a/pop/maytrax/bin/beat-lights.mjs +++ b/pop/maytrax/bin/beat-lights.mjs @@ -41,6 +41,8 @@ for (let i = 2; i < process.argv.length; i++) { const FPS = 24; const W = 320, H = 180; // overlay resolution — soft light upscales fine 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 eventsPath = `${OUT}/${SLUG}.events.json`; if (!existsSync(eventsPath)) { @@ -48,7 +50,7 @@ if (!existsSync(eventsPath)) { process.exit(1); } const feed = JSON.parse(readFileSync(eventsPath, "utf8")); -const DUR = feed.seconds; +const DUR = Number(flags.dur ?? feed.seconds) ; const FRAMES = Math.ceil(DUR * FPS); // ── the rig ──────────────────────────────────────────────────────────────── @@ -110,7 +112,7 @@ const acc = new Float32Array(W * H * 3); function renderFrame(fi) { acc.fill(0); - const t = fi / FPS; + const t = fi / FPS + FROM; for (const e of perFrame[fi]) { const lamp = LAMPS[e.i]; const span = e.i === "riser" ? (e.dur || 0) : 0; @@ -175,11 +177,17 @@ if (!over) { 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}`); const mix = spawnSync("ffmpeg", [ "-hide_banner", "-loglevel", "error", "-y", "-i", over, "-i", overlayPath, "-filter_complex", - `[1:v]format=yuv420p[o];[0:v][o]blend=all_mode=screen:all_opacity=${OPACITY}[v]`, + `[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?", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", litPath, diff --git a/pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs b/pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs new file mode 100644 index 0000000000..6ae0714bbf --- /dev/null +++ b/pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// maytrax/bin/gen-motion-femrag-plusplus-reel.mjs — the VERTICAL cut of +// SHAKEOUT: 9:16 portrait, the first 60 seconds of femrag++, for reels. +// +// It is the same film, not a different one. The panels come from +// gen-sections-femrag-plusplus-shakeout.mjs --reel, which re-shoots each +// beat in portrait against its own landscape panel as a reference, so the +// room, the props, the lighting and the bunny are identical — only the +// camera's aspect changed. +// +// The reel is the track's first FIVE sections, which land on exactly 60.0s: +// drop1a POV on the laptop 0 – 13.3 +// drop1b game chase cam 13.3 – 26.7 +// breakdown through the window 26.7 – 40.0 +// buildup2 the wind-up 40.0 – 46.7 +// drop2a the big drop 46.7 – 60.0 +// Cutting on real section boundaries means the audio slice is a clean +// musical phrase rather than a fade at an arbitrary time. +// +// Its own motion dir + struct, so takes never collide with the long cut. +// +// Usage: +// node pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs --dry-run +// node pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs +// node pop/maytrax/bin/gen-motion-femrag-plusplus-reel.mjs --assemble + +import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { runMotionCli, parseFlags } from "../../lib/motion-pipeline.mjs"; +import { NEEDLE_FELT_WOOL_MOTION, FRAMING_IG_STORY_PORTRAIT_MOTION } from "../../lib/mediums.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const OUT = `${LANE}/out`; +const SLUG = "femrag-plusplus"; +const REEL_DIR = `${OUT}/reel`; +mkdirSync(REEL_DIR, { recursive: true }); + +const REEL_SECTIONS = ["drop1a", "drop1b", "breakdown", "buildup2", "drop2a"]; + +// 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. +const structSrc = `${OUT}/${SLUG}.struct.json`; +if (!existsSync(structSrc)) { + console.error(`✗ no film struct at ${structSrc} — render the track first`); + process.exit(1); +} +const full = JSON.parse(readFileSync(structSrc, "utf8")); +const picked = REEL_SECTIONS.map((n) => full.sections?.find((s) => s.name === n)).filter(Boolean); +if (picked.length !== REEL_SECTIONS.length) { + console.error("✗ reel sections missing from the film struct — re-render the track first"); + process.exit(1); +} +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)); + +// The audio: the same slice of the master, cut once and cached. +const reelAudio = `${REEL_DIR}/${SLUG}-reel.mp3`; +if (!existsSync(reelAudio)) { + const cut = spawnSync("ffmpeg", [ + "-hide_banner", "-loglevel", "error", "-y", + "-ss", String(t0), "-t", String(REEL_END - t0), "-i", `${OUT}/${SLUG}.mp3`, + "-af", "afade=t=out:st=" + (REEL_END - t0 - 1.2).toFixed(2) + ":d=1.2", + "-c:a", "libmp3lame", "-b:a", "320k", reelAudio, + ], { stdio: ["ignore", "inherit", "inherit"] }); + if (cut.status !== 0) { console.error("✗ audio slice failed"); process.exit(1); } + console.log(`✓ audio slice ${(REEL_END - t0).toFixed(1)}s → ${reelAudio.split("/").pop()}`); +} + +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.`, + `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, + NEEDLE_FELT_WOOL_MOTION, +].join("\n\n"); + +// 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: { + 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.`, + }, + drop1b: { + 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.`, + }, + breakdown: { + 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.`, + }, + 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" }, + ], + 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.`, + }, + drop2a: { + 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" }, + ], + 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.`, + }, +}; + +// Reel panels resolve by NAME, like the wide cut, so renumbering is harmless. +function panelFor(name) { + const suffix = `-${name}.png`; + const hit = readdirSync(OUT) + .filter((f) => f.startsWith(`${SLUG}-reel-sec-`) && f.endsWith(suffix)) + .sort(); + return hit.length ? `${OUT}/${hit[0]}` : `${OUT}/${SLUG}-reel-sec-?-${name}.png`; +} + +await runMotionCli({ + slug: `${SLUG}-reel`, + laneDir: LANE, + motionDir: `${REEL_DIR}/motion`, + structPath, + panelFor, + shots: SHOTS, + mediumMotion: MEDIUM_MOTION, + ratio: "9:16", + audio: reelAudio, + finalOut: `${OUT}/${SLUG}-shakeout-reel.mp4`, +}, parseFlags()); diff --git a/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs b/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs index 2e5de15ee6..dfd0f5e54c 100644 --- a/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs +++ b/pop/maytrax/bin/gen-sections-femrag-plusplus-shakeout.mjs @@ -34,7 +34,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, renameSync } from "node:fs"; import { resolve, dirname, basename, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { NEEDLE_FELT_WOOL, FRAMING_YT_LANDSCAPE } from "../../lib/mediums.mjs"; +import { NEEDLE_FELT_WOOL, FRAMING_YT_LANDSCAPE, FRAMING_IG_STORY_PORTRAIT } from "../../lib/mediums.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const LANE = resolve(HERE, ".."); @@ -42,7 +42,13 @@ const REPO = resolve(LANE, "..", ".."); const SLUG = "femrag-plusplus"; const OUT = `${LANE}/out`; const REJECTED = `${OUT}/rejected`; -const SIZE = "1536x1024"; // 16:9-ish landscape +// --reel makes the vertical (9:16) cut: the same film, its first five +// sections, which is exactly the first 60 seconds of the track. +// Its panels are generated against the LANDSCAPE panel of the same beat, so +// the reel borrows the illys rather than reinventing them. +const REEL = process.argv.includes("--reel"); +const REEL_BEATS = ["drop1a", "drop1b", "breakdown", "buildup2", "drop2a"]; +const SIZE = REEL ? "1024x1536" : "1536x1024"; const QA_MODEL = "gpt-5.5"; const QA_ATTEMPTS = 3; @@ -125,7 +131,8 @@ const POV_NOTE = `POV INTEGRITY — this is the bunny's OWN first-person viewpoint: his eyes ARE the camera. The only parts of him in frame are his own front PAWS and forearms in the near foreground, seen from behind as his own hands would be. ABSOLUTELY NO second bunny, no mirror, no reflection, no face — his head is never visible because the camera is inside it.`; const FRAME_NOTE = -`${FRAMING_YT_LANDSCAPE} +`${REEL ? FRAMING_IG_STORY_PORTRAIT : FRAMING_YT_LANDSCAPE} +${REEL ? `REFRAME FOR VERTICAL — a landscape frame of this exact same moment is supplied as a reference. Keep its world, its lighting, its props and its character identical, but RECOMPOSE it for a tall 9:16 portrait frame: stack the composition vertically, let the A-frame rafters rise up the tall frame, bring the bunny larger and more central, and keep the action in the middle band. This is the SAME room and the SAME instant, re-shot with a portrait camera — not a crop of the wide, and not a new scene.` : ``} HONOR THE SHOT DIRECTIVE in this beat exactly: a WIDE shot lets the whole room breathe with the bunny comfortably within it; a CLOSE-UP crops in tight and FILLS the frame with the detail (do NOT pull back to a wide); a LOW-ANGLE puts the camera down on the rug looking up.`; @@ -219,8 +226,17 @@ if (flags.list) { process.exit(0); } -const panelPath = (i, name) => `${OUT}/${SLUG}-yt-sec-${i}-${name}.png`; -const ANCHOR = panelPath(0, "runway"); +const panelPath = (i, name) => REEL + ? `${OUT}/${SLUG}-reel-sec-${i}-${name}.png` + : `${OUT}/${SLUG}-yt-sec-${i}-${name}.png`; +// The anchor is always the landscape one — it defines the character. +const ANCHOR = `${OUT}/${SLUG}-yt-sec-0-runway.png`; +// The landscape panel of the same beat, handed to the reel as its source. +const landscapeOf = (name) => { + const suffix = `-${name}.png`; + const hit = readdirSync(OUT).filter((f) => f.startsWith(`${SLUG}-yt-sec-`) && f.endsWith(suffix)).sort(); + return hit.length ? `${OUT}/${hit[0]}` : null; +}; // @jeffrey's old Ashland A-frame — the room is modeled on this contact sheet // (exposed rafters meeting in a peak, honey plank walls and floor, gable // window, round green rug, string lights already strung along the beams). @@ -360,9 +376,11 @@ async function generate(beat, i) { // The Ashland sheet carries the ROOM (A-frame geometry, plank wood, gable // window); the anchor carries the BUNNY. The anchor itself is generated // against Ashland alone, since it is what defines the character. + const wide = REEL ? landscapeOf(beat.name) : null; const refs = [ existsSync(ASHLAND) ? ASHLAND : null, - i > 0 && existsSync(ANCHOR) ? ANCHOR : null, + wide, // reel: this exact moment, wide + (REEL || i > 0) && existsSync(ANCHOR) ? ANCHOR : null, beat.screen && existsSync(NOTEPAT) ? NOTEPAT : null, ].filter(Boolean); const useEdit = refs.length > 0; @@ -390,9 +408,17 @@ async function generate(beat, i) { } // The anchor must exist (and go first) before any ref'd beat runs. -const todo = BEATS.map((b, i) => ({ b, i })).filter(({ b }) => !ONLY || ONLY.includes(b.name)); -const needsAnchor = todo.some(({ i }) => i > 0) && !existsSync(ANCHOR); -if (needsAnchor && !todo.some(({ i }) => i === 0)) todo.unshift({ b: BEATS[0], i: 0 }); +const todo = REEL + ? REEL_BEATS.map((n, i) => ({ b: BEATS.find((x) => x.name === n), i })) + .filter(({ b }) => b && (!ONLY || ONLY.includes(b.name))) + : BEATS.map((b, i) => ({ b, i })).filter(({ b }) => !ONLY || ONLY.includes(b.name)); +if (!REEL) { + const needsAnchor = todo.some(({ i }) => i > 0) && !existsSync(ANCHOR); + if (needsAnchor && !todo.some(({ i }) => i === 0)) todo.unshift({ b: BEATS[0], i: 0 }); +} else if (!existsSync(ANCHOR)) { + console.error("✗ --reel needs the landscape anchor first (run without --reel)"); + process.exit(1); +} let failed = 0; for (const { b, i } of todo) if (!(await generate(b, i))) failed++; -- 2.54.0