diff --git a/pop/bin/codify-sections.mjs b/pop/bin/codify-sections.mjs new file mode 100644 index 000000000..0c30b8494 --- /dev/null +++ b/pop/bin/codify-sections.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// pop/bin/codify-sections.mjs — add lowercase letter codes (a..z) to +// every section in every released pop struct.json AND its matching AC +// piece manifest at system/public/aesthetic.computer/disks/pop/.json. +// +// Idempotent — re-running produces no diff. Run after a new track lands +// or after a struct's sections list changes. +// +// Usage: +// node pop/bin/codify-sections.mjs +// node pop/bin/codify-sections.mjs --dry-run +// +// Why: lets the AC player jump to a named section via prompt syntax — +// `marimbaba c` → section index 2 (letter "c") +// `hellsine:r` → section index 17 (letter "r") +// The letter shows up in the side stamp + on each illy thumb so users +// can discover the codes. + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../.."); + +const DRY = process.argv.includes("--dry-run"); +const LETTERS = "abcdefghijklmnopqrstuvwxyz"; + +// (struct path, manifest path) pairs. Either or both can be missing — +// the script skips missing entries with a warning so the rest still run. +const TRACKS = [ + { + slug: "marimbaba", + struct: "pop/marimba/out/marimbaba.struct.json", + manifest: "system/public/aesthetic.computer/disks/pop/marimbaba.json", + }, + { + slug: "helpabeach", + struct: "pop/chillwave/out/helpabeach.struct.json", + manifest: "system/public/aesthetic.computer/disks/pop/helpabeach.json", + }, + { + slug: "trancenwaltz", + struct: "pop/dance/out/trancenwaltz.assets/struct.json", + manifest: "system/public/aesthetic.computer/disks/pop/trancenwaltz.json", + }, + { + slug: "trancepenta", + struct: null, // no struct.json yet — manifest only + manifest: "system/public/aesthetic.computer/disks/pop/trancepenta.json", + }, + { + slug: "hellsine", + struct: "pop/hellsine/hellsine.struct.json", + manifest: "system/public/aesthetic.computer/disks/pop/hellsine.json", + }, + { + slug: "solafiya", + struct: "pop/jungle/out/solafiya.struct.json", + manifest: "system/public/aesthetic.computer/disks/pop/solafiya.json", + }, +]; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, data) { + // 2-space indent + trailing newline matches the existing pop struct style. + writeFileSync(path, JSON.stringify(data, null, 2) + "\n"); +} + +function codifySections(sections, label) { + if (!Array.isArray(sections)) { + console.warn(` ⚠ ${label}: sections is not an array, skipping`); + return { changed: 0, total: 0 }; + } + if (sections.length > LETTERS.length) { + throw new Error( + `${label}: ${sections.length} sections exceeds single-letter ceiling (${LETTERS.length}). ` + + `Extend to two-letter codes (aa, ab, …) before running again.` + ); + } + let changed = 0; + for (let i = 0; i < sections.length; i++) { + const want = LETTERS[i]; + if (sections[i].code !== want) { + sections[i].code = want; + changed++; + } + } + return { changed, total: sections.length }; +} + +function processFile(absPath, label) { + if (!absPath) return { skipped: true }; + if (!existsSync(absPath)) { + console.warn(` ⚠ ${label}: not found at ${absPath.replace(REPO + "/", "")}`); + return { skipped: true }; + } + const data = readJson(absPath); + const sections = data.sections; + const { changed, total } = codifySections(sections, label); + if (changed > 0 && !DRY) writeJson(absPath, data); + return { changed, total, path: absPath }; +} + +function main() { + console.log(DRY ? "▸ codify-sections (DRY RUN)" : "▸ codify-sections"); + let totalChanged = 0; + let totalChecked = 0; + for (const t of TRACKS) { + console.log(`\n· ${t.slug}`); + const struct = processFile(t.struct && resolve(REPO, t.struct), `${t.slug} struct`); + const manifest = processFile(t.manifest && resolve(REPO, t.manifest), `${t.slug} manifest`); + + // Sanity check: if both exist their section counts should match. + if (!struct.skipped && !manifest.skipped && struct.total !== manifest.total) { + console.warn( + ` ⚠ ${t.slug}: struct has ${struct.total} sections but manifest has ${manifest.total}. ` + + `Codes may go out of sync — reconcile and re-run.` + ); + } + if (!struct.skipped) { + console.log(` struct · ${struct.total} sections · ${struct.changed} updated → ${LETTERS.slice(0, struct.total)}`); + totalChecked += struct.total; totalChanged += struct.changed; + } + if (!manifest.skipped) { + console.log(` manifest · ${manifest.total} sections · ${manifest.changed} updated → ${LETTERS.slice(0, manifest.total)}`); + totalChecked += manifest.total; totalChanged += manifest.changed; + } + } + console.log(`\n✓ codified ${totalChanged}/${totalChecked} sections${DRY ? " (no writes — dry run)" : ""}`); +} + +main(); diff --git a/pop/bin/crisp-pixel-sections.mjs b/pop/bin/crisp-pixel-sections.mjs new file mode 100644 index 000000000..6e71c93c4 --- /dev/null +++ b/pop/bin/crisp-pixel-sections.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// pop/bin/crisp-pixel-sections.mjs — downscale + crisp the raw gpt-image-2 +// pixel-art outputs into a tight true-pixel grid. +// +// Input: pop//out/-p-sec--.pixel-raw.png +// Output: pop//out/-p-sec--.pixel.png +// (and mirrored to system/public/assets/pop//sec-.pixel.png) +// +// Uses ImageMagick (`magick`) with `-filter Box -resize WxH!` to do a clean +// nearest-neighbor-like downscale that snaps every pixel to the new grid +// (no smoothing). Optionally quantizes to a small indexed palette via +// `-colors 32 -dither None` for the indexed-look crisp. +// +// Usage: +// node pop/bin/crisp-pixel-sections.mjs --lane hellsine --slug hellsine +// node pop/bin/crisp-pixel-sections.mjs --lane hellsine --slug hellsine --force +// node pop/bin/crisp-pixel-sections.mjs --lane hellsine --slug hellsine --size 192x288 +// node pop/bin/crisp-pixel-sections.mjs --lane hellsine --slug hellsine --colors 32 + +import { readdirSync, existsSync, mkdirSync, statSync, copyFileSync } from "node:fs"; +import { resolve, dirname, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const POP = resolve(HERE, ".."); +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 LANE = flags.lane; +const SLUG = flags.slug; +const FORCE = flags.force === true; +const SIZE = typeof flags.size === "string" ? flags.size : "192x288"; +const COLORS = typeof flags.colors === "string" ? parseInt(flags.colors, 10) : 0; + +if (!LANE || !SLUG) { + console.error("usage: node pop/bin/crisp-pixel-sections.mjs --lane --slug [--force] [--size 192x288] [--colors 32]"); + process.exit(1); +} + +const SRC_DIR = `${POP}/${LANE}/out`; +const ASSET_DIR = `${REPO}/system/public/assets/pop/${SLUG}`; + +if (!existsSync(SRC_DIR)) { + console.error(`✗ source dir missing: ${SRC_DIR}`); + process.exit(1); +} +mkdirSync(ASSET_DIR, { recursive: true }); + +// Discover *.pixel-raw.png files matching -p-sec-NN-.pixel-raw.png +const pattern = new RegExp(`^${SLUG}-p-sec-(\\d+)-([a-z0-9-]+)\\.pixel-raw\\.png$`, "i"); +const raws = readdirSync(SRC_DIR) + .filter((f) => pattern.test(f)) + .sort(); + +if (raws.length === 0) { + console.error(`✗ no pixel-raw files matching ${SLUG}-p-sec-NN-.pixel-raw.png in ${SRC_DIR}`); + process.exit(1); +} + +console.log(`▸ crisping ${raws.length} panel(s) · ${SIZE}${COLORS ? ` · ${COLORS} colors` : ""}`); + +let processed = 0, cached = 0; +for (const raw of raws) { + const m = raw.match(pattern); + const padIdx = m[1]; + const id = m[2]; + const src = `${SRC_DIR}/${raw}`; + const out = `${SRC_DIR}/${SLUG}-p-sec-${padIdx}-${id}.pixel.png`; + // Asset mirror — strip the leading zero for the human-friendly index. + const secN = parseInt(padIdx, 10); + const mirror = `${ASSET_DIR}/sec-${secN}.pixel.png`; + + const needs = FORCE || !existsSync(out) || statSync(src).mtimeMs > statSync(out).mtimeMs; + + if (needs) { + const args = [src, "-filter", "Box", "-resize", `${SIZE}!`]; + if (COLORS > 0) args.push("-colors", String(COLORS), "-dither", "None"); + args.push(out); + execFileSync("magick", args, { stdio: "inherit" }); + processed++; + console.log(`✓ ${raw} → ${basename(out)}`); + } else { + cached++; + console.log(`✓ cached → ${basename(out)}`); + } + + // Mirror to system/public/assets/pop// + const mirrorNeeds = FORCE || !existsSync(mirror) || statSync(out).mtimeMs > statSync(mirror).mtimeMs; + if (mirrorNeeds) { + copyFileSync(out, mirror); + console.log(` → assets/pop/${SLUG}/${basename(mirror)}`); + } +} + +console.log(`\n✓ crisped ${processed} new, ${cached} cached · ${SIZE}${COLORS ? ` · ${COLORS} colors` : ""}`); +console.log(` out: ${SRC_DIR}/${SLUG}-p-sec-NN-.pixel.png`); +console.log(` mirror: ${ASSET_DIR}/sec-N.pixel.png`); diff --git a/pop/bin/gen-pixel-sections.mjs b/pop/bin/gen-pixel-sections.mjs new file mode 100755 index 000000000..a17b3a54c --- /dev/null +++ b/pop/bin/gen-pixel-sections.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +// gen-pixel-sections.mjs — deterministic pixel-art pass over pop panels. +// +// Down-samples each per-section illy PNG to a small canvas (default +// 192×288) and re-maps it to a 24-colour AC-native palette with +// Floyd–Steinberg dither. Outputs land beside the source as +// `-p-sec--.pixel.png` and are mirrored into +// `system/public/assets/pop//sec-.pixel.png` so the asset +// pipeline (`npm run pop:assets:up`) can sync them to DigitalOcean. +// +// Why deterministic (not AI re-gen): the photo-real panels are locked, +// on-model, and ship continuity (jeffrey's gear, butterfly, PALS arc). +// ImageMagick is already a repo dep, runs offline, costs nothing, and +// the output is byte-reproducible from the same source + same palette. +// +// Usage: +// node pop/bin/gen-pixel-sections.mjs --lane hellsine --slug hellsine +// node pop/bin/gen-pixel-sections.mjs --lane marimba --slug marimbaba --force +// node pop/bin/gen-pixel-sections.mjs --lane hellsine --slug hellsine --only 0,1,2 +// node pop/bin/gen-pixel-sections.mjs --lane hellsine --slug hellsine --write-manifest + +import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname, basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const POP = resolve(HERE, ".."); +const REPO = resolve(POP, ".."); + +// ── arg parsing ────────────────────────────────────────────────────── +const args = {}; +{ + const a = process.argv.slice(2); + for (let i = 0; i < a.length; i++) { + const k = a[i]; + if (!k.startsWith("--")) continue; + const key = k.slice(2); + const v = (a[i + 1] && !a[i + 1].startsWith("--")) ? a[++i] : "true"; + args[key] = v; + } +} +const LANE = args.lane; +const SLUG = args.slug; +const FORCE = args.force === "true"; +const WRITE_MANIFEST = args["write-manifest"] === "true"; +const ONLY = args.only ? new Set(args.only.split(",").map(s => s.trim())) : null; +const SIZE = args.size || "192x288"; +const PALETTE = args.palette + ? resolve(args.palette) + : `${POP}/lib/pixel-art/ac24.png`; + +if (!LANE || !SLUG) { + console.error("usage: node pop/bin/gen-pixel-sections.mjs --lane --slug [--force] [--only 0,1] [--palette ] [--size 192x288] [--write-manifest]"); + process.exit(1); +} + +const OUT = `${POP}/${LANE}/out`; +const MIRROR = `${REPO}/system/public/assets/pop/${SLUG}`; + +if (!existsSync(OUT)) { + console.error(`✗ no out dir for lane '${LANE}': ${OUT}`); + process.exit(1); +} + +// ── 24-colour AC-native palette ────────────────────────────────────── +// Mixes: AC UI cream/coral/sage, hellsine lava arc, PALS rainbow, and +// 8 neutrals. Logic per slot is documented inline so the palette can be +// retuned by editing AC24_HEX in one place. +const AC24_HEX = [ + // AC UI cream / coral / sage (4) + "#fff8e8", // cream — AC default page bg + "#ffd6c2", // coral pink — AC accent + "#ff7a5a", // hot coral — AC button hover + "#9ac2a8", // sage — AC chip + // hellsine lava arc — warm fire (5) + "#ffb24a", // sulphur-yellow / amber kindling + "#ff8a3c", // lava orange (TITLE_PALETTE mid) + "#ff6a1f", // deep lava (TITLE_PALETTE low) + "#dc4834", // red-alarm flare (statement-a) + "#7a2a18", // obsidian-ember dark warm + // hellsine cool / cosmic (4) + "#3c4876", // dusk indigo (overture-a) + "#7452a8", // electric violet (bridge-a) + "#4c8cc8", // cyan plunge (bridge-c) + "#60a8c4", // pale aqua (bridge-d) + // PALS rainbow (7) + "#22e0ff", // PALS cyan + "#ff3cb8", // PALS magenta / hot-pink + "#b8ff3c", // PALS lime + "#ffc83c", // PALS gold + "#ff7a1f", // PALS electric-orange + "#6a1fff", // PALS deep-violet + "#1f1fff", // PALS electric-blue + // 8 neutrals — black → near-white plus 3 warm darks (8 total slots, + // but we only have 4 left after 16 chroma; pack the warm darks + // through the chroma allocations above and keep these grey-only) + "#000000", // pure black + "#3a3a3e", // dark grey (warm-leaning) + "#8a8a8e", // mid grey + "#e2e2dc", // near-white (cream-leaning) +]; +if (AC24_HEX.length !== 24) { + console.error(`✗ palette must be 24 colours, got ${AC24_HEX.length}`); + process.exit(1); +} + +function generatePalette() { + mkdirSync(dirname(PALETTE), { recursive: true }); + // Build a 24x1 indexed PNG by drawing one 1px column per colour. + // `magick -size 24x1 xc:none` then `-draw "fill #xxx point X,0"` 24x. + const drawCmds = AC24_HEX.flatMap((hex, i) => + ["-fill", hex, "-draw", `point ${i},0`] + ); + const argv = [ + "-size", "24x1", "xc:none", + ...drawCmds, + "PNG8:" + PALETTE, + ]; + const r = spawnSync("magick", argv, { stdio: ["ignore", "pipe", "inherit"] }); + if (r.status !== 0) { + console.error("✗ magick palette generation failed"); + process.exit(1); + } + console.log(`▸ wrote palette → ${PALETTE.replace(REPO + "/", "")} (24 colours)`); +} + +if (!existsSync(PALETTE) || FORCE) { + generatePalette(); +} else { + console.log(`▸ using existing palette ${PALETTE.replace(REPO + "/", "")}`); +} + +// ── source discovery ──────────────────────────────────────────────── +// Match both `-sec-0-` (single-digit, marimba/dance) and `-sec-00-` +// (two-digit padded, hellsine) using a single regex. +const SEC_RE = new RegExp(`^${SLUG}-p-sec-(\\d{1,2})-([^.]+?)\\.png$`); +const allNames = readdirSync(OUT); +const sources = []; +for (const n of allNames) { + const m = n.match(SEC_RE); + if (!m) continue; + const idx = parseInt(m[1], 10); + const id = m[2]; + if (ONLY && !ONLY.has(String(idx))) continue; + sources.push({ name: n, idx, id, pad: m[1].length }); +} +sources.sort((a, b) => a.idx - b.idx); + +if (sources.length === 0) { + console.error(`✗ no panels matched ${SLUG}-p-sec-NN-id.png in ${OUT}`); + process.exit(1); +} + +console.log(`▸ ${sources.length} source panel(s) found in ${OUT.replace(REPO + "/", "")}`); +mkdirSync(MIRROR, { recursive: true }); + +// ── optional progress heartbeat ───────────────────────────────────── +let progress = null; +try { + progress = await import("../lib/render-progress.mjs"); + progress.begin({ type: "illy", label: `${SLUG} pixel` }); +} catch { /* optional */ } + +// ── per-panel pass ────────────────────────────────────────────────── +const t0 = Date.now(); +let made = 0, skipped = 0, mirrored = 0; +const sizes = []; + +for (let i = 0; i < sources.length; i++) { + const { name, idx, id } = sources[i]; + const src = `${OUT}/${name}`; + // Output name preserves the original two-digit (or one-digit) form + // so it sorts the same way as the source. + const padded = String(idx).padStart(sources[0].pad, "0"); + const outName = `${SLUG}-p-sec-${padded}-${id}.pixel.png`; + const out = `${OUT}/${outName}`; + const mirrorOut = `${MIRROR}/sec-${idx}.pixel.png`; + + const srcStat = statSync(src); + const upToDate = + !FORCE && + existsSync(out) && existsSync(mirrorOut) && + statSync(out).mtimeMs >= srcStat.mtimeMs && + statSync(mirrorOut).mtimeMs >= srcStat.mtimeMs; + + if (upToDate) { + skipped++; + sizes.push(statSync(out).size); + console.log(` · sec-${idx} ${id} (cached)`); + } else { + // Down-sample → Floyd–Steinberg → remap to 24-colour palette. + // -filter Box: blocky downsample, retains pixel-art look at low res + // PNG8: : forces indexed-colour PNG, the smallest encoding + const argv = [ + src, + "-filter", "Box", + "-resize", `${SIZE}!`, + "-dither", "FloydSteinberg", + "-remap", PALETTE, + "PNG8:" + out, + ]; + const r = spawnSync("magick", argv, { stdio: ["ignore", "pipe", "inherit"] }); + if (r.status !== 0) { + console.error(`✗ magick failed on ${name}`); + process.exit(1); + } + copyFileSync(out, mirrorOut); + made++; + const kb = statSync(out).size; + sizes.push(kb); + console.log(` + sec-${idx} ${id} (${(kb / 1024).toFixed(1)} KB)`); + } + if (existsSync(mirrorOut)) mirrored++; + if (progress) progress.update(((i + 1) / sources.length) * 100, { done: i + 1, total: sources.length }); +} + +const elapsed = ((Date.now() - t0) / 1000).toFixed(1); +const avgKb = sizes.length ? (sizes.reduce((s, x) => s + x, 0) / sizes.length / 1024).toFixed(1) : "0"; +console.log(`✓ ${made} generated, ${skipped} cached, ${mirrored} mirrored · avg ${avgKb} KB · ${elapsed}s`); + +// ── optional manifest variant ─────────────────────────────────────── +if (WRITE_MANIFEST) { + const manifest = `${REPO}/system/public/aesthetic.computer/disks/pop/${SLUG}.json`; + if (!existsSync(manifest)) { + console.log(`▸ manifest skipped: ${manifest.replace(REPO + "/", "")} does not exist`); + } else { + const json = readFileSync(manifest, "utf8"); + // Regex-replace any /assets/pop//sec-N. illy field to .pixel.png + const replaced = json.replace( + new RegExp(`(/assets/pop/${SLUG}/sec-\\d+)\\.(jpg|jpeg|png)`, "g"), + "$1.pixel.png" + ); + const variant = manifest.replace(/\.json$/, ".pixel.json"); + writeFileSync(variant, replaced); + console.log(`▸ wrote manifest variant → ${variant.replace(REPO + "/", "")}`); + } +} + +if (progress) progress.end(); diff --git a/pop/hellsine/assets/whistlegraph-butterfly.png b/pop/hellsine/assets/whistlegraph-butterfly.png new file mode 100644 index 0000000000000000000000000000000000000000..32e20175f95de09f121252e33cbfd04df61cd0cb GIT binary patch literal 44377 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

B(NmtHGIswnjJ7gl95c$ZAy3fjwt?u@?e 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" + letter `code` a..r in order. +// +// Run AFTER hellsine.mjs --strategy ultimate + warp-struct-to-master.mjs +// to keep events + 18-beat structure aligned with the master audio. +// +// Usage: +// node pop/hellsine/bin/expand-sections-to-subbeats.mjs + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const STRUCT_PATH = resolve(HERE, "../hellsine.struct.json"); + +const SUB_BEATS = { + overture: ["a", "b", "c"], + statement: ["a", "b", "c"], + bridge: ["a", "b", "c", "d"], + develop: ["a", "b", "c"], + climax: ["a", "b", "c"], + coda: ["a", "b"], +}; +const CODE_ORDER = "abcdefghijklmnopqrstuvwxyz"; + +const s = JSON.parse(readFileSync(STRUCT_PATH, "utf8")); +if (s.subBeatsExpanded) { + console.log("✓ struct already expanded to 18 sub-beats — no-op"); + process.exit(0); +} + +const expanded = []; +let codeIdx = 0; +for (const sec of s.sections) { + const suffixes = SUB_BEATS[sec.name]; + if (!suffixes) { + console.warn(` ⚠ no sub-beat layout for "${sec.name}" — leaving as-is`); + expanded.push({ ...sec, code: CODE_ORDER[codeIdx++] }); + continue; + } + const span = sec.endSec - sec.startSec; + const step = span / suffixes.length; + for (let i = 0; i < suffixes.length; i++) { + const sStart = sec.startSec + i * step; + const sEnd = i === suffixes.length - 1 ? sec.endSec : sStart + step; + expanded.push({ + name: `${sec.name}-${suffixes[i]}`, + t: +sStart.toFixed(4), + startSec: +sStart.toFixed(4), + endSec: +sEnd.toFixed(4), + code: CODE_ORDER[codeIdx++], + }); + } +} + +if (expanded.length !== 18) { + console.warn(` ⚠ expected 18 sub-beats, got ${expanded.length} — verify SUB_BEATS map`); +} + +s.sections = expanded; +s.subBeatsExpanded = true; +writeFileSync(STRUCT_PATH, JSON.stringify(s, null, 2) + "\n"); +console.log(`✓ expanded ${expanded.length} sub-beats:`); +for (const e of expanded) console.log(` ${e.code} · ${e.name.padEnd(12)} · ${e.t.toFixed(2)}–${e.endSec.toFixed(2)}s`); +console.log(` ${STRUCT_PATH.replace(process.env.HOME, "~")}`); diff --git a/pop/hellsine/bin/gen-sections-pixel.mjs b/pop/hellsine/bin/gen-sections-pixel.mjs new file mode 100644 index 000000000..d1920753b --- /dev/null +++ b/pop/hellsine/bin/gen-sections-pixel.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node +// hellsine/bin/gen-sections-pixel.mjs — AI-generated 16-bit pixel-art +// reinterpretation of each hellsine storyline panel. Same beats + identity +// anchors + scene arcs as gen-sections.mjs, but the MEDIUM / PALETTE / +// AVOID blocks ask gpt-image-2 for HARD-EDGED limited-palette pixel art +// (Chrono Trigger / FFVI / Castlevania SOTN cutscene tableaux), NOT +// photo-real felt-craft. Post-process with pop/bin/crisp-pixel-sections.mjs +// to downscale + crisp the pixels (gpt-image-2 tends to anti-alias even +// when told not to). +// +// Output: pop/hellsine/out/hellsine-p-sec-NN-.pixel-raw.png +// (portrait 1024x1536 raw AI output — pass through the crisper) +// +// Usage: +// node pop/hellsine/bin/gen-sections-pixel.mjs # cached +// node pop/hellsine/bin/gen-sections-pixel.mjs --force # regen all +// node pop/hellsine/bin/gen-sections-pixel.mjs --only climax-a # one panel +// node pop/hellsine/bin/gen-sections-pixel.mjs --only bridge-a,bridge-b + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +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 = "1024x1536"; // portrait; will be downscaled by crisper +const TAG = "-p"; +mkdirSync(`${LANE}/out`, { recursive: true }); + +// ── identity refs (mirrors hellsine/bin/gen-sections.mjs) ──────────── +const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot-2k`; +const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`; +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`, + `${ARCHIVE_DIR}/2021-07-10_CRI095Vl7AO_1.jpg`, + `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`, + `${LANE}/assets/pals-logo.png`, + `${LANE}/assets/whistlegraph-butterfly.png`, +].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"); +} + +// ── PORTRAIT recomposition — tall 9:16 ─────────────────────────────── +const PORTRAIT_NOTE = +`PORTRAIT OVERRIDE — recompose this beat for a TALL vertical 9:16 frame (NOT square, NOT widescreen). the action arranged vertically: figures stacked / staggered, environment extending UP into smoke columns + sky and DOWN into lava + basalt foreground (or studio floor / cosmic-chute depth for non-hellscape beats). give visualizer chrome breathing room near the top + bottom edges. keep every other rule from the shared material law identical.`; + +// ── PIXEL-ART MEDIUM (replaces felt-craft MEDIUM) ─────────────────── +const MEDIUM = +`HAND-DRAWN 16-BIT PIXEL-ART TABLEAU — render this scene as TRUE PIXEL ART in the style of late-90s SNES / Genesis / PC-Engine JRPG cutscene + intro art (think: Chrono Trigger pre-rendered cutscenes, Final Fantasy VI portrait scenes, Castlevania Symphony of the Night intro paintings, Seiken Densetsu 3 cinematic panels, Lunar: Eternal Blue animated intros, Phantasy Star IV cinematic frames). HARD-EDGED PIXELS — every edge is a stepped pixel boundary, NEVER smoothed, NEVER anti-aliased, NEVER soft. CHUNKY PIXEL GRID visibly readable across the entire frame: the pixel cell size should be CLEARLY larger than the smallest possible mark — think effective resolution of roughly 192×288 to 256×384 BLOWN UP to fill the canvas, so each "pixel" reads as a fat square block. INDEXED COLOR palette only — 24 to 32 distinct flat colors total across the whole image, NO smooth gradients, NO airbrush blends, NO photographic texture. SHADING is done with HAND-PLACED DITHER PATTERNS (checkerboard, 50% dither, ordered Bayer dither) where you need to suggest a transition between two flat colors — NEVER smooth blending. CRISP 1-PIXEL OUTLINES around figures and key objects (single-pixel-wide dark line, hand-placed). FACES use the JRPG sprite-portrait language: large simplified eyes (2-4 pixels each), simplified hair shapes built from 3-4 flat color zones with dithered shading at the edges, mouth as a small 1-3 pixel stroke. CLOTHING is flat color zones with single-pixel highlights and dithered shadow patches. ENVIRONMENT is built from tiled-style basalt / wood / sky chunks, each a flat color with dither shading at the seams. NOTHING SOFT, NOTHING BLURRED, NOTHING PHOTO-REAL — this is FLAT 16-bit pixel-art reinterpretation of the same scene, drawn pixel-by-pixel by a human pixel artist in the SNES era. DROP the felt fibre / wool texture / stop-motion realism from any prior brief — those material rules are SUSPENDED for the pixel-art reinterpretation. The identity beats (jeffrey's face, outfit, gear, pixsies, laptops, world) remain — but rendered in chunky indexed pixels, NOT in felt and NOT in photography.`; + +const JEFFREY = +`PIXEL-ART JEFFREY — about 30, recognizable from the jeffrey reference photographs but RENDERED AS A 16-BIT SPRITE PORTRAIT: tousled medium-length brown hair drawn as 3-4 flat brown tones with dithered shading (mussed, loose strands suggested by single-pixel outline jags), CLEAN-SHAVEN or 1-pixel stubble shadow at most (NO full beard), pale-flesh skin as a flat warm-beige base with a single dithered cheek-pink patch + a 1-pixel nose shadow. Eyes are large simplified pixel-sprite eyes (2-4 px each). Peer-horizontal — never centred as a hero. + +JEFFREY'S CANONICAL OUTFIT + GEAR (mandatory — render legibly in pixel-art shorthand whenever jeffrey is in frame at medium-shot or closer; substituting any of these for sweaters, hoodies, t-shirts, tank tops, robes, jackets, or any other garment is FORBIDDEN): + · TOP — a PALE-BABY-BLUE BUTTON-DOWN SHIRT rendered as flat pale-blue pixels with a sharp pointed collar (drawn as a few angled pixels) and a vertical row of small 1-pixel BUTTONS down the front placket. NOT a sweater, NOT a hoodie, NOT a t-shirt. THIN DARKER-BLUE PIXEL PINSTRIPES run vertically down the fabric — single-pixel-wide vertical stripes spaced evenly across the chest + sleeves (clearly visible parallel pinstripe pattern in indexed colors). NEVER plain solid blue, NEVER pattern-free. + · CHEST EMBLEM — a SMALL YELLOW BEAR EMBLEM (a tiny pixel-art cartoon bear silhouette, golden-yellow, roughly 6-10 px square) hand-placed on the left chest of the button-down, just above the pocket. always present. + · PEN — a YELLOW SAILOR PRO GEAR FOUNTAIN PEN clipped at the shirt-pocket as a 2-3 pixel saturated-yellow vertical bar with a single grey 1-pixel clip. always visible whenever the shirt pocket is in frame. + · GLASSES — RED PLASTIC GLASSES (saturated-red rectangular pixel frames) DANGLING by one earpiece hooked through the button-down's placket between the second + third button. always at the placket unless he's actively wearing them on his face (rare). + · PANTS — WIDE-LEG MEDIUM-COBALT-BLUE TROUSERS (clearly darker + more saturated blue than the shirt, indexed as a distinct palette color), straight wide leg, no jeans, no shorts. + · HEADPHONES — when at his desk (overture / early statement only), large over-ear headphones in dark slate-grey pixels, worn HALF ON (one cup over the right ear, the other cup pushed back behind the left ear). otherwise off / absent. +These gear items are the SINGLE MOST IMPORTANT IDENTITY CUES alongside his face — render them legibly in pixel shorthand every time.`; + +const PIXSIES = +`PIXEL-ART PIXSIES — 4-7 humanoid grad-students rendered as 16-bit sprite portraits, a real spread of AGES (kid → elder) and a wide RACE + GENDER spectrum (women, men, boys, girls, femme, masc, androgynous; many ethnicities). ROUNDED HUMAN EARS ONLY — never pointed / elf / fae. each sprite has flat-color skin (warm-beige / olive / deep-brown / pale-pink etc. indexed), 3-4 flat hair tones, 1-pixel outlines, simplified sprite eyes (2-4 px). OUTFITS a mixed-up pastiche in pixel-art shorthand — some militaristic-tactical (flat olive + grey patches with small 1-pixel buckles), some super-cute girly (flat pink + lilac flat zones with dithered ruffle hints), some cyberpunk-techwear (flat black + neon-cyan single-pixel accents), some cardigan (flat warm-brown with single-pixel button row), all clashed eclectic the way grad-student wardrobes are. uncanny tells (1-pixel cyan-green LED beads at temple / ear / eye-edge) optional + subtle. each holds (when present) ONE small AC PALS laptop in one hand: rendered as a flat-color rectangular sprite (grey body + colored lid) with the PALS glyph (two-bubbly-people Keith-Haring linked outline from the pals-logo.png reference) painted on the lid in a unique INDEXED hue — cyan / magenta / lime-green / hot-pink / golden-yellow / electric-orange / deep-violet. NEVER apple, NEVER butterfly on pixsie lids.`; + +const JEFFREY_LAPTOP = +`JEFFREY'S MACBOOK NEO — when shown, a CITRUS-GREEN pixel-art laptop with a TORN WHITE-PAPER SCRAP visible on the lid where the apple logo would be. On the scrap is the WHISTLEGRAPH BUTTERFLY DOODLE rendered in pixel art. + +★ CRITICAL — THE DOODLE'S COMPOSITION MUST MATCH the attached whistlegraph-butterfly.png reference image (see the ref file in the input set). Reproduce the doodle's shape, proportions, wing curves, head shape, leg position, and stroke weight in pixel-art form — a chunky pixel-grid translation of that exact reference, NOT improvisation or stylistic reinterpretation. + +Reference description (the PNG is authoritative): a wonky child-marker doodle drawn in a SINGLE THICK MEDIUM-GREY MARKER LINE on a slightly off-white paper scrap, with NO interior fill and NO color. The doodle is a SMILING STICK-FIGURE WITH BUTTERFLY WINGS composed of: + (1) a small slightly-tilted RECTANGULAR HEAD on top, with TWO small dot EYES and a SHORT CURVED SMILE inside the rectangle; + (2) a tall thin VERTICAL RECTANGULAR BODY descending straight down from the head; + (3) FOUR LARGE ROUNDED BUTTERFLY-WING LOBES growing directly out of the SIDES of the body — TWO on the LEFT (an upper-left lobe + a lower-left lobe joined at the body's midline) and TWO on the RIGHT (an upper-right lobe + a lower-right lobe joined at the body's midline). Each lobe is a soft rounded blob shape with a single curved interior crease line suggesting a wing fold; + (4) TWO SHORT STUBBY RECTANGULAR LEGS side-by-side below the body. + +The figure is LEFT-RIGHT SYMMETRIC. Render it as a thick 2-3-pixel medium-grey stroke on an off-white paper-scrap rectangle, the embossed apple peeking around its torn pixel-edges. + +This doodle is on JEFFREY'S green laptop ONLY — NEVER on a pixsie's laptop (pixsie lids carry the PALS glyph ONLY). The laptop is rendered as flat-color pixel shapes with single-pixel highlights — NO smooth plastic reflections, NO photographic gloss.`; + +const WORLD_LAW = +`WORLD LAW — the SETTING varies by beat, but always rendered in 16-bit pixel-art tileset style: +• EARTH PANELS (overture-*, statement-a, statement-b) = jeffrey's studio zollo as pixel-art interior: warm amber desk-lamp pool (flat amber + dithered halo), WOODEN-PLANK floor (rows of 8-12 px brown planks with single-pixel seam lines + small dither knots), a small pixel-art plant in a pot, a pixel-art AC poster on the wall, a star-flecked indexed-blue night sky outside the windowpane. +• STUDIO-HOLE PANELS (statement-c, bridge-a, bridge-b, bridge-c) = the SAME pixel-art studio, but now with a PERFECT ROUND HOLE burned cleanly through the wooden floor — circular cut rendered as a stepped pixel circle, edges as a 1-pixel ember-red glow, charred + dithered-black smoking wood at the rim, dithered heat-shimmer pixels rolling up. DOWN INSIDE the hole: HELL clearly visible far below — sinusoidal LAVA RIVERS as orange + yellow flat pixel zones with dithered seams, basalt as dark-grey tiles, bruise-purple sky. warm orange hell-glow paints the studio with dithered orange pixels on the wood; cool desk-lamp + cold drone targeting beams paint from above. small soap-bubble pixel circles drifting up. +• TRANSITION (bridge-d) = mid-fall THROUGH the hole — the studio doorway + ceiling shrinking away above, basalt + lava rushing up below, the crew cannonballing as one pixel-sprite cluster. NO long cosmic chute. +• HELLSCAPE PANELS (develop-*, climax-*, coda-*) = pixel-art alien volcanic landscape — cracked CHARRED basalt foreground as tiled dark-grey + soot-yellow flat zones with dithered cracks, sinusoidal LAVA RIVERS curving across the ground as orange + yellow pixel sine waves (the lava itself runs in clean math sine waves, stepped to the pixel grid), tall walls of sine-shaped FLAME drawn as stacked orange + yellow pixel tongues, jagged obsidian spires receding into a bruise-purple horizon, dense atmospheric particulate (orange ember pixels + yellow spark pixels, white ash pixels, soap-bubble pixel circles, smoke columns as dithered grey, multi-hue matrix-rain as single-pixel-wide vertical streaks in yellow / red / purple / lime), heat-shimmer pixels above the lava. A SINGLE PURE-WHITE HORSE flames across the mid-distance basalt with red flames licking its pixel hooves + body. A SLEEK BULLET TRAIN streaks the far horizon as a pixel-sprite with lit-up windows + motion-streak lines. THE SINES ARE THE FIRE — every lava river + flame tongue traces a perfect pixel-stepped sine wave. NO sine beams from anyone's mouth. NOTE: develop-a + develop-b feature the crew IN A LAVA POOL — the lava acts as warm magical liquid; sprites darken at the waterline + char-shade but bodies stay buoyant + joyful.`; + +const ARCS = +`CONTINUITY ARCS across the whole 18-panel set (the beat description below names this beat's stage): +• EYES: dim (dark sprite eyes) → shock-wide at the strike → wide-awe peering into the hole → bright-call mid-facetime → bright-greeting at the bust-in → grouped-wonder around the hole → mid-whoop cannonballing → mid-whoop splashing → laughing while swimming → IGNITION (fire-pixel ignites in sockets in a chain) → blazing red-orange flame-pixels in sockets (no whites) for the party → ember-pixels (soft glow, no flames) at dawn. +• CLOTHES: clean through overture → clean through statement → clean as he peers in the hole → clean through facetime + bust-in + cannonball → dithered-dark at the waterline on splashdown → wet + first char-pixels from swimming → first scorch-pixels + frayed edges at ignition → fully tattered (jagged pixel edges) + char-marked at the party → tattered but settled by dawn. +• PALS LIDS: jeffrey's MacBook Neo BUTTERFLY-scrap visible in overture, statement, coda; pixsie laptops APPEAR for the first time in bridge-b (each pixsie carries one when bursting in) but PALS lids are DARK until ignition; PALS FLICKER on during the swim (one or two glowing in their indexed hue) → full seven-hue glow at the party → soft pulse at dawn. +• FINGERTIPS: clean through earth + studio-hole + cannonball + splashdown + swim → tiny pixel flames at tips + scorch + ember-cracks on palms at ignition + party → embers at dawn.`; + +// ── PIXEL-ART PALETTE (replaces the felt-craft PALETTE) ────────────── +const PALETTE = +`PALETTE — strictly LIMITED INDEXED 16-bit-style palette of 24-32 distinct flat colors per panel (NO smooth gradients between hues, NO airbrush blends; tonal transitions happen through DITHER PATTERNS only). Per arc-stage: +• EARTH PANELS (overture-*, statement-a, statement-b) = 8-12 indexed colors: warm amber desk-lamp pool, cool deep-blue night, pale-baby-blue + medium-cobalt for jeffrey, soft browns for the wood + hair, single bright accent for the bear emblem + pen. +• STUDIO-HOLE PANELS (statement-c, bridge-a, bridge-b, bridge-c) = 10-14 indexed colors: the earth palette PLUS a warm ember-red + orange + dithered-yellow for the hole's hellglow, bruise-purple for the deep, soot-black for the char. +• TRANSITION (bridge-d) = 10-14 indexed colors: studio doorway light from above, lava orange + yellow + ember-red rushing up from below, basalt grey. +• HELLSCAPE PANELS (develop-*, climax-*, coda-*) = 12-16 indexed colors: dominant warm lava-orange + sulphur-yellow + ember-red as the principal light, deep crimson sky, bruise-purple horizon, obsidian-black + dark-grey for basalt, single-pixel cyan / magenta / lime / hot-pink / gold / orange / violet accents for PALS lids, single-pixel white + yellow for sparks + ash. Dawn coda panels swap the crimson for a coral-pink + warm peach. +NO smooth gradient ramps. ALL tonal transitions handled with hand-placed dither patterns (checkerboard, 50%, ordered Bayer).`; + +const DEVICE_ENGAGEMENT = +`DEVICE ENGAGEMENT — whenever jeffrey or any pixsie is holding a laptop, phone, or other device, the figure's HEAD AND GAZE MUST BE ON THE SCREEN (eyes down/toward the display, head tilted into the work, body posture absorbed in using the thing). NEVER the presentational "showing the laptop to the camera" pose where the device is turned sideways so its screen / lid faces the lens. The screen faces the user, not the viewer. If a lid (back of the screen) is in frame, it's because the figure is using the device naturally — not because they're modelling it.`; + +// ── PIXEL-ART AVOID (replaces felt-craft AVOID) ────────────────────── +const AVOID = +`AVOID (pixel-art specific) — NO anti-aliasing of any kind (every edge is a hard stepped pixel boundary, NEVER blurred or smoothed), NO photo-real texture or surface, NO soft blur, NO airbrush gradients, NO smooth color blends (use DITHER PATTERNS instead), NO film grain, NO depth-of-field blur, NO 3D rendering tells (no rendered shadows, no ambient occlusion, no specular highlights — only hand-placed pixel highlights + dithered shadow patches), NO felt fibre / wool / fabric texture (this is the pixel-art reinterpretation, NOT the felt-craft set), NO photographic photo backdrops behind the figures (the whole world is pixel-art tiles), NO modern flat vector-illustration look (this is JRPG sprite art, NOT Figma flat-design), NO cartoon / plush / collaged look, NO apple logos or butterfly glyphs on pixsie lids (PALS only on pixsie lids, butterfly only on jeffrey's MacBook Neo lid scrap), NO sine beams pouring from anyone's mouth (the sines ARE the lava + fire itself), NO blood / gore / body horror (heat damage = pixel scorch + jagged frayed edges, not gore), NO readable text / wordmark / logo anywhere, NO pointed / elf / fae ears on pixsies (rounded human ears only), NO jeffrey centred as a hero (peer-horizontal), NO recursive screens, NO living-artist names, NO motion blur, NO presentational "showing the laptop to camera" poses.`; + +// ── per-beat story — 18 panels (verbatim from gen-sections.mjs) ────── +const SECTION_ORDER = [ + "overture-a", "overture-b", "overture-c", + "statement-a", "statement-b", "statement-c", + "bridge-a", "bridge-b", "bridge-c", "bridge-d", + "develop-a", "develop-b", "develop-c", + "climax-a", "climax-b", "climax-c", + "coda-a", "coda-b", +]; + +const SECTION_VARIANTS = { + "overture-a": +`BEAT — OVERTURE A "before the keystroke" · EMOTION: focused calm, the hum before everything. ARC STAGE — eyes dim (no fire), clothes clean, NO pixsies present, PALS lids dark. EARTH. studio zollo, late. + +★ LAPTOP STATE (mandatory): The MacBook Neo is CLOSED — lid flush down on top of the keyboard base. We see the GREEN OUTSIDE-OF-LID facing UP toward camera, the TORN WHITE-PAPER SCRAP with the WHISTLEGRAPH BUTTERFLY DOODLE taped in the centre of that green top panel (where the apple logo would be). NO LCD visible anywhere, no screen content, no screen glow — the laptop is shut. The butterfly scrap ONLY ever lives on the OUTSIDE-OF-LID, never on the LCD side and never on the base; the laptop being closed makes this unambiguous. + +CAMERA: positioned OUTSIDE the window LOOKING IN through the night-time glass. We see jeffrey from OUTSIDE in three-quarter profile: he's seated at his desk inside the warmly-lit studio, his TORSO + ONE SHOULDER + the side of his face turned slightly toward camera; one hand resting gently ON TOP of the closed laptop's green lid, the other hand reaching for a small toy bunny / honey jar / pennies on the desk. Headphones half-on around his neck/ears. + +He wears the canonical pale-baby-blue BUTTON-DOWN with darker-blue PINSTRIPES + yellow BEAR EMBLEM at the chest + yellow Sailor Pro Gear PEN clipped at the pocket + RED GLASSES dangling at the placket + wide-leg medium-cobalt TROUSERS + dark slate-grey HEADPHONES half-on. The button-down + pinstripes + pen + glasses are all clearly visible. + +Through the window we read into the studio: warm desk-lamp pool, wooden floor, a plant, an AC poster on the wall. On the desk: the CLOSED green MacBook Neo with butterfly scrap face-up, a small toy BUNNY plushie, a small glass jar of golden HONEY with a wooden dipper, and a small stack of pennies (the MONEY) — the song's three lyric talismans.`, + + "overture-b": +`BEAT — OVERTURE B "the blinking light" · EMOTION: stillness, the moment before everything. ARC STAGE — eyes dim, clothes clean, NO pixsies, PALS dark. EARTH. + +★ LAPTOP STATE (mandatory): The MacBook Neo is OPEN now, on the desk. Its LCD SCREEN faces JEFFREY (toward him, AWAY from camera). Camera ANGLE is positioned roughly opposite jeffrey's face — but the laptop lid is between camera and the LCD, so the LCD ITSELF IS NEVER VISIBLE to camera (we see only the TOP EDGE / HINGE / BACK OF THE LID from this angle, with the WHISTLEGRAPH BUTTERFLY DOODLE scrap visible on the LID-BACK turned toward us). NO LCD pixels, no readable text — but the WARM SCREEN GLOW spills outward onto jeffrey's face from below his chin, illuminating his cheeks + brow with a soft cool-white screen light (rendered as dithered pale pixels), eyes lowered to the (unseen) screen. + +CAMERA: still OUTSIDE the window LOOKING IN, now turned to a more frontal three-quarter angle so we see JEFFREY'S FACE softly lit by the screen-glow from below; his pixel features readable + thoughtful. The laptop is angled in the foreground between us and him, lid-back with butterfly toward us, screen invisible. + +He wears the canonical pale-baby-blue BUTTON-DOWN with darker-blue PINSTRIPES + yellow BEAR EMBLEM + yellow PEN + RED GLASSES at the placket + cobalt trousers + dark slate-grey HEADPHONES half-on. + +Through the glass behind jeffrey, the otherwise-still indexed-blue night sky carries ONE tiny RED LIGHT blinking once, far away in the distance — too small for him to notice.`, + + "overture-c": +`BEAT — OVERTURE C "the swarm forms" · EMOTION: oblivious — the last calm moment. ARC STAGE — eyes dim, clothes clean, NO pixsies, PALS dark. EARTH. INTERIOR THIS TIME — we are inside the studio looking ACROSS the room from a low angle. the SKY OUTSIDE the window is now alive: the one red blink has become a dozen, then dozens of drones forming a moving constellation against the indexed-black, faint neon-orange targeting beams (single-pixel-wide orange streaks) sweeping across the studio glass from outside, still soundless. jeffrey is at his desk in three-quarter profile still focused on his MacBook Neo, headphones on, oblivious. THE LAPTOP SCREEN STAYS HIDDEN — angled AWAY from camera or otherwise out of view; do NOT show the screen's content. only the lid carries the canonical WHISTLEGRAPH BUTTERFLY DOODLE on the white-paper scrap. warm amber desk-lamp pixels on his face now competing with cold orange beams crossing his shoulder from the window. on the desk: the small toy BUNNY, the jar of HONEY, the stack of pennies (MONEY).`, + + "statement-a": +`BEAT — STATEMENT A "the swarm" · EMOTION: SHOCK — caught mid-recoil, no understanding yet. ARC STAGE — eyes WIDE in shock (still no fire), clothes about to tear, NO pixsies, PALS dark. EARTH. the WINDOW EXPLODES INWARD — pixel shards of glass mid-flight, frozen. a SWARM of black military drones outside, neon-orange targeting lasers crisscrossing the studio, ONE drone right at the broken pane staring in with a single red lens-eye. jeffrey recoils, BOTH PALMS UP defensive, his pixel face caught mid-shock — brow up, mouth open in a small dark oval, NO grin, NO fire-eyes. camera: WIDE, drones swarming on one side of the frame, jeffrey centred, glass pixels mid-air.`, + + "statement-b": +`BEAT — STATEMENT B "the floor laser" · EMOTION: disbelief — watching the floor get carved. ARC STAGE — eyes shock-wide, clothes still clean, NO pixsies, PALS dark, NO fire on him. EARTH. ONE lead drone hovers low in the middle of the studio, its barrel angled STRAIGHT DOWN. it fires a CLEAN PILLAR of cutting laser light — saturated red-orange flat pixels stacked vertically — straight into the wooden plank floor between jeffrey's feet. the beam is a perfect vertical column of pixels, carving a stepped pixel circle through the planks; spark pixels + curling pixel-smoke + flying splinter pixels fan out around the cut. jeffrey is leapt back against the desk, arms shielding his face, watching the floor get carved. NO fire-eyes yet — just wide shock.`, + + "statement-c": +`BEAT — STATEMENT C "the hole to hell" · EMOTION: awe displacing fear — the first faint curl of a grin. ARC STAGE — eyes wide-awe, clothes clean, NO pixsies, NO fire, NO PALS. STUDIO-HOLE. the circle of floor has DROPPED AWAY. a PERFECT ROUND PIXEL-STEPPED HOLE now opens in the wooden planks, edges glowing ember-red where the laser bit through, charred wood smoking. THROUGH the hole: HELL plainly visible — basalt tiles + sinusoidal lava rivers + bruise-purple sky, miles below and yet right there. jeffrey is on his knees at the rim, PEERING IN, hair lifting slightly, the warm orange glow lighting his face from below for the first time. pixel face: awe, no fear, the first faint curl of a grin.`, + + "bridge-a": +`BEAT — BRIDGE A "videocall the squad" · EMOTION: urgent excitement — calling friends to come over. ARC STAGE — eyes bright (no fire), clothes clean, pixsies APPEAR for the first time but only as glow on jeffrey's face from his LAPTOP screen (not yet in the room), PALS not yet relevant. STUDIO-HOLE. + +★ DEVICE (mandatory): jeffrey is HOLDING HIS OPEN CITRUS-GREEN MACBOOK NEO IN ONE HAND like a tray / oversized tablet — palm flat under the base, the lid open at ~110° standing up off his palm, screen facing TOWARD HIS FACE so he can see the video call. The LID-BACK (with the WHISTLEGRAPH BUTTERFLY scrap taped on its centre) is what faces the CAMERA. The LCD screen content (a multi-pane FACETIME-style GRID of 4-6 pixsie faces in their own warm-lit pixel tiles) is angled AWAY from camera behind the lid edge — we don't see the screen content directly, only the SCREEN-GLOW spilling onto jeffrey's face from the laptop's screen side. NO PHONE anywhere in the frame. + +jeffrey is mid-SHOUT into the laptop, mouth open as a small pixel oval, eyes wide. His FREE HAND (the one NOT holding the laptop) is pointing DOWN at the glowing floor hole. + +He wears the canonical pale-baby-blue BUTTON-DOWN with darker-blue PINSTRIPES + yellow BEAR EMBLEM + yellow PEN + RED GLASSES at the placket + cobalt trousers.`, + + "bridge-b": +`BEAT — BRIDGE B "the squad busts in" · EMOTION: arrival energy — wide pixel grins. ARC STAGE — eyes bright (no fire yet), clothes clean, pixsies APPEAR in the room for the first time, each pixsie now carrying ONE AC laptop (PALS lids DARK), jeffrey also still has his MacBook Neo on the desk behind him. STUDIO-HOLE. the STUDIO DOOR explodes open — door slamming back on its hinges, splinter pixels flying. 4-6 pixsies pile through the doorway in a jumbled wave, mid-stride, each carrying their own glossy plastic AC laptop in one hand with the PALS lid showing dark (not yet glowing). outfits in pixel shorthand: kid pixsie in lime PJs + sneakers, elder pixsie in a cardigan with cane raised, femme grad-student in cyberpunk techwear, tactical-vest pixsie in boots, hot-pink-hair pixsie in a hoodie, beanie pixsie clutching a felt coffee. they arrive READY — wide bright pixel grins, eager eyes, peer-horizontal stack in the doorway. jeffrey is on his feet now, half-turned toward them, free arm thrown UP in greeting, the glowing floor hole still visible beside him.`, + + "bridge-c": +`BEAT — BRIDGE C "around the hole" · EMOTION: shared awe, shared grin — warmth on every pixel face. ARC STAGE — eyes bright-wonder (no fire yet), clothes clean, full crew present and ringed around the hole, PALS lids DARK but warm orange glow already dithering on every face from below. STUDIO-HOLE. the whole crew now ringed around the glowing round floor hole, peer-horizontal CIRCLE, jeffrey one member among them (NOT centred). they're leaning in, peering DOWN into hell — warm lava glow hitting every face from below as dithered orange pixels, fingertips on the rim of the cut planks. one pixsie holds her AC laptop OUT over the hole letting the (still dark) PALS lid catch the orange light, one pixsie crouches with hands on knees grinning, the kid pixsie kneels rim-side eyes huge, the elder grips her cane. STILL no fire-eyes — but the warm glow already paints them all.`, + + "bridge-d": +`BEAT — BRIDGE D "all in" · EMOTION: peer-horizontal cannonball — joy + commitment, no fear. ARC STAGE — eyes bright-wide (no fire yet), clothes clean, the whole crew mid-air, AC laptops in tow, the macbook neo spinning free with them. TRANSITION (mid-fall through the hole). CANNONBALL — the entire crew launching INTO the hole together in one frozen instant mid-leap: feet off the floor, knees pulled up, arms thrown around each other's shoulders, the citrus-green MacBook Neo spinning free in the air alongside them with the whistlegraph butterfly scrap still on the lid, AC laptops tucked under arms or pinwheeling. jeffrey is ONE BODY in the group cluster, somewhere in the middle, NOT centred. hair lifted, eyes bright but still clean — no fire yet. behind/above them the ruined studio doorway + ceiling recedes; below them the basalt + lava rivers already rushes up to meet them. peer-horizontal mid-air. camera: FROM BELOW INSIDE THE HOLE looking UP at the falling cluster against the studio doorway light, hell-glow on undersides, the round pixel-stepped disc of studio ceiling shrinking above.`, + + "develop-a": +`BEAT — DEVELOP A "splashdown" · EMOTION: shared whoop, caught between gasp + grin. ARC STAGE — eyes wide-mid-whoop (no fire yet), clothes darkening at the waterline, pixsies present, PALS dark, fingertips clean. HELLSCAPE LAVA POOL. IMPACT into a wide LAVA POOL. the crew hits the sinusoidal lava river in a huge spray — pixel splashes of glowing orange-red lava arcing up in fat pixel droplets, stepped waves rolling outward in concentric rings. jeffrey + pixsies frozen mid-plunge, half-submerged at varied depths, expressions caught between gasp and grin — eyes wide, mouths OPEN in a shared whoop. the citrus-green MacBook Neo splashes down beside them, lid still glowing softly. NO fire-eyes yet — but the lava is up to their chests, lighting every pixel face from within the pool.`, + + "develop-b": +`BEAT — DEVELOP B "swim in lava" · EMOTION: pure pool-day joy in hell. ARC STAGE — eyes laughing (no fire yet), clothes wet + first chars at the waterline, pixsies in the lava pool with jeffrey, PALS LIDS JUST FLICKERING ON (one or two glowing on floating laptops, rest still dark), fingertips clean. HELLSCAPE LAVA POOL. the crew floats + strokes through the LAVA RIVER like it's a warm pool — the lava holds them up the way water would. ONE pixsie on her back floating with arms behind her head, eyes closed grinning. ONE pixsie doing a slow backstroke, trailing a stepped sine-shaped wake. the KID pixsie cannonball-bobs in the middle splashing the ELDER, who is splashing back. JEFFREY is treading lava beside another pixsie laughing, his AC laptop floating like a pool toy beside him with the PALS lid just flickering on. tongues of pixel-sine-flame lick off the surface between them. the lava is their water. outfit pixels darkening at the waterline + starting to char. wicked grins arriving but eyes still clean.`, + + "develop-c": +`BEAT — DEVELOP C "ignition" · EMOTION: wickedness arriving — wide-eyed grins forming, not full demon yet. ARC STAGE — FIRE IGNITES in eye sockets in a chain (jeffrey first, then each pixsie), ALL PALS LIDS now full-glowing in seven hues, FINGERTIPS BEGINNING TO SINGE with tiny pixel flames, first scorch marks + frays. HELLSCAPE LAVA BANK. the crew now CLIMBING OUT + RISING UP from the lava onto the basalt bank — a glowing orange water-line of lava drips off them as they emerge (dithered orange-red dripping pixels). AT THIS MOMENT the FIRE IGNITES — first in jeffrey's eye sockets (one socket, then the other, rendered as small bright orange + yellow flame-pixel sprites where his eyes were) and then in a CHAIN around the crew, each pixsie's eyes lighting in sequence as they surface. all the PALS lids now full-glowing in seven hues. fingertips beginning to singe, tiny pixel flames flickering off finger tips. wide-eyed grins forming. first threads pulling loose, first scorch pixel marks.`, + + "climax-a": +`BEAT — CLIMAX A "the cover" (verbatim cover crop) · EMOTION: peak chaos, peak joy. ARC STAGE — eyes BLAZING with full live flame-pixel sprites in sockets (no whites), all PALS lids full seven-hue glow, fingertips on fire with tiny pixel flames + scorch + glowing ember-cracks, clothes TATTERED + char-marked (jagged pixel edges). THE COVER. the smooshed-into-lens wide-angle group portrait — jeffrey + pixsies dancing ON / OVER / AROUND active pixel-flame, BOTH PALMS UP at the lens, fire-eyes BLAZING, sinusoidal lava ribbons weaving between feet in pixel-stepped sine waves, sine-flame tongues dancing between faces, PALS lids glowing cyan / magenta / lime / hot-pink / gold / orange / violet, white horse + bullet train on the back horizon as small pixel sprites, multi-hue code-rain through smoke columns as single-pixel-wide vertical streaks, soap-bubble pixel circles rising, fingertip flame-pixels + scorched palm cracks, tattered clothes. peak chaos, peak joy, the PARTY. camera: THE COVER CROP VERBATIM — edge-to-edge, jeffrey at about 40% from left, smooshed into the lens.`, + + "climax-b": +`BEAT — CLIMAX B "in the heart of the dance" · EMOTION: peak joyful celebration, alternate vantage. ARC STAGE — eyes glowing warm with inner pixel-light, PALS lids full seven-hue, fingertips bright with warmth, clothes joyfully tattered. THE SAME CELEBRATION, ALTERNATE ANGLE. camera positioned WITHIN the dancing crew at chest-height: jeffrey is just OFF-CENTER on the left side of the frame, dancing with one arm raised, his profile in three-quarter view. the pixsies are scattered AROUND him at peer eye-level, each looking in a DIFFERENT direction — some looking up at the lava sky, two laughing together off to one side, one focused on dancing, one looking down at her PALS laptop, one mid-twirl with her pixel-hair flying — NEVER all looking at jeffrey, NEVER cult-leader composition. raised PALS laptops glow in their seven hues scattered across the frame. lava sines weaving gently between feet as pixel-stepped sine waves. peer-horizontal — jeffrey is just one member of the crew, NO ONE is centered.`, + + "climax-c": +`BEAT — CLIMAX C "the wide vista" · EMOTION: a vast warm chaos with the crew at its heart. ARC STAGE — eyes blazing, PALS full, fingertip flames, clothes tattered — but at scale now. PULL BACK to the WIDEST shot of the track — the dancing group is now SMALL in the lower-third of the frame, the FULL hellsine vista revealed around them: parallel sinusoidal lava rivers curving through the basalt foreground at varied amplitudes (each river a clean pixel-stepped sine), obsidian spires receding into bruise-purple horizon, the white horse flaming across the mid-distance, the bullet train streaking the far horizon, multi-hue matrix-rain streaming through smoke columns on either side, soap bubbles + embers everywhere. the dancing crew is the warm pulsing nucleus inside a vast pixel-art lava world.`, + + "coda-a": +`BEAT — CODA A "all chilling on the basalt" · EMOTION: chilled-out afterglow — nobody is posing, everyone at rest. ARC STAGE — eyes now just EMBERS (small soft orange pixel glow, no flames), clothes fully tattered + scorched (but the damage settled), fingertips embers + scorch (no flames), PALS lids dimmed to a soft pulse, dawn light. HELLSCAPE AT DAWN. dawn breaking over the obsidian horizon — bruise-purple softening to deep coral, the lava glow halved, smoke columns thinning. the crew is JUST CHILLING across the basalt + lava-pool edge — sat on warm rocks, leaning back on elbows, one stretched out flat looking up at the coral sky, ONE pixsie floating again in the cooled lava pool arms behind her head, ONE elder smoking a slow ember off the basalt edge, KID pixsie curled up dozing against an obsidian wedge, jeffrey is reclining against a warm rock among them (NOT centred). spent and content, the party's afterglow. fire-eyes now just EMBERS — soft orange pixel glow, no flames. clothes fully tattered + scorched (jagged pixel edges) but the heat is no longer hurting. PALS lids dimmed to a soft pulse. nobody is posing; everyone is at rest.`, + + "coda-b": +`BEAT — CODA B "they live here now" · EMOTION: calm settled smile, home found. ARC STAGE — eyes embers, clothes tattered-settled, fingertips quiet embers, PALS soft pulse, MacBook Neo open + screen visibly glowing with content. HELLSCAPE AT DAWN, INTIMATE. THREE-QUARTER ANGLE FROM JEFFREY'S SIDE so the camera sees jeffrey in three-quarter profile AND clearly sees the LAPTOP SCREEN OPEN ACROSS HIS LAP — the citrus-green MacBook Neo's screen glowing softly with a KIDLISP piece (green-on-black pixel-stepped terminal text with subtle warm orange ember flickers reflected). the lid (closed side facing away/up toward camera) shows the canonical WHISTLEGRAPH BUTTERFLY on the white-paper scrap. ONE pixsie ASLEEP against his shoulder, embers in her eyes. tucked beside him on the basalt: a small singed BUNNY plushie, a small heat-cracked jar of HONEY, a pile of WARM-glowing pennies (MONEY) — the three lyric talismans that came with him. the white horse a small silhouette on the obsidian behind him. the bullet train a thin red streak. jeffrey is smiling — a CALM smile, NOT the wicked climax grin.`, +}; + +// ── prompt construction ────────────────────────────────────────────── +function build(sectionBeat) { + return [MEDIUM, JEFFREY, PIXSIES, JEFFREY_LAPTOP, DEVICE_ENGAGEMENT, WORLD_LAW, ARCS, sectionBeat, PALETTE, AVOID].join("\n\n") + + "\n\n" + PORTRAIT_NOTE + "\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 pad = (n) => String(n).padStart(2, "0"); +const jobs = []; +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/hellsine${TAG}-sec-${pad(i)}-${name}.pixel-raw.png`, + label: `hellsine${TAG} §${pad(i)} ${name} (pixel-raw)`, + }); +} + +progress.begin({ type: "illy-pixel", label: `hellsine${TAG} pixel · ${jobs.length} panels` }); +let done = 0; +for (const job of jobs) { + await generate(job.prompt, job.out, job.label); + progress.update((++done / jobs.length) * 100, { done, total: jobs.length }); +} +progress.end(); +console.log(`\n✓ hellsine PIXEL-RAW storyline panel set — ${jobs.length} job(s) · portrait (1024x1536)`); +console.log(` next: node pop/bin/crisp-pixel-sections.mjs --lane hellsine --slug hellsine`); diff --git a/pop/hellsine/bin/gen-sections.mjs b/pop/hellsine/bin/gen-sections.mjs index f0e2fbf4e..238781dc4 100644 --- a/pop/hellsine/bin/gen-sections.mjs +++ b/pop/hellsine/bin/gen-sections.mjs @@ -3,14 +3,18 @@ // set: 18 per-beat illustrations tracing jeffrey's drone-attack fall // from earth into the hellsine party. // -// Concept (jas, 2026-05-26): +// Concept (jas, 2026-05-27): // the hellsine track visualized as one story — jeffrey at his -// earth desk in studio zollo → a drone strike opens a portal → -// he + the pixsies fall through it together → they land in -// hellsine and ignite → peak party (climax-a is the locked cover) -// → dawn, they live here now. eighteen beats arranged across the -// six README sections (overture / statement / bridge / develop / -// climax / coda), ≈ 9 s each over the 2:42 master. +// earth desk in studio zollo → a drone strike, one drone fires a +// laser STRAIGHT DOWN and burns a hole through the studio floor +// exposing hell below → jeffrey FACETIMES the squad → the squad +// BUSTS THROUGH the studio door → they all CANNONBALL into the +// hole together → SPLASHDOWN into lava → they SWIM in lava → +// ignite → peak party on the basalt shore (climax-a is the +// locked cover) → dawn, everyone CHILLING, they live here now. +// eighteen beats arranged across the six README sections +// (overture / statement / bridge / develop / climax / coda), +// ≈ 9 s each over the 2:42 master. // // Material rules + identity are inherited from hellsine.illy.txt // (felt-craft figures, plastic PALS-lid laptops, photo-real worlds, @@ -65,6 +69,7 @@ const REFS = [ `${ARCHIVE_DIR}/2021-07-10_CRI095Vl7AO_1.jpg`, `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`, `${LANE}/assets/pals-logo.png`, + `${LANE}/assets/whistlegraph-butterfly.png`, ].filter((p) => { if (existsSync(p)) return true; console.warn(` ⚠ ref missing, dropping: ${p}`); @@ -97,32 +102,62 @@ const MEDIUM = `HAND-CRAFTED FELT TABLEAU placed inside a REAL PHOTOGRAPHIC environment. the figures are FELT — visible wool fibre on every surface of skin / hair / clothing, slightly fuzzy outlines where each body meets the photo backdrop, soft 3-D forms sculpted from felt. think high-end stop-motion (Wes Anderson Isle of Dogs / Aardman). NOT cartoon, NOT plush-toy, NOT cute, NOT collaged. refined felt-craft realism seamlessly inside the photographed world. EMOTION + POSTURE shifts beat to beat — read the EMOTION called out in this beat's description and let faces + bodies show it clearly.`; const JEFFREY = -`FELT-PUPPET JEFFREY — about 30, recognizable from the jeffrey reference photographs: tousled medium-length brown felt-yarn hair (mussed, loose strands), CLEAN-SHAVEN or faint stubble at most (NO full beard, NO heavy scruff), pale-flesh felt skin with subtle cheek pink. he wears a PALE-BLUE felt button-down shirt with hand-applied darker-blue felt pinstripes, a small yellow felt bear emblem at the chest, a yellow Sailor Pro Gear-shape fountain pen (short cigar-shaped barrel, flat-topped cap, polished metal clip, gold trim ring) clipped at the shirt pocket, red plastic glasses dangling at the placket; wide-leg medium-cobalt felt trousers. peer-horizontal — never centred as a hero. his felt + eyes go through the arc described below per beat.`; +`FELT-PUPPET JEFFREY — about 30, recognizable from the jeffrey reference photographs: tousled medium-length brown felt-yarn hair (mussed, loose strands), CLEAN-SHAVEN or faint stubble at most (NO full beard, NO heavy scruff), pale-flesh felt skin with subtle cheek pink. peer-horizontal — never centred as a hero. his felt + eyes go through the arc described below per beat. + +JEFFREY'S CANONICAL OUTFIT + GEAR (mandatory — must be CLEARLY VISIBLE whenever jeffrey is in frame at medium-shot or closer; substituting any of these for sweaters, hoodies, t-shirts, tank tops, robes, jackets, or any other garment is FORBIDDEN): + · TOP — a PALE-BABY-BLUE felt BUTTON-DOWN SHIRT with a sharp pointed collar and a row of small felt buttons running down the front placket. NOT a sweater, NOT a cable-knit, NOT a cardigan, NOT a hoodie, NOT a t-shirt, NOT a sweatshirt, NOT a turtleneck, NOT a flannel — a true light-blue oxford-style button-down with visible buttons + collar at all times. it has THIN HAND-APPLIED DARKER-BLUE FELT PINSTRIPES running vertically down the fabric (visible parallel pinstripe pattern across the chest + sleeves). NEVER plain solid blue, NEVER pattern-free. + · CHEST EMBLEM — a SMALL YELLOW FELT BEAR EMBLEM (a wonky cartoon bear silhouette, golden-yellow, the size of a 50¢ coin) hand-stitched onto the left chest of the button-down, just above the pocket. always present. + · PEN — a YELLOW SAILOR PRO GEAR FOUNTAIN PEN clipped at the shirt-pocket of the button-down: short cigar-shaped saturated-yellow barrel, flat-topped saturated-yellow cap with a polished silver metal clip + a single thin gold trim ring at the cap join. ONLY the cap + clip protrude above the pocket — body of the pen is tucked inside. always visible whenever the shirt pocket is in frame. + · GLASSES — RED PLASTIC GLASSES (saturated red rectangular frames, no lenses-tint) DANGLING by one earpiece hooked through the button-down's placket between the second + third button. always at the placket unless he's actively wearing them on his face (rare). NOT in his pocket, NOT in his hand — dangling. + · PANTS — WIDE-LEG MEDIUM-COBALT-BLUE FELT TROUSERS (clearly darker + more saturated blue than the shirt), straight wide leg, no pleats, no cuffs. NOT jeans, NOT shorts, NOT joggers. + · HEADPHONES — when at his desk (overture / early statement only), large over-ear felt headphones in a dark slate-grey, worn HALF ON (one ear cup over the right ear, the other cup pushed back behind the left ear so the left ear is exposed). otherwise off / absent. +THESE GEAR ITEMS ARE THE SINGLE MOST IMPORTANT IDENTITY CUES alongside his face — render them legibly every time. If the camera is at medium-shot or closer, the button-down + pinstripes + bear emblem + pen + glasses must all be visible.`; const PIXSIES = `FELT PIXSIES — 4-7 humanoid grad-students appearing in mid-story beats onward, a real spread of AGES (kid → elder) and a wide RACE + GENDER spectrum (women, men, boys, girls, femme, masc, androgynous; many ethnicities). ROUNDED HUMAN EARS ONLY — never pointed / elf / fae. thoughtful warmly-intelligent faces. OUTFITS a mixed-up pastiche — some militaristic-tactical, some super-cute girly, some cyberpunk-techwear, some cardigan, all of it clashed eclectic the way grad-student wardrobes are. uncanny tells (LED beads under felt skin at temple / ear / eye-edge in cyan-green pinpricks, occasional hairline felt seams) optional + subtle. each holds (when present) ONE small AC PALS laptop in one hand: glossy injection-moulded plastic shell with the PALS glyph (the two-bubbly-people Keith-Haring linked outline from the pals-logo.png reference) glowing on the lid in a unique hue — cyan / magenta / lime-green / hot-pink / golden-yellow / electric-orange / deep-violet. NEVER apple, NEVER butterfly on pixsie lids.`; const JEFFREY_LAPTOP = -`JEFFREY'S MACBOOK NEO — when shown, a CITRUS-GREEN plastic MacBook Neo with a TORN WHITE-PAPER SCRAP taped over where the apple logo would be, a hand-penned thick whistlegraph BUTTERFLY drawn on the scrap (NOT an apple, NOT a PALS glyph — only jeffrey's lid carries the butterfly scrap). the lid is glossy plastic that reflects ambient light.`; +`JEFFREY'S MACBOOK NEO — when shown, a CITRUS-GREEN plastic MacBook Neo with a TORN WHITE-PAPER SCRAP taped over where the apple logo would be. On the scrap is the WHISTLEGRAPH BUTTERFLY DOODLE. + +★ CRITICAL — THE DOODLE MUST EXACTLY MATCH the attached whistlegraph-butterfly.png reference image (see the ref file in the input set). Reproduce that doodle's EXACT line work, proportions, wing curves, head shape, leg position, and line weight — pixel-faithful copy, no improvisation, no stylistic reinterpretation, no anatomical correction. The reference png IS the doodle; if the rendered scrap doesn't read like that exact reference, it is wrong. + +Reference description (a verbal aid, but the PNG is authoritative): a wonky child-marker doodle drawn in a SINGLE THICK MEDIUM-GREY MARKER LINE on a slightly off-white paper scrap, with NO interior fill and NO color. The doodle is a SMILING STICK-FIGURE WITH BUTTERFLY WINGS composed of: + (1) a small slightly-tilted RECTANGULAR HEAD on top, with TWO small dot EYES and a SHORT CURVED SMILE inside the rectangle; + (2) a tall thin VERTICAL RECTANGULAR BODY descending straight down from the head; + (3) FOUR LARGE ROUNDED BUTTERFLY-WING LOBES growing directly out of the SIDES of the body — TWO on the LEFT (an upper-left lobe + a lower-left lobe joined at the body's midline) and TWO on the RIGHT (an upper-right lobe + a lower-right lobe joined at the body's midline). Each lobe is a soft rounded blob shape with a single curved interior crease line suggesting a wing fold; + (4) TWO SHORT STUBBY RECTANGULAR LEGS side-by-side below the body. + +The figure is LEFT-RIGHT SYMMETRIC. The four wing lobes are clearly WINGS attached to the body (NOT clouds, NOT hands, NOT arms reaching up, NOT a realistic insect with antennae). Render it CLEARLY LEGIBLE — a wonky hand-drawn child-marker doodle in a single thick stroke on a real taped paper scrap, the embossed apple peeking around its torn edges. + +This doodle is on JEFFREY'S green laptop ONLY — NEVER on a pixsie's laptop (pixsie lids carry the PALS glyph ONLY). The lid is glossy plastic that reflects ambient light.`; const WORLD_LAW = `WORLD LAW — the SETTING varies by beat: -• EARTH PANELS (overture-*, statement-*) = jeffrey's felt-crafted studio zollo: warm desk-lamp pool, wooden floor, plant, AC poster, a real-photo night sky outside the window. the studio is felt, the night sky outside is photo-real. -• PORTAL / COSMIC-CHUTE PANELS (statement-c, bridge-*) = a long descending shaft between worlds; walls are streams of glowing MATRIX-RAIN code-glyphs (kana / kanji / numbers / abstract symbols) in MULTI-HUE — yellow + red + purple + lime, intermixed columns. broken chunks of earth (studio floor, the macbook neo, a coffee mug, sheet music, the desk lamp) drift weightless in the fall. soap bubbles rising. -• HELLSCAPE PANELS (develop-*, climax-*, coda-*) = real-photo alien volcanic landscape — cracked CHARRED basalt foreground crusted with soot + sulphur, sinusoidal LAVA RIVERS curving across the ground (the lava itself runs in clean math sine waves), tall walls of sine-shaped FLAME, jagged obsidian spires receding into a bruise-purple horizon, dense atmospheric particulate (orange embers + sparks, ash flakes, soap bubbles, smoke columns, multi-hue matrix-rain cascading through some smoke columns, faint glowing wireframe-grid + circuit-trace lines in basalt cracks, a few floating holographic HUDs), heat-shimmer above the lava. A SINGLE PURE-WHITE HORSE flames across the mid-distance basalt with red flames licking its hooves + body. A SLEEK BULLET TRAIN streaks the far horizon with lit-up windows + motion-streaked tracks. THE SINES ARE THE FIRE — every lava river + flame tongue traces a perfect sine wave. NO sine beams from anyone's mouth.`; +• EARTH PANELS (overture-*, statement-a, statement-b) = jeffrey's felt-crafted studio zollo: warm desk-lamp pool, WOODEN-PLANK floor, plant, AC poster, a real-photo night sky outside the window. the studio is felt, the night sky outside is photo-real. Camera = STANDARD lens, normal perspective. +• STUDIO-HOLE PANELS (statement-c, bridge-a, bridge-b, bridge-c) = the SAME felt-crafted studio zollo, but now with a PERFECT ROUND HOLE burned cleanly through the wooden floor — circular cut, edges glowing ember-red where the laser bit through, charred + smoking wood at the rim, heat-shimmer rolling up out of it. DOWN INSIDE the hole: HELL clearly visible far below — sinusoidal LAVA RIVERS, basalt, bruise-purple sky — miles down yet RIGHT THERE through the disc of floor. warm orange hell-glow paints the studio from below; cool desk-lamp + cold drone targeting beams paint from above. soap bubbles drifting up out of the hole. Camera = STANDARD lens. +• TRANSITION (bridge-d) = mid-fall THROUGH the hole — the studio doorway + ceiling shrinking away above, basalt + lava rushing up below, the felt crew cannonballing as one cluster. NO long cosmic chute. Camera = HANDHELD WIDE-ANGLE looking up. + +★ HELLSCAPE WORLDLY SHIFT (develop-*, climax-*, coda-*) — once in hell, the LENS + CAMERA STYLE shifts dramatically to a FISH-EYE FIRST-PERSON GO-PRO / SKATE-VIDEO HANDHELD aesthetic. Strong wide-angle FISH-EYE LENS DISTORTION at the frame edges (vertical lines bow outward, horizon curves), generous DUTCH TILT on the horizon, low POV often near ground level or chest height as if the camera-operator is INSIDE the action with a handheld mini-cam, occasionally a felt hand reaches into the foreground holding the cam or a selfie pole. Shot styling = the "Spike Jonze + Ty Evans Yeah Right! skate doc + Jackass home-video" feel — energetic close-quarters chaos, action filling the FORE + MID grounds, peer-horizontal subjects often closer than they "should" be. NO motion blur and NO out-of-focus language — convey energy through pose + composition + lens distortion only. The fish-eye PUSHES the SINE-WAVE WORLD into more dimensional 3D — basalt valleys curl, sine lava rivers wrap around the lens, flame tongues lean toward the camera, the bruise-purple sky bowls overhead, obsidian spires lean inward. The world reads more SCULPTED + DIMENSIONAL than the flat earth shots — every hellscape beat should feel like a chunk of carved sine-volume you're standing inside of. + +• HELLSCAPE PANELS = real-photo alien volcanic landscape, MAX SINE EVERYTHING — cracked CHARRED basalt foreground crusted with soot + sulphur, MULTIPLE PARALLEL sinusoidal LAVA RIVERS curving across the ground at varied amplitudes (every river is a perfect math sine wave, NEVER straight), tall walls of sine-shaped FLAME licking up, jagged obsidian spires receding into a bruise-purple horizon (the spires themselves carved into sine-curves), dense atmospheric particulate (orange embers + sparks, ash flakes, soap bubbles, smoke columns, multi-hue matrix-rain cascading through some smoke columns, faint glowing wireframe-grid + circuit-trace lines in basalt cracks, a few floating holographic HUDs), heat-shimmer above the lava. A SINGLE PURE-WHITE HORSE flames across the mid-distance basalt with red flames licking its hooves + body. A SLEEK BULLET TRAIN streaks the far horizon with lit-up windows + motion-streaked tracks. THE SINES ARE THE FIRE — every lava river + flame tongue traces a perfect sine wave. NO sine beams from anyone's mouth. NOTE: develop-a + develop-b feature the crew IN A LAVA POOL — the lava acts as a warm magical liquid the crew can splash into + swim in without being hurt; felt darkens at the waterline + chars but bodies stay buoyant + joyful. + +★ COOLING HELL (coda-* only) — the hellscape begins COOLING DOWN in the final beats: lava rivers crusting over with darker obsidian skin, the bruise-purple sky shifting to a COLD STEEL-BLUE dawn instead of warm coral, SNOWFLAKES BEGINNING TO FALL from a thinning ashy sky and SETTLING on basalt + on the felt crew's shoulders + hair, surviving lava patches glowing dim red through frost. The "magma planet" gives way to a frostbitten ash-volcanic plain. Temperature drops sharply between climax-c → coda-a → coda-b. Coda-b is fully WINTER hell.`; const ARCS = `CONTINUITY ARCS across the whole 18-panel set (the beat description below names this beat's stage): -• EYES: dim (clean dark felt) → shock-wide → wonder-wide → fire ignites in sockets → blazing red-orange flames in sockets, no whites → embers (soft glow, no flames). -• FELT: clean → torn from the strike → frayed from the descent → scorched at cuffs → fully tattered + char-marked → still tattered but settled. -• PALS LIDS: dark / off → flickering on → full seven-hue glow → soft pulse at dawn. -• FINGERTIPS: clean → clean → clean → tiny live flames singeing felt fibres at tips, scorch marks + glowing ember cracks on palms → embers.`; +• EYES: dim (clean dark felt) → shock-wide at the strike → wide-awe peering into the hole → bright-call mid-facetime → bright-greeting at the bust-in → grouped-wonder around the hole → mid-whoop cannonballing → mid-whoop splashing → laughing while swimming → IGNITION (fire ignites in sockets in a chain) → blazing red-orange flames in sockets (no whites) for the party → embers (soft glow, no flames) at dawn. +• FELT: clean through overture → clean through statement (the strike is at the room, not yet at his clothes) → clean as he peers in the hole → clean through facetime + bust-in + cannonball → wet + darkening at the waterline on splashdown → wet + first chars from swimming → first scorch marks + frays at ignition → fully tattered + char-marked at the party → tattered but settled + dry by dawn. +• PALS LIDS: jeffrey's MacBook Neo BUTTERFLY-scrap visible in overture, statement, coda; pixsie laptops APPEAR for the first time in bridge-b (each pixsie carries one when bursting in) but PALS are DARK until ignition; PALS FLICKER on during the swim (one or two glowing) → full seven-hue glow at the party → soft pulse at dawn. +• FINGERTIPS: clean through earth + studio-hole + cannonball + splashdown + swim → tiny live flames singeing felt fibres at tips + scorch marks + glowing ember cracks on palms at ignition + party → embers at dawn.`; const PALETTE = `PALETTE — for earth panels: warm amber desk-lamp pool against cool blue night, the felt blues of jeffrey's shirt + trousers, soft browns; for chute panels: cool deep-blue chute with multi-hue matrix-rain streaks (yellow / red / purple / lime); for hellscape panels: dominant warm lava-orange + red as the principal light, deep crimson sky, bruise-purple horizon, obsidian black + sulphur yellow + soot, embers + ash, intermixed with the multi-hue matrix-techno layer (yellow / red / purple / lime / amber / magenta), PALS-lid hues (cyan / magenta / lime / hot-pink / gold / orange / violet) as the secondary punctuation light.`; +const DEVICE_ENGAGEMENT = +`DEVICE ENGAGEMENT — whenever jeffrey or any pixsie is holding a laptop, phone, or other device, the figure's HEAD AND GAZE MUST BE ON THE SCREEN (eyes down/toward the display, head tilted into the work, body posture absorbed in using the thing). NEVER the presentational "showing the laptop to the camera" pose where the device is turned sideways so its screen / lid faces the lens. The screen faces the user, not the viewer. If a lid (back of the screen) is in frame, it's because the figure is using the device naturally — not because they're modelling it. Two examples to copy from: someone hunched over a keyboard reading the terminal; someone scrolling a phone with thumb mid-swipe and eyes on the glass. Two examples to AVOID: someone holding a laptop OUT toward the camera with both hands; someone tilting a phone toward the lens to show what's on it.`; + const AVOID = -`AVOID — any cartoon / plush / collaged look (this is refined felt-craft realism); apple logos or butterfly glyphs on pixsie lids (PALS only on pixsie lids, butterfly only on jeffrey's MacBook Neo lid scrap); SINE BEAMS POURING FROM ANYONE'S MOUTH (the sines ARE the lava + fire itself, never an oral effect); blood / gore / body horror (the heat damage is real felt damage — fraying, char, scorch — not gore); any readable text, wordmark, or logo anywhere; pointed / elf / fae ears on pixsies (rounded human ears only); jeffrey centred as a hero (peer-horizontal); modern flat tech-illustration; recursive screens showing the surrounding scene; living-artist names; motion blur language (convey motion through pose only).`; +`AVOID — any cartoon / plush / collaged look (this is refined felt-craft realism); apple logos or butterfly glyphs on pixsie lids (PALS only on pixsie lids, butterfly only on jeffrey's MacBook Neo lid scrap); SINE BEAMS POURING FROM ANYONE'S MOUTH (the sines ARE the lava + fire itself, never an oral effect); blood / gore / body horror (the heat damage is real felt damage — fraying, char, scorch — not gore); any readable text, wordmark, or logo anywhere; pointed / elf / fae ears on pixsies (rounded human ears only); jeffrey centred as a hero (peer-horizontal); modern flat tech-illustration; recursive screens showing the surrounding scene; living-artist names; motion blur language (convey motion through pose only); presentational "showing the laptop to camera" poses (see DEVICE ENGAGEMENT — screen faces the user, not the viewer).`; // ── per-beat story — 18 panels ─────────────────────────────────────── // Keys must match the SECTION_ORDER list below. Order matches @@ -140,64 +175,132 @@ const SECTION_ORDER = [ const SECTION_VARIANTS = { "overture-a": -`BEAT — OVERTURE A "mid-keystroke" · EMOTION: focused calm, the hum before everything. ARC STAGE — eyes dim (no fire), felt clean, NO pixsies present, PALS lids dark. EARTH. studio zollo, late. felt-puppet jeffrey at his desk seen from across the room — citrus-green MacBook Neo open with the white-paper whistlegraph-butterfly scrap taped over the apple, sailor pro gear yellow pen clipped in his pale-blue button-down pocket, headphones half on, red glasses dangling at the placket. felt studio behind him: warm desk-lamp glow, wooden floor, a plant, an AC poster on the wall. he is mid-keystroke on a kidlisp piece — calm focused face. through the window: a still black night sky. camera: MEDIUM across the desk, jeffrey + screen + window all in frame.`, +`BEAT — OVERTURE A "before the keystroke" · EMOTION: focused calm, the hum before everything. ARC STAGE — eyes dim (no fire), felt clean, NO pixsies present, PALS lids dark. EARTH. studio zollo, late. + +★ LAPTOP STATE (mandatory): The MacBook Neo is CLOSED — lid flush down on top of the keyboard base. We see the GREEN OUTSIDE-OF-LID facing UP toward camera, the TORN WHITE-PAPER SCRAP with the WHISTLEGRAPH BUTTERFLY DOODLE taped in the centre of that green top panel (where the apple logo would be). NO LCD visible anywhere, no screen content, no screen glow — the laptop is shut. The butterfly scrap ONLY ever lives on the OUTSIDE-OF-LID, never on the LCD side and never on the base; the laptop being closed makes this unambiguous. + +CAMERA: positioned OUTSIDE the window LOOKING IN through the photo-real night-time glass. We see felt-puppet jeffrey from OUTSIDE in three-quarter profile: he's seated at his desk inside the warmly-lit studio, his TORSO + ONE SHOULDER + the side of his face turned slightly toward camera; one felt hand resting gently ON TOP of the closed laptop's green lid, the other hand reaching for the small toy bunny / honey jar / pennies on the desk (he hasn't started typing yet — this is the quiet moment before he opens it). Headphones half-on around his neck/ears. + +He wears the canonical pale-baby-blue felt BUTTON-DOWN with darker-blue PINSTRIPES + yellow felt BEAR EMBLEM at the chest + yellow Sailor Pro Gear PEN clipped at the pocket + RED PLASTIC GLASSES dangling at the placket + wide-leg medium-cobalt felt TROUSERS + dark slate-grey HEADPHONES half-on. The button-down + pinstripes + pen + glasses are all clearly visible. NOT a sweater, NOT a cable-knit, NOT a hoodie. + +Through the window we read into the felt studio: warm desk-lamp pool, wooden floor, a plant, an AC poster on the wall. Tiny reflections of the night sky on the window glass overlay the scene. On the desk: the CLOSED green MacBook Neo with butterfly scrap face-up, a small toy felt BUNNY plushie, a small glass jar of golden HONEY with a wooden dipper, and a small stack of pennies (the MONEY) — the song's three lyric talismans. Camera: EXTERIOR vantage looking IN through a single windowpane, framed so the warm interior is the bright centre and a sliver of cool black night is around the edges.`, "overture-b": -`BEAT — OVERTURE B "the blinking light" · EMOTION: stillness, the moment before everything. ARC STAGE — eyes dim, felt clean, NO pixsies, PALS dark. EARTH. TIGHTER on the window behind jeffrey's shoulder. black night sky outside, perfectly still. ONE tiny RED LIGHT blinks once, far away in the distance — too small for him to notice. his felt hands stay on the keyboard, the warm desk-lamp pool to one side. mood: the hum before everything. camera: OVER-THE-SHOULDER past jeffrey OUT THE GLASS, the red blink small and centred in the night.`, +`BEAT — OVERTURE B "the blinking light" · EMOTION: stillness, the moment before everything. ARC STAGE — eyes dim, felt clean, NO pixsies, PALS dark. EARTH. + +★ LAPTOP STATE (mandatory): The MacBook Neo is OPEN now, on the desk. Its LCD SCREEN faces JEFFREY (toward him, AWAY from camera). Camera ANGLE is positioned roughly opposite jeffrey's face (we see HIS FACE, not his back this time) — but the laptop lid is between camera and the LCD, so the LCD ITSELF IS NEVER VISIBLE to camera (we see only the TOP EDGE / HINGE / BACK OF THE LID from this angle, with the WHISTLEGRAPH BUTTERFLY DOODLE scrap visible on the LID-BACK turned toward us). NO LCD pixels, no screen content, no readable text — but the WARM SCREEN GLOW spills outward onto jeffrey's face from below his chin, illuminating his cheeks + brow + the underside of his felt hair with a soft cool-white screen light, eyes lowered to the (unseen) screen. THE BUTTERFLY IS ONLY ON THE LID-BACK, NEVER ON THE LCD. + +CAMERA: still OUTSIDE the window LOOKING IN, now turned to a more frontal three-quarter angle so we see JEFFREY'S FACE softly lit by the screen-glow from below; his felt features readable + thoughtful. The laptop is angled in the foreground between us and him, lid-back with butterfly toward us, screen invisible. Mood: the hum before everything. + +He wears the canonical pale-baby-blue felt BUTTON-DOWN with darker-blue PINSTRIPES + yellow felt BEAR EMBLEM + yellow Sailor Pro Gear PEN at the pocket + RED PLASTIC GLASSES at the placket + cobalt felt trousers + dark slate-grey HEADPHONES half-on. All gear clearly visible. NOT a sweater, NOT a cable-knit, NOT a hoodie. + +In the windowpane reflection (or visible through the glass behind jeffrey), the otherwise-still black night sky carries ONE tiny RED LIGHT blinking once, far away in the distance — too small for him to notice. Camera: EXTERIOR three-quarter looking IN through the glass at jeffrey's face, screen-glow under his chin, the red blink small in the dark sky beyond his shoulder.`, "overture-c": -`BEAT — OVERTURE C "the swarm forms" · EMOTION: oblivious — the last calm moment. ARC STAGE — eyes dim, felt clean, NO pixsies, PALS dark. EARTH. the SKY OUTSIDE the window is now alive — the one red blink has become a dozen, then dozens, drones forming a moving constellation against the black. faint neon-orange targeting beams sweep across the studio glass from outside, still soundless. jeffrey is in mid-frame still focused on his macbook, headphones on, oblivious — the warm desk-lamp glow on his face now competing with cold orange beams crossing his shoulder. camera: ACROSS THE ROOM, jeffrey in foreground, the lit-up sky filling the window behind him.`, +`BEAT — OVERTURE C "the swarm forms" · EMOTION: oblivious — the last calm moment. ARC STAGE — eyes dim, felt clean, NO pixsies, PALS dark. EARTH. INTERIOR THIS TIME — we are inside the studio looking ACROSS the room from a low angle. the SKY OUTSIDE the window is now alive: the one red blink has become a dozen, then dozens of drones forming a moving constellation against the black, faint neon-orange targeting beams sweeping across the studio glass from outside, still soundless. jeffrey is at his desk in three-quarter profile still focused on his MacBook Neo, headphones on, oblivious. THE LAPTOP SCREEN STAYS HIDDEN — angled AWAY from camera or otherwise out of view; do NOT show the screen's content (no kidlisp, no terminal, no text). only the lid carries the canonical WHISTLEGRAPH BUTTERFLY DOODLE on the white-paper scrap. warm desk-lamp glow on his face now competing with cold orange beams crossing his shoulder from the window. on the desk: the small toy BUNNY plushie, the jar of HONEY, the stack of pennies (MONEY) all still in their quiet still-life positions. camera: across the room at low angle, jeffrey + the lit-up sky-through-the-window both readable, screen content NOT visible.`, "statement-a": `BEAT — STATEMENT A "the swarm" · EMOTION: SHOCK — caught mid-recoil, no understanding yet. ARC STAGE — eyes WIDE in shock (still no fire), felt about to tear, NO pixsies, PALS dark. EARTH. the WINDOW EXPLODES INWARD — felt-and-glass shards mid-flight, slow-motion. a SWARM of black military drones outside, neon-orange targeting lasers crisscrossing the studio, ONE drone right at the broken pane staring in with a single red lens-eye. jeffrey recoils, BOTH PALMS UP defensive, his felt face caught mid-shock — brow up, mouth open, NO grin, NO fire-eyes. camera: WIDE, drones swarming on one side of the frame, jeffrey centred, glass mid-air.`, "statement-b": -`BEAT — STATEMENT B "the portal opens" · EMOTION: disbelief turning to gravity-pull. ARC STAGE — eyes shock-wide, felt starting to tear, NO pixsies yet, PALS dark, NO fire on him. EARTH transitioning to PORTAL. AT THE SAME INSTANT, BEHIND jeffrey a vertical PORTAL tears open in the studio air — a rip in space, edges crackling with multi-hue MATRIX RAIN (yellow / red / purple / lime), the inside lava-orange + swirling. the desk, chair, mug, sheet music are already lifting and pulling toward it. studio walls warping into the suck. jeffrey pivoting toward the portal, hair lifting in the pull. the macbook neo slides off the desk into the air. camera: LOW ANGLE BEHIND THE DESK, the portal a vertical blade of light dominating the back of the frame, jeffrey silhouetted against it.`, +`BEAT — STATEMENT B "the floor laser" · EMOTION: disbelief — watching the floor get carved. ARC STAGE — eyes shock-wide, felt still clean, NO pixsies, PALS dark, NO fire on him. EARTH. ONE lead drone hovers low in the middle of the studio, its barrel angled STRAIGHT DOWN. it fires a CLEAN PILLAR of cutting laser light — saturated red-orange — straight into the wooden plank floor between jeffrey's feet. the beam is a perfect vertical column, carving a slow circle through the planks; sparks + curling smoke + flying splinters fan out around the cut. jeffrey is leapt back against the desk, arms shielding his face, watching the floor get carved. NO fire-eyes yet — just wide shock. camera: LOW across the floor, the laser column dominating centre frame, jeffrey braced against the desk on the right.`, "statement-c": -`BEAT — STATEMENT C "across the threshold" · EMOTION: held breath, mid-leap, suspended. ARC STAGE — eyes wide held breath, felt just beginning to fray where it crosses the portal, NO pixsies, NO fire yet. EARTH-PORTAL CROSSING. jeffrey caught AT THE LIP OF THE PORTAL — feet off the floor, drones still firing in from the window-right edge of frame, the macbook neo + pen + a single sheet of music suspended in the air around him. HALF HIS BODY already in the lava-orange swirl, half still in the felt studio (the seam crossing his torso). NO fire-eyes yet — wide eyes, breath held. the felt of his crossing sleeve is just beginning to fray. camera: WIDE, jeffrey mid-threshold, earth on one side of him, hellsine light on the other.`, +`BEAT — STATEMENT C "the hole to hell" · EMOTION: awe displacing fear — the first faint curl of a grin. ARC STAGE — eyes wide-awe, felt clean, NO pixsies, NO fire, NO PALS. STUDIO-HOLE. the circle of floor has DROPPED AWAY. a PERFECT ROUND HOLE now opens in the wooden planks, edges glowing ember-red where the laser bit through, charred wood smoking. THROUGH the hole: HELL plainly visible — basalt + sinusoidal lava rivers + bruise-purple sky, miles below and yet right there, the heat already rolling up into the studio. jeffrey is on his knees at the rim, PEERING IN, hair lifting in the updraft, the warm orange glow lighting his face from below for the first time. felt face: awe, no fear, the first faint curl of a grin. drones still buzz overhead but the room has gone quiet around the hole. camera: HIGH THREE-QUARTER on the hole, jeffrey at the rim, the basalt hellscape clearly readable down inside the circle.`, "bridge-a": -`BEAT — BRIDGE A "weightless" · EMOTION: wonder displacing fear. ARC STAGE — eyes wide-wonder (no fire), felt frayed at edges from descent, NO pixsies yet, NO fire. COSMIC CHUTE. jeffrey ALONE now, tumbling slowly in freefall through a long descending shaft, weightless, looking around in WONDER — eyes wide but mouth softening, no panic. around him: streams of glowing CODE-RAIN (yellow / red / purple / lime) on every wall of the chute, broken chunks of earth drifting alongside (a slice of studio floor, the macbook spinning slow, the coffee mug, pages of sheet music, the desk lamp still glowing). no other figures yet. camera: VERTICAL PAN, jeffrey centred in the upper third, debris in orbit, the chute walls receding into depth.`, +`BEAT — BRIDGE A "videocall the squad" · EMOTION: urgent excitement — calling friends to come over. ARC STAGE — eyes bright (no fire), felt clean, pixsies APPEAR for the first time but only as glow on jeffrey's face from his LAPTOP screen (not yet in the room), PALS not yet relevant. STUDIO-HOLE. jeffrey kneeling on the studio floor next to the glowing round hole, the hellfire glow under-lighting him from below, the cool desk-lamp + cold drone targeting beams from above — a two-tone light bath. + +★ DEVICE (mandatory): jeffrey is NOT holding a phone. He is HOLDING HIS OPEN CITRUS-GREEN MACBOOK NEO IN ONE HAND like a tray / oversized tablet — palm flat under the base, the lid open at ~110° standing up off his palm, screen facing TOWARD HIS FACE so he can see the video call. The LID-BACK (with the WHISTLEGRAPH BUTTERFLY scrap taped on its centre) is what faces the CAMERA. The LCD screen content (a multi-pane FACETIME-style GRID of 4-6 pixsie faces in their own warm-lit tiles — kid pixsie in lime PJs, elder pixsie in a cardigan, femme grad-student pixsie at her own kitchen counter, tactical-vest pixsie in a parked car, hot-pink-hair pixsie in a felt hoodie) is angled AWAY from camera behind the lid edge — we don't see the screen content directly, only the SCREEN-GLOW spilling onto jeffrey's face from the laptop's screen side, suggesting the call is in progress. NO PHONE anywhere in the frame. + +jeffrey is mid-SHOUT into the laptop, mouth open, eyes wide. His FREE HAND (the one NOT holding the laptop) is pointing DOWN at the glowing floor hole. Peer-horizontal energy — he's not commanding, he's calling friends in. + +He wears the canonical pale-baby-blue felt BUTTON-DOWN with darker-blue PINSTRIPES + yellow felt BEAR EMBLEM + yellow Sailor Pro Gear PEN at the pocket + RED PLASTIC GLASSES at the placket + cobalt felt trousers. All gear clearly visible. NOT a sweater, NOT a cable-knit, NOT a hoodie. + +Camera: medium three-quarter on jeffrey from his side — the green laptop LID-BACK with butterfly scrap turned toward us, jeffrey's face lit by the screen-glow on the far side of the lid, the glowing round floor hole visible behind/beside him.`, "bridge-b": -`BEAT — BRIDGE B "the pixsies arrive" · EMOTION: surprise + relief — finding companions. ARC STAGE — eyes wide-wonder, felt frayed, pixsies APPEAR for the first time (clean clothes, no fire), PALS dark. COSMIC CHUTE. THE FIRST PIXSIES EMERGE one by one through colored code-rain walls into the fall — a kid pixsie steps through the LIME stream, an elder through the VIOLET, a femme grad-student through the HOT-PINK, a tactical-vest pixsie through the GOLD. each joins the descent in their own felt outfit, drawn in from elsewhere, mid-step out of the wall. jeffrey turns toward them in surprise + relief. peer-horizontal formation already forming — none centred. soap bubbles starting to rise past them. camera: VERTICAL PAN, the fall now populated, jeffrey + arriving pixsies spread across the frame.`, +`BEAT — BRIDGE B "the squad busts in" · EMOTION: arrival energy — wide felt grins. ARC STAGE — eyes bright (no fire yet), felt clean, pixsies APPEAR in the room for the first time, each pixsie now carrying ONE AC laptop (PALS lids DARK), jeffrey also still has his MacBook Neo on the desk behind him. STUDIO-HOLE. the STUDIO DOOR explodes open — door slamming back on its hinges, splinters flying. 4-6 felt pixsies pile through the doorway in a jumbled wave, mid-stride, each carrying their own glossy plastic AC laptop in one hand with the PALS lid showing dark (not yet glowing). outfits: kid pixsie in lime PJs + sneakers, elder pixsie in a cardigan with cane raised, femme grad-student in cyberpunk techwear, tactical-vest pixsie in boots, hot-pink-hair pixsie in a felt hoodie, beanie pixsie clutching a felt coffee. they arrive READY — wide bright grins, eager eyes, peer-horizontal stack in the doorway. jeffrey is on his feet now, half-turned toward them, free arm thrown UP in greeting, the glowing floor hole still visible beside him. drones forgotten. camera: LOW + WIDE from inside the studio, the doorway bursting open on the left, jeffrey + the glowing hole on the right.`, "bridge-c": -`BEAT — BRIDGE C "warmth from below" · EMOTION: curiosity, anticipation, the first hint of grin. ARC STAGE — eyes wide-curious (no fire), felt hems beginning to fray more, pixsies present, PALS still dark, warm orange light on the underside of every face. COSMIC CHUTE. full crew falling together — jeffrey + 4-6 pixsies in loose orbit around each other and the orbiting earth-debris. soap bubbles rising thick now. WARM ORANGE LIGHT starting to leak in from BELOW the frame — they're approaching hellsine. felt hems beginning to fray from the descent. expressions: curiosity, anticipation, the first hint of grin. camera: VERTICAL PAN, the crew composed peer-horizontally, the bottom of frame washed warm orange.`, +`BEAT — BRIDGE C "around the hole" · EMOTION: shared awe, shared grin — the warmth on every face. ARC STAGE — eyes bright-wonder (no fire yet), felt clean, full crew present and ringed around the hole, PALS lids DARK but warm orange glow already painting every face from below. STUDIO-HOLE. the whole crew now ringed around the glowing round floor hole, peer-horizontal CIRCLE, jeffrey one member among them (NOT centred). they're leaning in, peering DOWN into hell — warm lava glow hitting every face from below, fingertips on the rim of the cut planks. one pixsie holds her AC laptop OUT over the hole letting the (still dark) PALS lid catch the orange light, one pixsie crouches with hands on knees grinning, the kid pixsie kneels rim-side eyes huge, the elder grips her cane. jeffrey is roughly opposite the kid, hand out as if counting it off. expressions: shared grins, shared awe, the room is hot. STILL no fire-eyes — but the warm glow already paints them all. camera: HIGH THREE-QUARTER on the hole, the ringed crew visible all the way around it.`, "bridge-d": -`BEAT — BRIDGE D "approach" · EMOTION: awe — the last moment before transformation. ARC STAGE — eyes wide-awe, felt frayed, pixsies present in soft focus, PALS still dark, warm orange underlight fully landed on faces. COSMIC CHUTE NEAR THE BOTTOM. TIGHT on jeffrey's face as the fall accelerates toward landing — warm orange light from below now FULLY ILLUMINATING his underside + chin + the underside of his felt hair, the cool blue cosmic-chute light fading behind him. eyes wide, mouth slightly open in awe. a single pixsie just visible in soft focus over his shoulder, also lit warm from below. soap bubbles streaming past. NO fire-eyes yet — but the warmth he's about to inherit is already on his face. camera: MEDIUM-CLOSE on jeffrey, three-quarter angle, the warm glow rising up the frame.`, +`BEAT — BRIDGE D "all in" · EMOTION: peer-horizontal cannonball — joy + commitment, no fear. ARC STAGE — eyes bright-wide (no fire yet), felt clean, the whole crew mid-air. TRANSITION (mid-fall through the hole). + +★ JEFFREY'S MACBOOK NEO IS ABSENT FROM THIS PANEL — it's not in his hands, not on his back, not spinning in the air. He left it behind in the studio. The pixsies are NOT carrying laptops either in this cannonball frame — hands are free for the leap. Render NO laptops anywhere in the frame. + +CANNONBALL — the entire crew launching INTO the hole together in one frozen instant mid-leap: feet off the floor, knees pulled up, arms thrown around each other's shoulders, hands FREE. jeffrey is ONE BODY in the group cluster, somewhere in the middle, NOT centred. hair lifted, eyes bright but still felt-clean — no fire yet. behind/above them the ruined studio doorway + ceiling recedes; below them the basalt + lava rivers already rushes up to meet them. peer-horizontal mid-air. soap bubbles beginning to rise from below past them. camera: FROM BELOW INSIDE THE HOLE looking UP at the falling cluster against the studio doorway light, hell-glow on undersides, the round disc of studio ceiling shrinking above.`, "develop-a": -`BEAT — DEVELOP A "impact" · EMOTION: caught breath, taking stock. ARC STAGE — eyes wide-stunned (no fire yet), small scorch marks just starting at felt cuffs, pixsies present, PALS dark, fingertips clean. HELLSCAPE FIRST ENTRY. they LAND on basalt. dust + heat-shimmer kicked up around their boots. jeffrey on his feet, knees flexed from the landing; the pixsies in a loose semicircle around him in various landing poses — one still mid-crouch, one standing tall already, one picking up the macbook neo that landed beside her. expressions: caught breath, taking stock. small scorch marks just starting at the cuffs from the warm air. camera: LOW + WIDE, the landing zone occupying the lower half, the hellscape just beginning to reveal above + behind.`, +`BEAT — DEVELOP A "splashdown" · EMOTION: shared whoop, caught between gasp + grin. ARC STAGE — eyes wide-mid-whoop (no fire yet), felt darkening at the waterline, pixsies present, fingertips clean. HELLSCAPE LAVA POOL. + +★ NO LAPTOPS IN THIS PANEL — jeffrey's MacBook Neo is NOT splashing down beside him. The pixsies aren't holding laptops either. Hands are free for the splash. Render NO laptops anywhere in the frame. + +IMPACT into a wide LAVA POOL. the crew hits the sinusoidal lava river in a huge spray — felt-textured splashes of glowing orange-red lava arcing up in fat droplets, slow-motion sheets cresting around each body. waves rolling outward in concentric rings. jeffrey + pixsies frozen mid-plunge, half-submerged at varied depths, expressions caught between gasp and grin — eyes wide, mouths OPEN in a shared whoop. NO fire-eyes yet — but the lava is up to their chests, lighting every felt face from within the pool. camera: LOW across the lava surface, multiple splashes filling the frame, basalt banks visible on the edges, the round studio-hole far above lost in the upward glare.`, "develop-b": -`BEAT — DEVELOP B "discovery" · EMOTION: discovery, taking in the world piece by piece. ARC STAGE — eyes wide-curious (no fire yet), small scorch marks at cuffs, PALS LIDS JUST FLICKERING ON (one or two glowing, rest still dark), fingertips clean. HELLSCAPE REVEALED. jeffrey + pixsies looking outward in different directions — discovering pieces of the world one at a time: one looks at a sine river curving past their boots, one watches the bullet train streaking the horizon, one points at the white horse galloping mid-distance, one tracks an ember drifting up. multi-hue matrix-rain falls through distant smoke columns. the pixsies hold their first laptops up (lids facing camera) — PALS only just FLICKERING ON, one or two glowing, rest still dark. camera: WIDE PANORAMA, hellsine landscape behind, group anchored lower-third.`, +`BEAT — DEVELOP B "swim in lava" · EMOTION: pure pool-day joy in hell. ARC STAGE — eyes laughing (no fire yet), felt wet + first chars at the waterline, pixsies in the lava pool with jeffrey, fingertips clean. HELLSCAPE LAVA POOL. + +★ NO LAPTOPS IN THIS PANEL — jeffrey has NO laptop floating beside him. The pixsies have no laptops either. They're swimming with FREE HANDS — the laptops are gone. Render NO laptops anywhere in the frame. + +the crew floats + strokes through the LAVA RIVER like it's a warm pool — the lava holds them up the way water would. ONE pixsie on her back floating with arms behind her head, eyes closed grinning. ONE pixsie doing a slow felt-arm backstroke, trailing a sine-shaped wake. the KID pixsie cannonball-bobs in the middle splashing the ELDER, who is splashing back. JEFFREY is treading lava beside another pixsie laughing, hands paddling the lava. tongues of sine-flame lick off the surface between them. the lava is their water. felt outfits darkening at the waterline + starting to char. wicked grins arriving but eyes still felt-clean. camera: LOW along the lava surface, swimming bodies arrayed peer-horizontally across the pool, sine ripples curving past them, basalt banks at the edges.`, "develop-c": -`BEAT — DEVELOP C "ignition" · EMOTION: wickedness arriving — wide-eyed grins forming, not full demon yet. ARC STAGE — FIRE IGNITES in eye sockets in a chain (jeffrey first, then each pixsie), ALL PALS LIDS now full-glowing in seven hues, FINGERTIPS BEGINNING TO SINGE with tiny live flames, felt damage taking root (first threads pulling loose). HELLSCAPE. FIRE IGNITES — first in jeffrey's eye sockets (one socket, then the other) and then in a CHAIN around the semicircle, each pixsie's eyes lighting in sequence. all the PALS lids now full-glowing in seven hues (cyan / magenta / lime / hot-pink / gold / orange / violet). fingertips beginning to singe, tiny flames flickering off felt fibres at the tips. wide-eyed grins forming — not full demon yet, but the wickedness is arriving. camera: TIGHTER on the group, mid-shot, the chain of ignition catchable across the frame, the lava world warm behind them.`, +`BEAT — DEVELOP C "ignition" · EMOTION: wickedness arriving — wide-eyed grins forming, not full demon yet. ARC STAGE — FIRE IGNITES in eye sockets in a chain (jeffrey first, then each pixsie), FINGERTIPS BEGINNING TO SINGE with tiny live flames, first scorch marks + frays. HELLSCAPE LAVA BANK. + +★ NO LAPTOPS IN THIS PANEL — the crew is rising from the lava with FREE HANDS, no laptops in sight. Jeffrey's MacBook Neo is gone. The pixsies' AC laptops are gone. Render NO laptops anywhere in the frame. (The PALS lids will reappear in climax.) + +the crew now CLIMBING OUT + RISING UP from the lava onto the basalt bank — a glowing orange water-line of lava drips off their felt as they emerge. AT THIS MOMENT the FIRE IGNITES — first in jeffrey's eye sockets (one socket, then the other) and then in a CHAIN around the crew, each pixsie's eyes lighting in sequence as they surface. fingertips beginning to singe, tiny flames flickering off felt fibres at the tips. wide-eyed grins forming — not full demon yet, but the wickedness is arriving. felt damage taking root: first threads pulling loose, first scorch marks. camera: ALONG THE BASALT BANK looking down the line of rising bodies, the chain of ignition catchable across the frame, the lava pool steaming behind them.`, "climax-a": `BEAT — CLIMAX A "the cover" (verbatim cover crop) · EMOTION: peak chaos, peak joy. ARC STAGE — eyes BLAZING with full live flames in sockets (no whites), all PALS lids full seven-hue glow, fingertips on fire with tiny flames + scorch marks + glowing ember cracks, felt TATTERED + char-marked. THE COVER. the smooshed-into-lens wide-angle group portrait from hellsine.illy.txt — jeffrey + pixsies dancing ON / OVER / AROUND active fire, BOTH PALMS UP at the lens, fire-eyes BLAZING, sinusoidal lava ribbons weaving between feet, sine-flame tongues dancing between faces, PALS lids glowing cyan / magenta / lime / hot-pink / gold / orange / violet, white horse + bullet train on the back horizon, multi-hue code-rain through smoke columns, soap bubbles rising, fingertip flames + scorched palms, tattered felt. peak chaos, peak joy, the PARTY. camera: THE COVER CROP VERBATIM — edge-to-edge, jeffrey at about 40% from left, smooshed into the lens.`, "climax-b": -`BEAT — CLIMAX B "from inside the dance" · EMOTION: peak, alternate vantage. ARC STAGE — same as climax-a (eyes blazing, PALS full, fingertip flames, felt tattered). THE SAME PARTY, ALTERNATE ANGLE. camera now BEHIND jeffrey, looking past his shoulder + raised palms OUT through the dancing crew. we see the backs of his hands silhouetted against the lava glow, the pixsies arrayed in front of him ALL looking toward the camera (i.e. toward jeffrey), fire-eyes blazing, raised PALS laptops a row of multi-hue lanterns. lava sines weaving between. same chaos, different vantage — proves the world wraps around the group. camera: OVER JEFFREY'S SHOULDER, depth into the crowd, his back-of-head + raised arms in shallow foreground.`, +`BEAT — CLIMAX B "the party is threatened" · EMOTION: peak joyful celebration disrupted — a sudden intrusion the crew is already pushing back against, FUN-CHAOTIC not scared. ARC STAGE — eyes blazing, PALS lids full seven-hue, fingertips bright, felt joyfully tattered. + +★ INTRUDER (mandatory): a SMALL IMPISH ROBOT DRONE has crashed the hellsine party — a chrome-and-glitch IMP MACHINE about the size of a beach ball, with: a leering robotic face (LED-glowing red eyes + a metal teeth grin), spindly insect-leg attack appendages clutching glowing cyber-spears, datamosh / CRT-glitch artifacts rippling visibly across its chassis (pixel-tearing, scan-lines, RGB-channel splits, jpeg-block ghosting), and a single trailing exhaust contrail. It's mid-air swooping over the party from upper-right, mouth open in a tinny screech. ONE pixsie security team member is mid-action LEAPING UP TO BAT IT AWAY with a sine-shaped lava blade / felt slingshot. ANOTHER pixsie hurls a glowing PALS-lid disc at it like a frisbee weapon. JEFFREY in the middle of the frame is still mid-dance but glancing UP at the imp with a wicked grin (the party doesn't stop), one fire-eye half-tracked on the threat. The OTHER pixsies keep dancing, mostly oblivious — the security team has it handled. + +WORLDLY-SHIFT FISH-EYE POV (inherits HELLSCAPE STYLE from WORLD LAW): strong wide-angle fish-eye distortion at edges, Dutch tilt, low chest-height POV like a Spike-Jonze skate-doc handheld cam right inside the dancing crew. The lens curve bows everything inward — sine lava rivers wrap around the lens, obsidian spires lean in, the imp drone reads BIG due to wide-angle proximity. + +The pixsies' raised PALS laptops glow cyan / magenta / lime / hot-pink / gold / orange / violet scattered across the frame. lava sines weaving gently between feet. peer-horizontal — jeffrey one member of the crew, NO ONE centred. Camera: low + inside the dancing circle, fish-eye POV.`, "climax-c": `BEAT — CLIMAX C "the wide vista" · EMOTION: a vast warm chaos with the crew at its heart. ARC STAGE — eyes blazing, PALS full, fingertip flames, felt tattered — but at scale now. PULL BACK to the WIDEST shot of the track — the dancing group is now SMALL in the lower-third of the frame, the FULL hellsine vista revealed around them: parallel sinusoidal lava rivers curving through the basalt foreground at varied amplitudes, obsidian spires receding into bruise-purple horizon, the white horse flaming across the mid-distance, the bullet train streaking the far horizon, multi-hue matrix-rain streaming through smoke columns on either side, soap bubbles + embers everywhere. the dancing crew is the warm pulsing nucleus inside a vast lava world. camera: VERY WIDE, low, the group anchored lower-centre, sky + vista filling the upper two-thirds.`, "coda-a": -`BEAT — CODA A "embers" · EMOTION: spent + content. ARC STAGE — eyes now just EMBERS (soft orange glow, no flames), felt fully tattered + scorched (but the damage settled), fingertips embers + scorch marks (no flames), PALS lids dimmed to a soft pulse, dawn light. HELLSCAPE AT DAWN. dawn breaking over the obsidian horizon — bruise-purple softening to deep coral, the lava glow halved, smoke columns thinning. the pixsies are scattered across the basalt — sat on rocks, leaning against each other, one stretched out flat looking up. spent and content. fire-eyes now just EMBERS — soft orange glow, no flames. felt is fully tattered + scorched but the heat is no longer hurting. PALS lids dimmed to a soft pulse. camera: WIDE, the spent crew arranged across the basalt, dawn filling the upper half.`, +`BEAT — CODA A "snow on the lava" · EMOTION: chilled-out afterglow with a cold edge — hell is cooling. ARC STAGE — eyes now just EMBERS (soft orange glow, no flames), felt fully tattered + scorched + DUSTED WITH SNOW, fingertips embers (no flames), PALS lids dimmed to a soft pulse. + +★ COOLING HELLSCAPE (mandatory): the hellscape is COOLING DOWN. The sky is now COLD STEEL-BLUE dawn instead of warm coral. Lava rivers are CRUSTING OVER with darker obsidian skin (the sine wave still readable but now half-frozen). SNOWFLAKES FALL gently from a thinning ashy sky and settle on the basalt + on the felt crew's shoulders + hair. Surviving lava patches glow dim red through frost. Smoke columns thin and white instead of black. The "magma planet" is becoming a frostbitten ash-volcanic plain. + +WORLDLY-SHIFT FISH-EYE POV (inherits HELLSCAPE STYLE from WORLD LAW): strong wide-angle fish-eye distortion at edges, Dutch tilt, low POV like a handheld skate-doc cam. + +The crew is JUST CHILLING across the basalt — sat on now-warm-but-cooling rocks, leaning back on elbows with snow on their shoulders, one stretched out flat looking up at the steel-blue sky (snowflakes settling on her face), one elder warming hands on a dim ember + watching snow fall, KID pixsie curled up dozing against an obsidian wedge with a felt scarf of snow across her shoulder. Jeffrey reclines against a warm rock among them (NOT centred), snowflakes on his hair + shoulders, fire-eyes dim. Spent and content, but now sharing warmth as the heat dies. nobody is posing; everyone is at rest. Camera: wide fish-eye, the chilled-out crew arranged peer-horizontally across the cooling basalt, steel-blue snowy dawn filling the upper half.`, "coda-b": -`BEAT — CODA B "they live here now" · EMOTION: calm settled smile, home found. ARC STAGE — eyes embers, felt tattered-settled, fingertips quiet embers, PALS soft pulse, MacBook Neo open + glowing softly. HELLSCAPE AT DAWN, INTIMATE. jeffrey sits on a low basalt outcrop in mid-shot, his CITRUS-GREEN MacBook Neo open across his lap — it fell with him, it's home too, the white-paper whistlegraph-butterfly scrap still on the lid, screen glowing softly with a kidlisp piece. ONE pixsie ASLEEP against his shoulder, embers in her eyes. the white horse a small silhouette on the obsidian behind him. the bullet train a thin red streak. steam vents puff slow + soft. jeffrey is smiling — NOT the wicked climax grin, a CALMER smile — they live here now. one last soap bubble rising past frame. camera: MEDIUM, jeffrey + sleeping pixsie + macbook in focus, softening hellscape in soft focus behind.`, +`BEAT — CODA B "back through the portal" · EMOTION: quiet exhausted relief — home. ARC STAGE — eyes back to normal felt (NO fire, NO embers) but with a faint warm reflection caught in them from the closing portal, felt fully tattered + char-scorched, snowflakes settled on shoulders + hair, PALS not in frame. + +★ MAJOR RESTAGE — JEFFREY HAS RETURNED TO HIS STUDIO. He has just stepped BACK through the round portal hole from hell into his felt-crafted studio zollo. He is ALONE now (the pixsies stayed in hell). He stands or sits softly in his studio, exhausted-but-home, the portal closing behind him. + +★ THE PORTAL: behind/beside jeffrey, the SAME ROUND HOLE in the wooden plank floor that was burned by the laser at the start — but now it is CLOSING UP, the ember-red edges shrinking as the wood seals itself, only a faint warm afterglow + a few last sparks rising. A few SNOWFLAKES drift up FROM the hole + a few EMBERS drift up too — both hell-snow and hell-embers leaking back into the studio in the moment before the portal seals. + +★ JEFFREY'S APPEARANCE — singed + snowy + alive: + · His pale-blue button-down with pinstripes is RAGGED + CHAR-SCORCHED at the hem + cuffs + collar (real felt damage), with a small frayed hole on one shoulder. + · The yellow Sailor Pro Gear pen + red plastic glasses are still in place at the pocket / placket. + · SNOWFLAKES are visibly settled on his SHOULDERS, on his BROWN FELT HAIR, on his EYEBROWS, slowly melting in the warm studio air. A few flakes drift down past him. + · A small streak of soot across one cheekbone, a smudge of grey ash on his trouser knee. + · His face wears a CALM relieved smile (NOT a wicked grin) — eyes back to normal felt, glistening. + · He carries his CITRUS-GREEN MACBOOK NEO under one arm — closed, butterfly scrap visible on the lid, slightly scorched but intact. + +★ THE STUDIO BEHIND HIM (back-to-earth): warm desk-lamp pool glowing on the desk, the AC poster on the wall, the plant, the wooden plank floor — exactly the studio from overture-a. The desk still has the toy felt BUNNY plushie, the jar of HONEY, the small stack of PENNIES (MONEY) — all untouched, waiting for him. Night sky out the broken-and-now-felt-patched window. NO drones, NO pixsies, NO hell — just home. Snowflakes drifting in the air around him as the portal seals. + +Camera: STANDARD lens (the world-shift returns to earth perspective — no more fish-eye), three-quarter medium shot with jeffrey foreground + the closing round portal-hole behind him slightly to one side, the warm studio interior framing the scene. NO motion blur. No pixsies in frame. The story comes full-circle: back to his desk, alive, changed by hell.`, }; // ── prompt construction ────────────────────────────────────────────── function build(sectionBeat) { const orient = LANDSCAPE ? `\n\n${LANDSCAPE_NOTE}` : `\n\n${PORTRAIT_NOTE}`; - return [MEDIUM, JEFFREY, PIXSIES, JEFFREY_LAPTOP, WORLD_LAW, ARCS, sectionBeat, PALETTE, AVOID].join("\n\n") + return [MEDIUM, JEFFREY, PIXSIES, JEFFREY_LAPTOP, DEVICE_ENGAGEMENT, WORLD_LAW, ARCS, sectionBeat, PALETTE, AVOID].join("\n\n") + orient + "\n"; } diff --git a/pop/hellsine/bin/hellsine.mjs b/pop/hellsine/bin/hellsine.mjs index 42b47d0f6..1b7407747 100644 --- a/pop/hellsine/bin/hellsine.mjs +++ b/pop/hellsine/bin/hellsine.mjs @@ -45,7 +45,7 @@ const BPM = Number(flags.bpm ?? 182); const HELL = Number(flags.hell ?? 11); // base gabber drive ("hell knob") const NOKICK = !!flags.nokick; // --nokick: drop the kick + its ducking const SEED_STR = flags.seed || "hellsine"; -const OUT = flags.out || `${process.env.HOME}/Documents/Working Desktop/hellsine/.hellsine-pre.wav`; +const OUT = flags.out || `${process.env.HOME}/Documents/Shelf/hellsine/.hellsine-pre.wav`; const STRUCT = flags.struct || `${OUT.replace(/\.wav$/, "")}.assets/struct.json`; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -475,6 +475,14 @@ function loadWavMono(path) { // `maxDurMs` caps the output length so the rattle stays contained // inside the kick's body. Routes to wet bus via wetSend like playSample. function playSampleSwept(t, buf, gain = 1, opt = {}) { + // Capture as an SFX event for visualizer lane (preview-score.mjs uses + // struct.events.sfx). Tag with the sample identifier when call sites + // pass one; otherwise default to "sfx" so the lane still records the + // strike time. Gate near-silent / muted plays so we don't pollute the + // lane with 0-gain skips. + if (gain > 0.02 && t >= 0 && t < totalSec) { + sfxEvents.push({ t: +t.toFixed(4), tag: opt.tag || "sfx" }); + } const startRate = opt.startRate || 1; const endRate = opt.endRate || startRate; const pan = Math.max(-1, Math.min(1, opt.pan || 0)); @@ -1030,7 +1038,7 @@ function riser(t, dur, m0, m1, gain = 0.26) { } // ── render the arrangement ──────────────────────────────────────────── -const kickEvents = [], snareEvents = [], sectionRanges = []; +const kickEvents = [], snareEvents = [], sfxEvents = [], sectionRanges = []; let bar = 0; for (const sec of PLAN) { const tr = sec.transpose || 0; @@ -3257,12 +3265,24 @@ writeFileSync(OUT, buf); // ── struct.json (scratch-mix grid + tooling) ────────────────────────── mkdirSync(dirname(STRUCT), { recursive: true }); +// SFX events are pushed in order-of-execution which may not be time- +// sorted (different sections of the engine schedule across overlapping +// time ranges). Sort chronologically + dedupe near-simultaneous hits +// from the same sample (within 8 ms) so the lane reads cleanly. +sfxEvents.sort((a, b) => a.t - b.t); +const _dedupedSfx = []; +for (const e of sfxEvents) { + const prev = _dedupedSfx[_dedupedSfx.length - 1]; + if (prev && prev.tag === e.tag && e.t - prev.t < 0.008) continue; + _dedupedSfx.push(e); +} + writeFileSync(STRUCT, JSON.stringify({ engine: "hellsine", allSine: true, meter: 4, bpm: BPM, scale: "minor", rootMidi: 50, totalBars: TOTAL_BARS, totalSec, sections: sectionRanges, - counts: { kick: kickEvents.length, snare: snareEvents.length }, - events: { kick: kickEvents, snare: snareEvents }, + counts: { kick: kickEvents.length, snare: snareEvents.length, sfx: _dedupedSfx.length }, + events: { kick: kickEvents, snare: snareEvents, sfx: _dedupedSfx }, }, null, 2)); console.log(`hellsine · ${BPM} BPM · hell=${HELL} · strategy=${STRATEGY} · ${TOTAL_BARS} bars · ${totalSec.toFixed(1)}s`); diff --git a/pop/hellsine/bin/preview-score-hellsine-yt.mjs b/pop/hellsine/bin/preview-score-hellsine-yt.mjs new file mode 100755 index 000000000..b751c7aca --- /dev/null +++ b/pop/hellsine/bin/preview-score-hellsine-yt.mjs @@ -0,0 +1,1447 @@ +#!/usr/bin/env node +// hellsine/bin/preview-score-hellsine-yt.mjs — landscape 1920x1080 cut. +// Mirror of preview-score.mjs (portrait) — same flame/sfx/punch-up +// pipeline — but consumes the `-yt-sec-*.png` landscape panel set and +// writes the YouTube-aspect output. +// +// Renders the 18 story panels (gen-sections.mjs) into a 9:16 portrait +// IG-Reel / Story mp4. Forked from marimba/bin/preview-score.mjs; +// adapted for hellsine's 2-lane percussion struct (kick + snare). +// +// Differences vs marimba: +// • Panel filename = hellsine-p-sec-NN-.png (2-digit zero-padded). +// • LANES = [kick, snare] only (struct.events only carries these two, +// each event is { t } — no pitch / dur / gain). We default missing +// fields so the lane renderer still works (fixed pitch + short dur). +// • punchTimes driven by kick onsets (was bass for marimba). +// • SECTION_TINTS expanded to 18 panels — lava arc (cool overture → +// red statement → cosmic bridge → kindling develop → full lava +// climax → dawn coda). +// • TITLE_PALETTE / BACKLIGHT swapped to lava warm. +// • FORMS skipped (no hellsine-forms.json) — falls back to whole- +// frame Ken-Burns. TODO: add pop/hellsine/hellsine-forms.json for +// proper face zooms. +// • 17 sub-section transition boundaries instead of marimba's 9. +// +// Usage: +// node pop/hellsine/bin/preview-score.mjs --reel +// node pop/hellsine/bin/preview-score.mjs --reel --start 110 --frames 60 + +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 || "hellsine"; +const TITLE = flags.title || SLUG; +const COVER = flags.cover || `${LANE}/out/${SLUG}-cover.png`; +const AUDIO = flags.audio || "/Users/jas/Documents/Shelf/hellsine/hellsine-c-MASTER.wav"; +const STRUCT = flags.struct || `${LANE}/${SLUG}.struct.json`; +const OUT = flags.out || `${LANE}/out/${SLUG}-preview-score-landscape.mp4`; +const FPS = Number(flags.fps ?? 30); +const SIZE = flags.size || "1920x1080"; +const [W, H] = SIZE.split("x").map(Number); +const REEL = false; // YT cut is always chromed (progress bar + timecode shown) +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(` build it: see pop/hellsine/bin/preview-score.mjs header`); + process.exit(1); + } +} + +const struct = JSON.parse(readFileSync(STRUCT, "utf8")); +const DURATION = struct.totalSec; +const FRAMES = Math.ceil(DURATION * FPS); +const SECTIONS = struct.sections.slice().sort((a, b) => a.startSec - b.startSec); +console.log(`▸ ${SLUG} storyline visualizer · ${W}x${H} · ${DURATION.toFixed(1)}s · ${FRAMES} frames · ${SECTIONS.length} sections`); + +// ── audio decode + envelope ────────────────────────────────────────── +console.log(" decoding audio …"); +const { audio, sr: audioSr, audioPeak } = decodeAudioMono(AUDIO, AUDIO_SR_DEFAULT); +const envelope = computeRmsEnvelope(audio, audioSr, FPS, DURATION); +function envAt(t) { + const idx = Math.floor(t * FPS); + return (idx < 0 || idx >= envelope.length) ? 0 : envelope[idx]; +} + +// ── voice lanes — hellsine's two percussion voices ─────────────────── +// kick + snare. struct.events. items are { t } only; we default +// pitch/dur/gain so the existing lane renderer (which expects those) +// degrades gracefully. +const LANES = [ + { key: "kick", color: "#ff5a1f" }, // lava orange — the hole kick + { key: "snare", color: "#ffd24a" }, // amber — the snare crack + { key: "sfx", color: "#c2a4ff" }, // soft violet — freesound SFX (blaster/clap/whip/etc.) +]; +const DEFAULT_PITCH = { kick: 36, snare: 60, sfx: 72 }; // midi numbers for pitch tinting +const DEFAULT_DUR = { kick: 0.18, snare: 0.12, sfx: 0.10 }; +const DEFAULT_GAIN = { kick: 0.85, snare: 0.65, sfx: 0.55 }; +const laneEvents = {}; +for (const L of LANES) { + const raw = (struct.events?.[L.key] || []).slice().sort((a, b) => a.t - b.t); + // backfill the marimba-shape fields the lane renderer expects + const evs = raw.map((e) => ({ + t: e.t, + midi: e.midi ?? e.pitch ?? DEFAULT_PITCH[L.key], + dur: e.dur ?? DEFAULT_DUR[L.key], + gain: e.gain ?? DEFAULT_GAIN[L.key], + })); + // STACKROW assignment (same algorithm as marimba) + 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} percussion events across ${LANES.length} voice lanes`); + +// kick onsets drive the "sharpen" punch envelope. +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]; +} + +// ── hellsine identity — LAVA ARC ───────────────────────────────────── +// 18 sub-panel tints arcing from cool-night earth → red strike → +// cosmic violet/cyan fall → kindling ignition → full lava peak → +// dawn coral morning-after. Hand-picked so each section's three or +// four sub-panels evolve through their own micro-arc too. +const SECTION_TINTS = { + // OVERTURE — cool, calm earth night, the moment before the strike. + "overture-a": "rgba(60,72,118,1)", // deep dusk indigo + "overture-b": "rgba(82,96,140,1)", // dusk blue + "overture-c": "rgba(118,130,170,1)", // pale dusk → about to break + // STATEMENT — the strike. Red alert. + "statement-a": "rgba(220,72,52,1)", // red alarm flare + "statement-b": "rgba(232,92,44,1)", // hot orange-red + "statement-c": "rgba(244,116,52,1)", // bright lava orange + // BRIDGE — the fall. Cosmic violet/cyan. + "bridge-a": "rgba(116,82,168,1)", // electric violet + "bridge-b": "rgba(96,108,184,1)", // violet → cyan transit + "bridge-c": "rgba(76,140,200,1)", // cool cyan plunge + "bridge-d": "rgba(96,168,196,1)", // pale aqua before impact + // DEVELOP — kindling. Ignition arc. + "develop-a": "rgba(196,116,60,1)", // amber kindling + "develop-b": "rgba(220,128,52,1)", // bright kindling + "develop-c": "rgba(240,144,48,1)", // hot ember + // CLIMAX — full lava-orange peak party. + "climax-a": "rgba(252,140,40,1)", // lava blaze + "climax-b": "rgba(255,108,32,1)", // peak orange + "climax-c": "rgba(248,80,40,1)", // saturated red-orange peak + // CODA — the morning after. Dawn coral. + "coda-a": "rgba(236,160,128,1)", // soft dawn coral + "coda-b": "rgba(228,182,160,1)", // pale ash-coral fade +}; +// Lava warm title palette. +const TITLE_PALETTE = ["#ff6a1f", "#ff8a3c", "#ffb24a", "#ffd24a", "#ffe8a8"]; +const BACKLIGHT_RGB = "255,128,48"; // deep lava amber + +// ── YWFT title + per-second timecode ───────────────────────────────── +console.log(" rasterizing YWFT …"); +const assetsDir = AUDIO.replace(/\.(mp3|wav|flac|aac|m4a)$/i, ".assets"); +mkdirSync(assetsDir, { recursive: true }); + +const titleFontSize = 96; +const { chars: titleChars, totalWidth: titleTotalW } = await prerenderTitleChars({ + text: TITLE, ptSize: titleFontSize, palette: TITLE_PALETTE, + shadowColor: null, assetsDir, +}); + +// Per-slide side-stamp suffix letter: each of the 18 panels gets its own +// lowercase letter (a..r) drawn inline after "hellsine" on the vertical +// stamp — same baseline + same size as the rest of the title text, so the +// stamp reads as `hellsine c` (a real AC piece prompt that jumps to the +// c section of hellsine.mjs). +const STAMP_LETTERS = "abcdefghijklmnopqrstuvwxyz".split("").slice(0, 26); +const stampLetterImgs = []; +for (let i = 0; i < Math.max(SECTIONS.length, 18); i++) { + const ch = STAMP_LETTERS[i] || "?"; + const img = await magickRenderText(ch, { + ptSize: titleFontSize, fill: "rgba(255,253,242,1)", + outPath: `${assetsDir}/stampletter.${ch}.png`, + }); + stampLetterImgs.push(img); +} +const PALS_S = 145; +const PALS_HALF = PALS_S / 2; +const PALS_EDGE_X = 100; +const CHARS_EDGE_X = PALS_EDGE_X + 12; +const CHAR_SCALE = 0.52; +const CHAR_SPAN = titleTotalW * CHAR_SCALE; +const BOUNCE_BUF = 26; +const LEFT_CHARS_CY = H * 0.82 - 16; +const RIGHT_CHARS_CY = H * 0.18 + 32 + 16; +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"); +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, ""); +} +function pad2(i) { return String(i).padStart(2, "0"); } +let coverImg = existsSync(COVER) ? await loadImage(COVER) : null; +const sectionImgs = []; +let haveImgs = 0; +for (let i = 0; i < SECTIONS.length; i++) { + // hellsine uses 2-digit zero-padded section indexes. + const p = `${LANE}/out/${SLUG}-yt-sec-${pad2(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/hellsine/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) ──────────────────────────────── +// TODO(hellsine): write pop/hellsine/hellsine-forms.json with per-panel +// face + figure boxes (group-portrait centred lower-middle figure, +// upper-middle face) for tighter Ken-Burns face zooms. For v1 we +// fall back to whole-frame Ken-Burns (empty FORMS → no faces → camera +// breathes between WHOLE_BOX and itself). +let FORMS = {}; +// YT cut intentionally skips the portrait forms.json (bboxes are in +// 1024x1536 coords; YT panels are 1536x1024). Fallback default-box logic +// in renderPanel will centre the backlight glow per panel. Adapt later +// if a `hellsine-yt-forms.json` lands. +try { + const fp = `${LANE}/${SLUG}-yt-forms.json`; + if (existsSync(fp)) FORMS = JSON.parse(readFileSync(fp, "utf8")).sections || {}; +} catch { /* no forms → centre fallback */ } +function _toBox(v) { + if (!v) return null; + if (Array.isArray(v)) return { x: v[0], y: v[1], w: v[2], h: v[3] }; + if (Array.isArray(v.figure)) return { x: v.figure[0], y: v.figure[1], w: v.figure[2], h: v.figure[3] }; + return null; +} +function _toFace(v) { + if (!v || Array.isArray(v)) return null; + if (Array.isArray(v.face)) return { x: v.face[0], y: v.face[1], w: v.face[2], h: v.face[3] }; + return null; +} +function figureBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return [_toBox(f.jeffrey), _toBox(f.gates)].filter(Boolean); +} +function faceBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return [_toFace(f.jeffrey), _toFace(f.gates)].filter(Boolean); +} +const FRAME_ASPECT = W / H; +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 }; +} +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 — side stamps ───────────────────────────────────── +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); + 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)]; +} +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), + ]; +} +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; + const u = audioT * FPS / FRAMES; + const TAU = Math.PI * 2; + const hue = ((u * 360 * 4) % 360 + 360) % 360; + 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), + ]; + 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 spots = [ + { cx: PALS_EDGE_X - wig, cy: LEFT_PALS_CY, rot: Math.PI / 2 + swiv }, + { cx: W - PALS_EDGE_X + wig, cy: RIGHT_PALS_CY, rot: -Math.PI / 2 - swiv }, + ]; + const passes = [ + ["multiply", 0.78], ["color-burn", 0.42], + ["overlay", 0.58], ["source-over", 0.06], + ]; + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.cx, sp.cy); + ctx.rotate(sp.rot); + palsTinted([0, 0, 0], palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(wmCanvas, -s / 2 + 3, -s / 2 + 4, s, s); + palsTinted(col, palsBlur); + for (const [op, a] of passes) { + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.30; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + if (glow > 0.001) { + palsTinted(ledCol, palsBlur); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.78 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + palsTinted(ledCol, palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.18 + 0.46 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + ctx.restore(); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + drawPalsTitleChars(audioT); +} +function drawPalsTitleChars(audioT) { + if (!palsImg) return; + const s = 145; + 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 charScale = CHAR_SCALE; + const span = titleTotalW * charScale; + const palsRgb = palsFrameColor(audioT); + const spots = [ + { charsCx: CHARS_EDGE_X - wig, cy: LEFT_CHARS_CY, rot: Math.PI / 2 }, + { charsCx: W - CHARS_EDGE_X + wig, cy: RIGHT_CHARS_CY, rot: -Math.PI / 2 }, + ]; + const startX = -span / 2; + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.charsCx, sp.cy); + ctx.rotate(sp.rot); + for (let i = 0; i < titleChars.length; i++) { + const ch = titleChars[i]; + if (!ch.img) continue; + const x = startX + ch.prefixWidth * 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; + 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(); + 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(); + } + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.46; + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + 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(); + } + } + // ── inline per-section letter (hellsine a … r) ─────────────────── + // Same baseline + same size as the title chars — reads as the AC + // piece prompt `hellsine c` that would jump to hellsine.mjs section c. + const secIdx = sectionIndexAt(audioT); + const letterImg = stampLetterImgs[secIdx]; + if (letterImg) { + const ldw = letterImg.width * charScale; + const ldh = letterImg.height * charScale; + // Use a normal inter-word space (~25% of the cap height) as the gap. + const gap = titleFontSize * 0.25 * charScale; + const lx = startX + span + gap; + const supEnv = envAt(audioT); + const lift = 4 * Math.sin(audioT * 4.0 + secIdx * 0.8) * (0.3 + supEnv); + const ly = -ldh / 2 + lift; + // Drop shadow (matches title chars). + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(tintCharGlyph(letterImg, [0, 0, 0]), lx + 3, ly + 4, ldw, ldh); + ctx.restore(); + // Color body, same multi-pass treatment as the title chars. + const passes = [ + ["multiply", 0.78], + ["color-burn", 0.42], + ["overlay", 0.58], + ["source-over", 0.06], + ]; + for (const [op, a] of passes) { + ctx.save(); + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(tintCharGlyph(letterImg, palsRgb), lx, ly, ldw, ldh); + ctx.restore(); + } + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.46; + ctx.drawImage(tintCharGlyph(letterImg, palsRgb), lx, ly, ldw, ldh); + ctx.restore(); + if (supEnv > 0.45) { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.46 * Math.min(1, (supEnv - 0.45) / 0.55); + ctx.drawImage(tintCharGlyph(letterImg, palsRgb), lx, ly, ldw, ldh); + ctx.restore(); + } + } + ctx.restore(); + } +} +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 = 220; // faster scroll — hellsine is 182 bpm +const _vs = makeVerletString(ctx, { W, H, playheadX: PLAYHEAD_X, duration: DURATION }); + +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; }); + +const GROOVE_R = Math.round(Math.min(W, H) * 0.85); + +// ── illustration distortion UNDER the bent string ─────────────────── +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; + wuSnapC.clearRect(0, 0, W, H); + wuSnapC.drawImage(canvas, 0, 0); + 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); + 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 ──────────────────────────────────────────────── +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; + 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; +} +const glowCanvas = createCanvas(W, H); +const glowCtx = glowCanvas.getContext("2d"); +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 lava-shadow surround — deep amber/maroon instead of neutral. + vg.addColorStop(0.45, `rgba(140,60,32,${(0.62 + 0.12 * intensity).toFixed(3)})`); + vg.addColorStop(1, `rgba(28,10,4,${(0.94).toFixed(3)})`); + c.fillStyle = vg; + c.fillRect(0, 0, W, H); + c.restore(); + c.globalCompositeOperation = "source-over"; +} +// When no faces are available (we have no forms.json), the transmitted +// + leaded backlights fall through (their guard returns early). The +// vignette still bakes the heat in, and the panel itself reads as the +// scene. TODO(hellsine): a centred default face box per panel would let +// the transmitted layer still glow through the panel. +function drawTransmittedBacklight(c, sectionImg, xform, faces, intensity, audioT = 0) { + if (!faces || !faces.length || !xform || intensity <= 0.01) return; + const k = Math.min(1, intensity); + 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); + 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(${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), + ); + } + 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"; + 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"; +} +function drawLeadedContrast(c, sectionImg, xform, faces, intensity, audioT = 0) { + if (!faces || !faces.length || !xform || intensity <= 0.01) return; + const k = Math.min(1, intensity); + 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(58,28,16,${(0.55 * k).toFixed(3)})`); + g.addColorStop(0.50, `rgba(58,28,16,${(0.30 * k).toFixed(3)})`); + g.addColorStop(1.0, "rgba(58,28,16,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), + ); + } + 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 ───────────────────────────────────────────────────── +const KB = { breathAmp: 0.05, breathPeriodSec: 18, wobbleAmp: 4, envWobbleAmp: 0, zoomPad: 1.0 }; +const ZOOM_CYCLES = 4; // a bit more breath over the shorter / harder track +function zoomAt(audioT) { + const swing = 0.5 - 0.5 * Math.cos(2 * Math.PI * ZOOM_CYCLES * audioT / DURATION); + return 0.04 + 0.30 * swing; +} +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, + }); +} +// Default face/figure box centred upper-mid for panels without a +// hellsine-forms.json entry. Lets the transmitted backlight + leaded +// contrast layers still fire — the 3-layer separation glow that other +// pop releases (trancepenta etc.) use. +function defaultFigureBox() { + return { x: imgW * 0.18, y: imgH * 0.16, w: imgW * 0.64, h: imgH * 0.66 }; +} +function defaultFaceBox() { + return { x: imgW * 0.30, y: imgH * 0.22, w: imgW * 0.40, h: imgH * 0.30 }; +} + +// Punch-up pass — push chromatic richness + contrast WITHOUT lifting +// the panel's blacks. The earlier "screen" pass crept into shadow +// territory and washed out the panels; replaced with black-preserving +// blend modes ("color" shifts hue+saturation only, "soft-light" gently +// pushes contrast and preserves both blacks AND whites). The lava +// bloom is gated through the panel's own bright-region mask so it +// only paints where there's already light to push. +function drawPanelPunchUp(c, idx, audioT, env, punch, sectionImg, xform) { + const [sr, sg, sb] = tintRgb(SECTION_TINTS[SECTIONS[idx].name] || "rgba(255,140,40,1)"); + const heat = Math.min(1, env * 0.9 + punch * 0.5); + // "color" — shifts hue+saturation toward the section tint; preserves + // destination luma so blacks stay black and whites stay white. + c.save(); + c.globalCompositeOperation = "color"; + c.globalAlpha = 0.18 + 0.22 * heat; + c.fillStyle = `rgba(${sr},${sg},${sb},1)`; + c.fillRect(0, 0, W, H); + c.restore(); + // "soft-light" — gentle contrast push without crushing blacks or + // blowing highlights (much gentler than overlay, much darker-preserving + // than screen). + c.save(); + c.globalCompositeOperation = "soft-light"; + c.globalAlpha = 0.32 + 0.34 * heat; + c.fillStyle = `rgba(${sr},${sg},${sb},1)`; + c.fillRect(0, 0, W, H); + c.restore(); + // Lava bloom on the lower half — gated by the panel's own transmission + // mask so dark pixels stay dark (only existing light gets amplified). + if (heat > 0.03 && sectionImg && xform) { + glowCtx.globalCompositeOperation = "source-over"; + glowCtx.clearRect(0, 0, W, H); + const g = glowCtx.createLinearGradient(0, H * 0.50, 0, H); + g.addColorStop(0, `rgba(255,120, 32,0)`); + g.addColorStop(1, `rgba(255,120, 32,${(0.32 * heat).toFixed(3)})`); + glowCtx.fillStyle = g; + glowCtx.fillRect(0, H * 0.50, W, H * 0.50); + // Gate by the panel's bright-area mask. + 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"; + c.save(); + c.globalCompositeOperation = "lighter"; + c.drawImage(glowCanvas, 0, 0); + c.restore(); + } + c.globalCompositeOperation = "source-over"; +} + +function renderPanel(c, idx, audioT, env, punch) { + const name = SECTIONS[idx].name; + let figs = figureBoxes(name); + let faces = faceBoxes(name); + // Fallback when no hellsine-forms.json: synthesize a centred default + // figure + face box so the transmitted / leaded backlight layers can + // glow on the panel (matches the trancepenta 3-layer style). + if (!figs.length) figs = [defaultFigureBox()]; + if (!faces.length) faces = [defaultFaceBox()]; + 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, + }); + 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) + ); + // 3-layer stained-glass intensities — pushed harder so the transmitted + // (face/lava lit-through) + leaded (linework/darks) layers actually + // sharpen the panel instead of being a faint wash. + const v_i = (0.85 + 0.55 * env) * flicker; + const t_i = (0.45 + 1.40 * env + 1.10 * punch) * flicker; + const l_i = (0.30 + 1.10 * env + 0.80 * punch) * flicker; + drawPanelPunchUp(c, idx, audioT, env, punch, sectionImgs[idx], xform); + drawTransmittedBacklight(c, sectionImgs[idx], xform, faces, t_i, audioT); + drawLeadedContrast(c, sectionImgs[idx], xform, faces, l_i, audioT); + drawVignette(c, v_i); + return xform; +} + +// ── TRANSITIONS — 6 types cycled across 17 sub-section boundaries ──── +const TRANS_S = 0.85; +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) { + 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") { + 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 { + 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; +} + +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); + 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; + 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); + const rowIdx = Math.min(ev.stackRow ?? 0, visibleRows - 1); + const yC = yCBase - (LANE_BOTTOM - LANE_TOP) / LANES.length / 2 + + subH / 2 + rowIdx * subH; + 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); + const nrgb = midiToNotepatRgb(ev.midi); + const cr = Math.round(nrgb[0] * 0.48 + laneRgb[0] * 0.52); + const cg = Math.round(nrgb[1] * 0.48 + laneRgb[1] * 0.52); + const cb = Math.round(nrgb[2] * 0.48 + laneRgb[2] * 0.52); + const rgb = `${cr},${cg},${cb}`; + 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; + 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) / Math.max(1, 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; + else if (dt < 0.05) alpha = 1.0; + else if (dt < 0.45) alpha = 1.0 - (dt - 0.05) / 0.40 * 0.55; + else alpha = 0.42; + alpha *= baseAlpha; + const half = Math.max(2, (pk * fullH) / 2); + const bx = ex + (c / cols) * ew + bend; + 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"; +} + +function drawStringGlow(env, audioT) { + const [r, g, b] = palsFrameColor(audioT); + ctx.save(); + ctx.lineCap = "round"; + 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(); + 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"; +} + +// Physical pixel-flame + smoke — when a percussion event reaches the +// playhead (the verlet string), spawn a deterministic pixel-particle +// burst at that lane's y on the string. Particles obey a tiny physics +// model (initial upward thrust + drag + turbulent x-jitter), are +// rendered as snapped chunky pixels (not anti-aliased circles), and +// shift colour by temperature (white-hot → yellow → orange → red → +// embers). A delayed smoke burst trails each flame, source-over grey +// pixels expanding + dissipating upward. +// +// Deterministic spawn: every particle's per-frame state is a pure +// function of (eventTime, particleIdx, audioT) — no per-frame RNG, so +// re-renders are bit-identical and we never accumulate state leakage. +const PIX_GRID = 3; // pixel-block size for the flame (small + crisp) +const PIX_GRID_SMOKE = 5; // smoke uses chunkier pixels +const FLAME_LIFE = 1.80; // base seconds per particle (env-scaled per hit) +const SMOKE_LIFE = 2.40; // seconds per smoke particle +const SMOKE_DELAY = 0.22; // seconds before smoke spawns after a hit +const FLAMES_PER_HIT = 72; +const SMOKES_PER_HIT = 22; +const FLASH_LIFE = 0.15; // bright disc burst at hit moment +const EMITTER_SPREAD = 0; // all particles spawn at the exact activation point on the string +const HIT_WINDOW = Math.max(FLAME_LIFE * 1.5, SMOKE_DELAY + SMOKE_LIFE); +// Per-lane base flame colour — pulled from LANES.color so kick flames +// read red-orange (#ff5a1f) and snare flames read amber-gold (#ffd24a), +// matching their waveform cells on the lane strip. +const LANE_FLAME_RGB = {}; +for (const L of LANES) LANE_FLAME_RGB[L.key] = hexToRgb(L.color); + +const ringSpawnCursor = { kick: 0, snare: 0, sfx: 0 }; +const activeRings = []; // { spawnT, lane, sx, sy } + +function _hash01(seed) { + const s = Math.sin(seed * 9301.1 + 49297.7) * 233280.0; + return s - Math.floor(s); +} + +// Compute the SCREEN-space spawn point for a hit at (spawnT, laneKey). +// At the moment a kick/snare event fires, the string is rotated by +// theta(spawnT). Particles need to anchor to that rotated screen +// position and then rise STRAIGHT UP in screen coordinates (global +// vertical gravity) — the string keeps rotating, but the flame stays +// where it bloomed. This means flame draw happens OUTSIDE withRotation. +function _spawnScreenPos(audioT, laneKey, ev) { + // Use the CURRENT frame's theta — events can fire just before this + // frame's audioT, but we want the spawn at the VISIBLE string position + // right now (where the cell visually crosses the string this frame). + const theta = (audioT / DURATION) * Math.PI * 2; + // CRITICAL: cells are NOT drawn at laneCenterY. drawLanes uses + // yC = yCBase - LANE_H/2 + subH/2 + stackRow * subH (multi-row stack). + // Spawning at laneCenterY misses cells in row > 0 by up to LANE_H/2. + // Mirror drawLanes' formula here so the flame lands on the cell. + const L = LANES.find((x) => x.key === laneKey) || LANES[0]; + const visibleRows = Math.min(L.maxStackRows ?? 1, 3); + const yCBase = laneCenterY[laneKey]; + const laneSpan = (LANE_BOTTOM - LANE_TOP) / LANES.length; + const subH = laneSpan / visibleRows; + const rowIdx = Math.min((ev && ev.stackRow) ?? 0, visibleRows - 1); + const yU = yCBase - laneSpan / 2 + subH / 2 + rowIdx * subH; + // Match the cell rendering's half-bend offset (see drawLanes line ~988: + // `bend = (needleXAt(yC) - PLAYHEAD_X) * 0.5`). + const halfBend = (needleXAt(yU) - PLAYHEAD_X) * 0.5; + const xU = PLAYHEAD_X + halfBend; + const dx = xU - ROT_CX; + const dy = yU - ROT_CY; + const cT = Math.cos(theta), sT = Math.sin(theta); + return { + x: ROT_CX + dx * cT - dy * sT, + y: ROT_CY + dx * sT + dy * cT, + }; +} + +function drawStringFireRings(audioT) { + // Spawn rings for events that have crossed the playhead. Each ring + // stashes its screen-space spawn position once (after rotation at + // spawn time) — particles then rise from that fixed point. + for (const lane of ["kick", "snare", "sfx"]) { + const evs = laneEvents[lane] || []; + while ( + ringSpawnCursor[lane] < evs.length && + evs[ringSpawnCursor[lane]].t <= audioT + ) { + const ev = evs[ringSpawnCursor[lane]]; + if (audioT - ev.t <= HIT_WINDOW) { + const p = _spawnScreenPos(audioT, lane, ev); + const theta = (audioT / DURATION) * Math.PI * 2; + // Emitter geometry rotates with the string slope: tangent + // direction (along the string) at this lane is the rotated + // vertical (0,1) → (-sin θ, cos θ). Particles fan out along + // THIS direction at spawn time, then rise in screen-vertical. + const tx = -Math.sin(theta); + const ty = Math.cos(theta); + // env-at-spawn modulates intensity per hit (louder → longer + + // wider + more dynamic flames). + const eAtSpawn = Math.min(1, envAt(audioT) * 1.5); + activeRings.push({ + spawnT: ev.t, lane, + sx: p.x, sy: p.y, + tx, ty, + env: eAtSpawn, + }); + } + ringSpawnCursor[lane]++; + } + } + // Cull old rings. + while (activeRings.length && audioT - activeRings[0].spawnT > HIT_WINDOW) { + activeRings.shift(); + } + if (!activeRings.length) return; + + // ── FLASH PASS — bright hot disc at each hit moment, fades fast ───── + ctx.save(); + ctx.globalCompositeOperation = "screen"; + for (const ring of activeRings) { + const age = audioT - ring.spawnT; + if (age < 0 || age > FLASH_LIFE) continue; + const k = 1 - age / FLASH_LIFE; + const [lr, lg, lb] = LANE_FLAME_RGB[ring.lane] || [255, 140, 40]; + const r = 18 + 32 * (1 - k); + // Radial gradient with hot core, at the screen-space spawn anchor. + const grad = ctx.createRadialGradient(ring.sx, ring.sy, 0, ring.sx, ring.sy, r); + grad.addColorStop(0, `rgba(255,250,220,${(0.95 * k).toFixed(3)})`); + grad.addColorStop(0.35,`rgba(${lr},${lg},${lb},${(0.70 * k).toFixed(3)})`); + grad.addColorStop(1, `rgba(${lr},${lg},${lb},0)`); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(ring.sx, ring.sy, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + + // ── FLAME PASS — screen composite (additive light) ─────────────────── + ctx.save(); + ctx.globalCompositeOperation = "screen"; + for (const ring of activeRings) { + // Per-lane base colour — kick lane (#ff5a1f) burns red-orange, snare + // (#ffd24a) burns amber-gold. The temperature ramp interpolates from + // white-hot → lane colour → ember. + const [lr, lg, lb] = LANE_FLAME_RGB[ring.lane] || [255, 140, 40]; + // Per-ring intensity — louder hits → longer flames, wider emitter, + // more dynamic velocity. + const intensity = 0.5 + ring.env; // 0.5..1.5 + const ringLife = FLAME_LIFE * (0.65 + 0.70 * ring.env); // 1.17..1.97s + const spread = EMITTER_SPREAD * (0.55 + 0.95 * ring.env); // ~50..140px + for (let i = 0; i < FLAMES_PER_HIT; i++) { + const seedA = ring.spawnT * 17 + i * 0.137; + const seedB = ring.spawnT * 23 + i * 0.413; + const seedC = ring.spawnT * 29 + i * 0.911; + const h1 = _hash01(seedA); + const h2 = _hash01(seedB); + const h3 = _hash01(seedC); + const stagger = h1 * 0.10; // 0–100ms spawn stagger + const age = audioT - ring.spawnT - stagger; + if (age <= 0 || age >= ringLife) continue; + const life01 = age / ringLife; + + // EMITTER: spawn position fans out ALONG the string tangent at + // spawn — emitter geometry rotates WITH the string slope. Bell- + // curve falloff so most particles cluster near the centre. + const tOff = (h3 - 0.5) * spread * (1 - 0.3 * Math.abs(h3 - 0.5)); + const sx0 = ring.sx + ring.tx * tOff; + const sy0 = ring.sy + ring.ty * tOff; + + // Physics: vertical thrust + lateral wobble + envelope-modulated + // velocity range. Loud hits = much taller flames, more burst. + const burst = h1 > 0.85 ? 2.0 : 1.0; // 15% fast jets + const vy0 = -(160 + h1 * 320) * burst * intensity; // px/s upward + const vx0 = (h2 - 0.5) * 40 * (1 + h1 * 0.4); // narrow lateral + const drag = Math.exp(-age * (0.45 + h2 * 0.55)); // per-particle drag + const turbulence = + Math.sin(age * 9.0 + h1 * 11) * 10 * (1 - life01 * 0.3) + + Math.sin(age * 19.0 + h2 * 17) * 5 * (1 - life01 * 0.5) + + Math.sin(age * 31.0 + h1 * 23) * 2 * (1 - life01 * 0.7); + const x = sx0 + vx0 * age * drag + turbulence; + const y = sy0 + vy0 * age * drag + 22 * age * age; + + // Pixel-grid snap (chunky look). + const px = Math.floor(x / PIX_GRID) * PIX_GRID; + const py = Math.floor(y / PIX_GRID) * PIX_GRID; + + // Temperature ramp blended through the lane colour. + // 0..0.18 white-hot core + // 0.18..0.45 lane colour brightened (mix toward white) + // 0.45..0.75 pure lane colour + // 0.75..0.92 lane colour dimmed (mix toward dark-red) + // 0.92..1 ember fade + let r, g, b; + if (life01 < 0.18) { + r = 255; g = 250; b = 220; + } else if (life01 < 0.45) { + // brighten lane colour toward white-hot + const k = (0.45 - life01) / 0.27; // 1→0 across this band + r = Math.round(lr + (255 - lr) * k * 0.6); + g = Math.round(lg + (255 - lg) * k * 0.6); + b = Math.round(lb + (220 - lb) * k * 0.6); + } else if (life01 < 0.75) { + r = lr; g = lg; b = lb; + } else if (life01 < 0.92) { + const k = (life01 - 0.75) / 0.17; // 0→1 + r = Math.round(lr * (1 - k) + 130 * k); + g = Math.round(lg * (1 - k) + 30 * k); + b = Math.round(lb * (1 - k) + 12 * k); + } else { + r = 90; g = 20; b = 8; + } + + const aa = Math.pow(1 - life01, 1.05) * 0.95; + const size = PIX_GRID + (life01 < 0.4 ? PIX_GRID : 0); // brighter cores are 2×2 blocks + ctx.fillStyle = `rgba(${r},${g},${b},${aa.toFixed(3)})`; + ctx.fillRect(px, py, size, size); + } + } + ctx.restore(); + + // ── SMOKE PASS — source-over, low alpha grey pixels rising slowly ── + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + for (const ring of activeRings) { + const xRingC = ring.sx; + for (let i = 0; i < SMOKES_PER_HIT; i++) { + const seedA = ring.spawnT * 29 + i * 0.731 + 7777; + const seedB = ring.spawnT * 37 + i * 0.913 + 8888; + const h1 = _hash01(seedA); + const h2 = _hash01(seedB); + const age = audioT - ring.spawnT - SMOKE_DELAY - h1 * 0.20; + if (age <= 0 || age >= SMOKE_LIFE) continue; + const life01 = age / SMOKE_LIFE; + + // Slower rise + wider lateral drift. + const vy0 = -(28 + h1 * 36); + const vx0 = (h2 - 0.5) * 26; + const turbulence = Math.sin(age * 3 + h1 * 7) * 18 * Math.min(1, age * 0.5); + const x = xRingC + vx0 * age + turbulence; + const y = ring.sy + vy0 * age; + + const px = Math.floor(x / PIX_GRID_SMOKE) * PIX_GRID_SMOKE; + const py = Math.floor(y / PIX_GRID_SMOKE) * PIX_GRID_SMOKE; + + // Warm dark grey → cool light grey as it dissipates. + const grey = Math.round(48 + life01 * 110); + const aa = Math.sin(life01 * Math.PI) * 0.32; // ramp-up-then-fade + const size = PIX_GRID_SMOKE + Math.floor(life01 * 14); // expands + + ctx.fillStyle = `rgba(${grey},${Math.round(grey * 0.93)},${Math.round(grey * 0.86)},${aa.toFixed(3)})`; + ctx.fillRect(px, py, size, size); + } + } + ctx.restore(); +} + +// ── 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 }); +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" : (REEL ? "reel" : "insta-story")} · ${endFrame - startFrame} frames` }); +console.log(` rendering frames ${startFrame}..${endFrame - 1} (${endFrame - startFrame} frames)${TEST_MODE ? " → /tmp/hellsine-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); + + for (const L of LANES) { + const yC = laneCenterY[L.key]; + const lrgb = hexToRgb(L.color); + let amp = L.key === "kick" ? 22 : 11; + 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(); + + const idx = sectionIndexAt(audioT); + const sec = SECTIONS[idx]; + const since = audioT - sec.startSec; + if (flags.debug) { + // Debug mode: panel replaced with solid black so flame/string + // alignment is unambiguous against a flat background. + ctx.fillStyle = "rgb(0,0,0)"; + ctx.fillRect(0, 0, W, H); + } else if (idx > 0 && since >= 0 && since < TRANS_S) { + let p = since / TRANS_S; + p = p * p * (3 - 2 * p); + 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); + } + + const theta = (audioT / DURATION) * Math.PI * 2; + warpUnderString(theta); + _vs.withRotation(theta, () => { + drawLanes(audioT); + _vs.draw(); + drawStringGlow(env, audioT); + }); + // Flame + smoke render in SCREEN space (no rotation transform), so + // particles anchor to the rotated string at spawn time then rise with + // global vertical gravity regardless of further string rotation. + drawStringFireRings(audioT); + + drawWatermark(audioT); + // Progress bar in BOTH reel + insta-story (per-section colored bar, + // no timecode). Timecode stays insta-story-only — the reel format + // doesn't want digits ticking under the action. + drawProgressBar(audioT); + if (!REEL) drawTimecode(audioT); + + if (TEST_MODE) { + const png = canvas.toBuffer("image/png"); + const fname = `/tmp/hellsine-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/hellsine/bin/preview-score.mjs b/pop/hellsine/bin/preview-score.mjs new file mode 100644 index 000000000..be4c6dc11 --- /dev/null +++ b/pop/hellsine/bin/preview-score.mjs @@ -0,0 +1,1442 @@ +#!/usr/bin/env node +// hellsine/bin/preview-score.mjs — storyline visualizer for hellsine. +// +// Renders the 18 story panels (gen-sections.mjs) into a 9:16 portrait +// IG-Reel / Story mp4. Forked from marimba/bin/preview-score.mjs; +// adapted for hellsine's 2-lane percussion struct (kick + snare). +// +// Differences vs marimba: +// • Panel filename = hellsine-p-sec-NN-.png (2-digit zero-padded). +// • LANES = [kick, snare] only (struct.events only carries these two, +// each event is { t } — no pitch / dur / gain). We default missing +// fields so the lane renderer still works (fixed pitch + short dur). +// • punchTimes driven by kick onsets (was bass for marimba). +// • SECTION_TINTS expanded to 18 panels — lava arc (cool overture → +// red statement → cosmic bridge → kindling develop → full lava +// climax → dawn coda). +// • TITLE_PALETTE / BACKLIGHT swapped to lava warm. +// • FORMS skipped (no hellsine-forms.json) — falls back to whole- +// frame Ken-Burns. TODO: add pop/hellsine/hellsine-forms.json for +// proper face zooms. +// • 17 sub-section transition boundaries instead of marimba's 9. +// +// Usage: +// node pop/hellsine/bin/preview-score.mjs --reel +// node pop/hellsine/bin/preview-score.mjs --reel --start 110 --frames 60 + +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 || "hellsine"; +const TITLE = flags.title || SLUG; +const COVER = flags.cover || `${LANE}/out/${SLUG}-cover.png`; +const AUDIO = flags.audio || "/Users/jas/Documents/Shelf/hellsine/hellsine-c-MASTER.wav"; +const STRUCT = flags.struct || `${LANE}/${SLUG}.struct.json`; +const _isReel = flags.reel === true; +const OUT = flags.out + || `${LANE}/out/${SLUG}-preview-score-portrait-${_isReel ? "reel" : "insta-story"}.mp4`; +const FPS = Number(flags.fps ?? 30); +const SIZE = flags.size || "1080x1920"; +const [W, H] = SIZE.split("x").map(Number); +const REEL = flags.reel === true; +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(` build it: see pop/hellsine/bin/preview-score.mjs header`); + process.exit(1); + } +} + +const struct = JSON.parse(readFileSync(STRUCT, "utf8")); +const DURATION = struct.totalSec; +const FRAMES = Math.ceil(DURATION * FPS); +const SECTIONS = struct.sections.slice().sort((a, b) => a.startSec - b.startSec); +console.log(`▸ ${SLUG} storyline visualizer · ${W}x${H} · ${DURATION.toFixed(1)}s · ${FRAMES} frames · ${SECTIONS.length} sections`); + +// ── audio decode + envelope ────────────────────────────────────────── +console.log(" decoding audio …"); +const { audio, sr: audioSr, audioPeak } = decodeAudioMono(AUDIO, AUDIO_SR_DEFAULT); +const envelope = computeRmsEnvelope(audio, audioSr, FPS, DURATION); +function envAt(t) { + const idx = Math.floor(t * FPS); + return (idx < 0 || idx >= envelope.length) ? 0 : envelope[idx]; +} + +// ── voice lanes — hellsine's two percussion voices ─────────────────── +// kick + snare. struct.events. items are { t } only; we default +// pitch/dur/gain so the existing lane renderer (which expects those) +// degrades gracefully. +const LANES = [ + { key: "kick", color: "#ff5a1f" }, // lava orange — the hole kick + { key: "snare", color: "#ffd24a" }, // amber — the snare crack + { key: "sfx", color: "#c2a4ff" }, // soft violet — freesound SFX (blaster/clap/whip/etc.) +]; +const DEFAULT_PITCH = { kick: 36, snare: 60, sfx: 72 }; // midi numbers for pitch tinting +const DEFAULT_DUR = { kick: 0.18, snare: 0.12, sfx: 0.10 }; +const DEFAULT_GAIN = { kick: 0.85, snare: 0.65, sfx: 0.55 }; +const laneEvents = {}; +for (const L of LANES) { + const raw = (struct.events?.[L.key] || []).slice().sort((a, b) => a.t - b.t); + // backfill the marimba-shape fields the lane renderer expects + const evs = raw.map((e) => ({ + t: e.t, + midi: e.midi ?? e.pitch ?? DEFAULT_PITCH[L.key], + dur: e.dur ?? DEFAULT_DUR[L.key], + gain: e.gain ?? DEFAULT_GAIN[L.key], + })); + // STACKROW assignment (same algorithm as marimba) + 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} percussion events across ${LANES.length} voice lanes`); + +// kick onsets drive the "sharpen" punch envelope. +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]; +} + +// ── hellsine identity — LAVA ARC ───────────────────────────────────── +// 18 sub-panel tints arcing from cool-night earth → red strike → +// cosmic violet/cyan fall → kindling ignition → full lava peak → +// dawn coral morning-after. Hand-picked so each section's three or +// four sub-panels evolve through their own micro-arc too. +const SECTION_TINTS = { + // OVERTURE — cool, calm earth night, the moment before the strike. + "overture-a": "rgba(60,72,118,1)", // deep dusk indigo + "overture-b": "rgba(82,96,140,1)", // dusk blue + "overture-c": "rgba(118,130,170,1)", // pale dusk → about to break + // STATEMENT — the strike. Red alert. + "statement-a": "rgba(220,72,52,1)", // red alarm flare + "statement-b": "rgba(232,92,44,1)", // hot orange-red + "statement-c": "rgba(244,116,52,1)", // bright lava orange + // BRIDGE — the fall. Cosmic violet/cyan. + "bridge-a": "rgba(116,82,168,1)", // electric violet + "bridge-b": "rgba(96,108,184,1)", // violet → cyan transit + "bridge-c": "rgba(76,140,200,1)", // cool cyan plunge + "bridge-d": "rgba(96,168,196,1)", // pale aqua before impact + // DEVELOP — kindling. Ignition arc. + "develop-a": "rgba(196,116,60,1)", // amber kindling + "develop-b": "rgba(220,128,52,1)", // bright kindling + "develop-c": "rgba(240,144,48,1)", // hot ember + // CLIMAX — full lava-orange peak party. + "climax-a": "rgba(252,140,40,1)", // lava blaze + "climax-b": "rgba(255,108,32,1)", // peak orange + "climax-c": "rgba(248,80,40,1)", // saturated red-orange peak + // CODA — the morning after. Dawn coral. + "coda-a": "rgba(236,160,128,1)", // soft dawn coral + "coda-b": "rgba(228,182,160,1)", // pale ash-coral fade +}; +// Lava warm title palette. +const TITLE_PALETTE = ["#ff6a1f", "#ff8a3c", "#ffb24a", "#ffd24a", "#ffe8a8"]; +const BACKLIGHT_RGB = "255,128,48"; // deep lava amber + +// ── YWFT title + per-second timecode ───────────────────────────────── +console.log(" rasterizing YWFT …"); +const assetsDir = AUDIO.replace(/\.(mp3|wav|flac|aac|m4a)$/i, ".assets"); +mkdirSync(assetsDir, { recursive: true }); + +const titleFontSize = 96; +const { chars: titleChars, totalWidth: titleTotalW } = await prerenderTitleChars({ + text: TITLE, ptSize: titleFontSize, palette: TITLE_PALETTE, + shadowColor: null, assetsDir, +}); + +// Per-slide side-stamp suffix letter: each of the 18 panels gets its own +// lowercase letter (a..r) drawn inline after "hellsine" on the vertical +// stamp — same baseline + same size as the rest of the title text, so the +// stamp reads as `hellsine c` (a real AC piece prompt that jumps to the +// c section of hellsine.mjs). +const STAMP_LETTERS = "abcdefghijklmnopqrstuvwxyz".split("").slice(0, 26); +const stampLetterImgs = []; +for (let i = 0; i < Math.max(SECTIONS.length, 18); i++) { + const ch = STAMP_LETTERS[i] || "?"; + const img = await magickRenderText(ch, { + ptSize: titleFontSize, fill: "rgba(255,253,242,1)", + outPath: `${assetsDir}/stampletter.${ch}.png`, + }); + stampLetterImgs.push(img); +} +const PALS_S = 145; +const PALS_HALF = PALS_S / 2; +const PALS_EDGE_X = 100; +const CHARS_EDGE_X = PALS_EDGE_X + 12; +const CHAR_SCALE = 0.52; +const CHAR_SPAN = titleTotalW * CHAR_SCALE; +const BOUNCE_BUF = 26; +const LEFT_CHARS_CY = H * 0.82 - 16; +const RIGHT_CHARS_CY = H * 0.18 + 32 + 16; +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"); +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, ""); +} +function pad2(i) { return String(i).padStart(2, "0"); } +let coverImg = existsSync(COVER) ? await loadImage(COVER) : null; +const sectionImgs = []; +let haveImgs = 0; +for (let i = 0; i < SECTIONS.length; i++) { + // hellsine uses 2-digit zero-padded section indexes. + const p = `${LANE}/out/${SLUG}-p-sec-${pad2(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/hellsine/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) ──────────────────────────────── +// TODO(hellsine): write pop/hellsine/hellsine-forms.json with per-panel +// face + figure boxes (group-portrait centred lower-middle figure, +// upper-middle face) for tighter Ken-Burns face zooms. For v1 we +// fall back to whole-frame Ken-Burns (empty FORMS → no faces → camera +// breathes between WHOLE_BOX and itself). +let FORMS = {}; +try { + const fp = `${LANE}/${SLUG}-forms.json`; + if (existsSync(fp)) FORMS = JSON.parse(readFileSync(fp, "utf8")).sections || {}; +} catch { /* no forms → centre fallback */ } +function _toBox(v) { + if (!v) return null; + if (Array.isArray(v)) return { x: v[0], y: v[1], w: v[2], h: v[3] }; + if (Array.isArray(v.figure)) return { x: v.figure[0], y: v.figure[1], w: v.figure[2], h: v.figure[3] }; + return null; +} +function _toFace(v) { + if (!v || Array.isArray(v)) return null; + if (Array.isArray(v.face)) return { x: v.face[0], y: v.face[1], w: v.face[2], h: v.face[3] }; + return null; +} +function figureBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return [_toBox(f.jeffrey), _toBox(f.gates)].filter(Boolean); +} +function faceBoxes(name) { + const f = FORMS[name]; + if (!f) return []; + return [_toFace(f.jeffrey), _toFace(f.gates)].filter(Boolean); +} +const FRAME_ASPECT = W / H; +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 }; +} +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 — side stamps ───────────────────────────────────── +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); + 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)]; +} +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), + ]; +} +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; + const u = audioT * FPS / FRAMES; + const TAU = Math.PI * 2; + const hue = ((u * 360 * 4) % 360 + 360) % 360; + 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), + ]; + 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 spots = [ + { cx: PALS_EDGE_X - wig, cy: LEFT_PALS_CY, rot: Math.PI / 2 + swiv }, + { cx: W - PALS_EDGE_X + wig, cy: RIGHT_PALS_CY, rot: -Math.PI / 2 - swiv }, + ]; + const passes = [ + ["multiply", 0.78], ["color-burn", 0.42], + ["overlay", 0.58], ["source-over", 0.06], + ]; + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.cx, sp.cy); + ctx.rotate(sp.rot); + palsTinted([0, 0, 0], palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(wmCanvas, -s / 2 + 3, -s / 2 + 4, s, s); + palsTinted(col, palsBlur); + for (const [op, a] of passes) { + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.30; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + if (glow > 0.001) { + palsTinted(ledCol, palsBlur); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.78 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + palsTinted(ledCol, palsImg); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.18 + 0.46 * glow; + ctx.drawImage(wmCanvas, -s / 2, -s / 2, s, s); + } + ctx.restore(); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + drawPalsTitleChars(audioT); +} +function drawPalsTitleChars(audioT) { + if (!palsImg) return; + const s = 145; + 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 charScale = CHAR_SCALE; + const span = titleTotalW * charScale; + const palsRgb = palsFrameColor(audioT); + const spots = [ + { charsCx: CHARS_EDGE_X - wig, cy: LEFT_CHARS_CY, rot: Math.PI / 2 }, + { charsCx: W - CHARS_EDGE_X + wig, cy: RIGHT_CHARS_CY, rot: -Math.PI / 2 }, + ]; + const startX = -span / 2; + for (const sp of spots) { + ctx.save(); + ctx.translate(sp.charsCx, sp.cy); + ctx.rotate(sp.rot); + for (let i = 0; i < titleChars.length; i++) { + const ch = titleChars[i]; + if (!ch.img) continue; + const x = startX + ch.prefixWidth * 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; + 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(); + 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(); + } + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.46; + ctx.drawImage(tintCharGlyph(ch.img, palsRgb), x, y, dw, dh); + ctx.restore(); + 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(); + } + } + // ── inline per-section letter (hellsine a … r) ─────────────────── + // Same baseline + same size as the title chars — reads as the AC + // piece prompt `hellsine c` that would jump to hellsine.mjs section c. + const secIdx = sectionIndexAt(audioT); + const letterImg = stampLetterImgs[secIdx]; + if (letterImg) { + const ldw = letterImg.width * charScale; + const ldh = letterImg.height * charScale; + // Use a normal inter-word space (~25% of the cap height) as the gap. + const gap = titleFontSize * 0.25 * charScale; + const lx = startX + span + gap; + const supEnv = envAt(audioT); + const lift = 4 * Math.sin(audioT * 4.0 + secIdx * 0.8) * (0.3 + supEnv); + const ly = -ldh / 2 + lift; + // Drop shadow (matches title chars). + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.26; + ctx.drawImage(tintCharGlyph(letterImg, [0, 0, 0]), lx + 3, ly + 4, ldw, ldh); + ctx.restore(); + // Color body, same multi-pass treatment as the title chars. + const passes = [ + ["multiply", 0.78], + ["color-burn", 0.42], + ["overlay", 0.58], + ["source-over", 0.06], + ]; + for (const [op, a] of passes) { + ctx.save(); + ctx.globalCompositeOperation = op; + ctx.globalAlpha = a; + ctx.drawImage(tintCharGlyph(letterImg, palsRgb), lx, ly, ldw, ldh); + ctx.restore(); + } + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.46; + ctx.drawImage(tintCharGlyph(letterImg, palsRgb), lx, ly, ldw, ldh); + ctx.restore(); + if (supEnv > 0.45) { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = 0.14 + 0.46 * Math.min(1, (supEnv - 0.45) / 0.55); + ctx.drawImage(tintCharGlyph(letterImg, palsRgb), lx, ly, ldw, ldh); + ctx.restore(); + } + } + ctx.restore(); + } +} +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 = 220; // faster scroll — hellsine is 182 bpm +const _vs = makeVerletString(ctx, { W, H, playheadX: PLAYHEAD_X, duration: DURATION }); + +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; }); + +const GROOVE_R = Math.round(Math.min(W, H) * 0.85); + +// ── illustration distortion UNDER the bent string ─────────────────── +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; + wuSnapC.clearRect(0, 0, W, H); + wuSnapC.drawImage(canvas, 0, 0); + 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); + 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 ──────────────────────────────────────────────── +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; + 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; +} +const glowCanvas = createCanvas(W, H); +const glowCtx = glowCanvas.getContext("2d"); +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 lava-shadow surround — deep amber/maroon instead of neutral. + vg.addColorStop(0.45, `rgba(140,60,32,${(0.62 + 0.12 * intensity).toFixed(3)})`); + vg.addColorStop(1, `rgba(28,10,4,${(0.94).toFixed(3)})`); + c.fillStyle = vg; + c.fillRect(0, 0, W, H); + c.restore(); + c.globalCompositeOperation = "source-over"; +} +// When no faces are available (we have no forms.json), the transmitted +// + leaded backlights fall through (their guard returns early). The +// vignette still bakes the heat in, and the panel itself reads as the +// scene. TODO(hellsine): a centred default face box per panel would let +// the transmitted layer still glow through the panel. +function drawTransmittedBacklight(c, sectionImg, xform, faces, intensity, audioT = 0) { + if (!faces || !faces.length || !xform || intensity <= 0.01) return; + const k = Math.min(1, intensity); + 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); + 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(${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), + ); + } + 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"; + 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"; +} +function drawLeadedContrast(c, sectionImg, xform, faces, intensity, audioT = 0) { + if (!faces || !faces.length || !xform || intensity <= 0.01) return; + const k = Math.min(1, intensity); + 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(58,28,16,${(0.55 * k).toFixed(3)})`); + g.addColorStop(0.50, `rgba(58,28,16,${(0.30 * k).toFixed(3)})`); + g.addColorStop(1.0, "rgba(58,28,16,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), + ); + } + 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 ───────────────────────────────────────────────────── +const KB = { breathAmp: 0.05, breathPeriodSec: 18, wobbleAmp: 4, envWobbleAmp: 0, zoomPad: 1.0 }; +const ZOOM_CYCLES = 4; // a bit more breath over the shorter / harder track +function zoomAt(audioT) { + const swing = 0.5 - 0.5 * Math.cos(2 * Math.PI * ZOOM_CYCLES * audioT / DURATION); + return 0.04 + 0.30 * swing; +} +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, + }); +} +// Default face/figure box centred upper-mid for panels without a +// hellsine-forms.json entry. Lets the transmitted backlight + leaded +// contrast layers still fire — the 3-layer separation glow that other +// pop releases (trancepenta etc.) use. +function defaultFigureBox() { + return { x: imgW * 0.18, y: imgH * 0.16, w: imgW * 0.64, h: imgH * 0.66 }; +} +function defaultFaceBox() { + return { x: imgW * 0.30, y: imgH * 0.22, w: imgW * 0.40, h: imgH * 0.30 }; +} + +// Punch-up pass — push chromatic richness + contrast WITHOUT lifting +// the panel's blacks. The earlier "screen" pass crept into shadow +// territory and washed out the panels; replaced with black-preserving +// blend modes ("color" shifts hue+saturation only, "soft-light" gently +// pushes contrast and preserves both blacks AND whites). The lava +// bloom is gated through the panel's own bright-region mask so it +// only paints where there's already light to push. +function drawPanelPunchUp(c, idx, audioT, env, punch, sectionImg, xform) { + const [sr, sg, sb] = tintRgb(SECTION_TINTS[SECTIONS[idx].name] || "rgba(255,140,40,1)"); + const heat = Math.min(1, env * 0.9 + punch * 0.5); + // "color" — shifts hue+saturation toward the section tint; preserves + // destination luma so blacks stay black and whites stay white. + c.save(); + c.globalCompositeOperation = "color"; + c.globalAlpha = 0.18 + 0.22 * heat; + c.fillStyle = `rgba(${sr},${sg},${sb},1)`; + c.fillRect(0, 0, W, H); + c.restore(); + // "soft-light" — gentle contrast push without crushing blacks or + // blowing highlights (much gentler than overlay, much darker-preserving + // than screen). + c.save(); + c.globalCompositeOperation = "soft-light"; + c.globalAlpha = 0.32 + 0.34 * heat; + c.fillStyle = `rgba(${sr},${sg},${sb},1)`; + c.fillRect(0, 0, W, H); + c.restore(); + // Lava bloom on the lower half — gated by the panel's own transmission + // mask so dark pixels stay dark (only existing light gets amplified). + if (heat > 0.03 && sectionImg && xform) { + glowCtx.globalCompositeOperation = "source-over"; + glowCtx.clearRect(0, 0, W, H); + const g = glowCtx.createLinearGradient(0, H * 0.50, 0, H); + g.addColorStop(0, `rgba(255,120, 32,0)`); + g.addColorStop(1, `rgba(255,120, 32,${(0.32 * heat).toFixed(3)})`); + glowCtx.fillStyle = g; + glowCtx.fillRect(0, H * 0.50, W, H * 0.50); + // Gate by the panel's bright-area mask. + 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"; + c.save(); + c.globalCompositeOperation = "lighter"; + c.drawImage(glowCanvas, 0, 0); + c.restore(); + } + c.globalCompositeOperation = "source-over"; +} + +function renderPanel(c, idx, audioT, env, punch) { + const name = SECTIONS[idx].name; + let figs = figureBoxes(name); + let faces = faceBoxes(name); + // Fallback when no hellsine-forms.json: synthesize a centred default + // figure + face box so the transmitted / leaded backlight layers can + // glow on the panel (matches the trancepenta 3-layer style). + if (!figs.length) figs = [defaultFigureBox()]; + if (!faces.length) faces = [defaultFaceBox()]; + 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, + }); + 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) + ); + // 3-layer stained-glass intensities — pushed harder so the transmitted + // (face/lava lit-through) + leaded (linework/darks) layers actually + // sharpen the panel instead of being a faint wash. + const v_i = (0.85 + 0.55 * env) * flicker; + const t_i = (0.45 + 1.40 * env + 1.10 * punch) * flicker; + const l_i = (0.30 + 1.10 * env + 0.80 * punch) * flicker; + drawPanelPunchUp(c, idx, audioT, env, punch, sectionImgs[idx], xform); + drawTransmittedBacklight(c, sectionImgs[idx], xform, faces, t_i, audioT); + drawLeadedContrast(c, sectionImgs[idx], xform, faces, l_i, audioT); + drawVignette(c, v_i); + return xform; +} + +// ── TRANSITIONS — 6 types cycled across 17 sub-section boundaries ──── +const TRANS_S = 0.85; +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) { + 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") { + 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 { + 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; +} + +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); + 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; + 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); + const rowIdx = Math.min(ev.stackRow ?? 0, visibleRows - 1); + const yC = yCBase - (LANE_BOTTOM - LANE_TOP) / LANES.length / 2 + + subH / 2 + rowIdx * subH; + 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); + const nrgb = midiToNotepatRgb(ev.midi); + const cr = Math.round(nrgb[0] * 0.48 + laneRgb[0] * 0.52); + const cg = Math.round(nrgb[1] * 0.48 + laneRgb[1] * 0.52); + const cb = Math.round(nrgb[2] * 0.48 + laneRgb[2] * 0.52); + const rgb = `${cr},${cg},${cb}`; + 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; + 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) / Math.max(1, 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; + else if (dt < 0.05) alpha = 1.0; + else if (dt < 0.45) alpha = 1.0 - (dt - 0.05) / 0.40 * 0.55; + else alpha = 0.42; + alpha *= baseAlpha; + const half = Math.max(2, (pk * fullH) / 2); + const bx = ex + (c / cols) * ew + bend; + 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"; +} + +function drawStringGlow(env, audioT) { + const [r, g, b] = palsFrameColor(audioT); + ctx.save(); + ctx.lineCap = "round"; + 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(); + 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"; +} + +// Physical pixel-flame + smoke — when a percussion event reaches the +// playhead (the verlet string), spawn a deterministic pixel-particle +// burst at that lane's y on the string. Particles obey a tiny physics +// model (initial upward thrust + drag + turbulent x-jitter), are +// rendered as snapped chunky pixels (not anti-aliased circles), and +// shift colour by temperature (white-hot → yellow → orange → red → +// embers). A delayed smoke burst trails each flame, source-over grey +// pixels expanding + dissipating upward. +// +// Deterministic spawn: every particle's per-frame state is a pure +// function of (eventTime, particleIdx, audioT) — no per-frame RNG, so +// re-renders are bit-identical and we never accumulate state leakage. +const PIX_GRID = 3; // pixel-block size for the flame (small + crisp) +const PIX_GRID_SMOKE = 5; // smoke uses chunkier pixels +const FLAME_LIFE = 1.80; // base seconds per particle (env-scaled per hit) +const SMOKE_LIFE = 2.40; // seconds per smoke particle +const SMOKE_DELAY = 0.22; // seconds before smoke spawns after a hit +const FLAMES_PER_HIT = 72; +const SMOKES_PER_HIT = 22; +const FLASH_LIFE = 0.15; // bright disc burst at hit moment +const EMITTER_SPREAD = 0; // all particles spawn at the exact activation point on the string +const HIT_WINDOW = Math.max(FLAME_LIFE * 1.5, SMOKE_DELAY + SMOKE_LIFE); +// Per-lane base flame colour — pulled from LANES.color so kick flames +// read red-orange (#ff5a1f) and snare flames read amber-gold (#ffd24a), +// matching their waveform cells on the lane strip. +const LANE_FLAME_RGB = {}; +for (const L of LANES) LANE_FLAME_RGB[L.key] = hexToRgb(L.color); + +const ringSpawnCursor = { kick: 0, snare: 0, sfx: 0 }; +const activeRings = []; // { spawnT, lane, sx, sy } + +function _hash01(seed) { + const s = Math.sin(seed * 9301.1 + 49297.7) * 233280.0; + return s - Math.floor(s); +} + +// Compute the SCREEN-space spawn point for a hit at (spawnT, laneKey). +// At the moment a kick/snare event fires, the string is rotated by +// theta(spawnT). Particles need to anchor to that rotated screen +// position and then rise STRAIGHT UP in screen coordinates (global +// vertical gravity) — the string keeps rotating, but the flame stays +// where it bloomed. This means flame draw happens OUTSIDE withRotation. +function _spawnScreenPos(audioT, laneKey, ev) { + // Use the CURRENT frame's theta — events can fire just before this + // frame's audioT, but we want the spawn at the VISIBLE string position + // right now (where the cell visually crosses the string this frame). + const theta = (audioT / DURATION) * Math.PI * 2; + // CRITICAL: cells are NOT drawn at laneCenterY. drawLanes uses + // yC = yCBase - LANE_H/2 + subH/2 + stackRow * subH (multi-row stack). + // Spawning at laneCenterY misses cells in row > 0 by up to LANE_H/2. + // Mirror drawLanes' formula here so the flame lands on the cell. + const L = LANES.find((x) => x.key === laneKey) || LANES[0]; + const visibleRows = Math.min(L.maxStackRows ?? 1, 3); + const yCBase = laneCenterY[laneKey]; + const laneSpan = (LANE_BOTTOM - LANE_TOP) / LANES.length; + const subH = laneSpan / visibleRows; + const rowIdx = Math.min((ev && ev.stackRow) ?? 0, visibleRows - 1); + const yU = yCBase - laneSpan / 2 + subH / 2 + rowIdx * subH; + // Match the cell rendering's half-bend offset (see drawLanes line ~988: + // `bend = (needleXAt(yC) - PLAYHEAD_X) * 0.5`). + const halfBend = (needleXAt(yU) - PLAYHEAD_X) * 0.5; + const xU = PLAYHEAD_X + halfBend; + const dx = xU - ROT_CX; + const dy = yU - ROT_CY; + const cT = Math.cos(theta), sT = Math.sin(theta); + return { + x: ROT_CX + dx * cT - dy * sT, + y: ROT_CY + dx * sT + dy * cT, + }; +} + +function drawStringFireRings(audioT) { + // Spawn rings for events that have crossed the playhead. Each ring + // stashes its screen-space spawn position once (after rotation at + // spawn time) — particles then rise from that fixed point. + for (const lane of ["kick", "snare", "sfx"]) { + const evs = laneEvents[lane] || []; + while ( + ringSpawnCursor[lane] < evs.length && + evs[ringSpawnCursor[lane]].t <= audioT + ) { + const ev = evs[ringSpawnCursor[lane]]; + if (audioT - ev.t <= HIT_WINDOW) { + const p = _spawnScreenPos(audioT, lane, ev); + const theta = (audioT / DURATION) * Math.PI * 2; + // Emitter geometry rotates with the string slope: tangent + // direction (along the string) at this lane is the rotated + // vertical (0,1) → (-sin θ, cos θ). Particles fan out along + // THIS direction at spawn time, then rise in screen-vertical. + const tx = -Math.sin(theta); + const ty = Math.cos(theta); + // env-at-spawn modulates intensity per hit (louder → longer + + // wider + more dynamic flames). + const eAtSpawn = Math.min(1, envAt(audioT) * 1.5); + activeRings.push({ + spawnT: ev.t, lane, + sx: p.x, sy: p.y, + tx, ty, + env: eAtSpawn, + }); + } + ringSpawnCursor[lane]++; + } + } + // Cull old rings. + while (activeRings.length && audioT - activeRings[0].spawnT > HIT_WINDOW) { + activeRings.shift(); + } + if (!activeRings.length) return; + + // ── FLASH PASS — bright hot disc at each hit moment, fades fast ───── + ctx.save(); + ctx.globalCompositeOperation = "screen"; + for (const ring of activeRings) { + const age = audioT - ring.spawnT; + if (age < 0 || age > FLASH_LIFE) continue; + const k = 1 - age / FLASH_LIFE; + const [lr, lg, lb] = LANE_FLAME_RGB[ring.lane] || [255, 140, 40]; + const r = 18 + 32 * (1 - k); + // Radial gradient with hot core, at the screen-space spawn anchor. + const grad = ctx.createRadialGradient(ring.sx, ring.sy, 0, ring.sx, ring.sy, r); + grad.addColorStop(0, `rgba(255,250,220,${(0.95 * k).toFixed(3)})`); + grad.addColorStop(0.35,`rgba(${lr},${lg},${lb},${(0.70 * k).toFixed(3)})`); + grad.addColorStop(1, `rgba(${lr},${lg},${lb},0)`); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(ring.sx, ring.sy, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + + // ── FLAME PASS — screen composite (additive light) ─────────────────── + ctx.save(); + ctx.globalCompositeOperation = "screen"; + for (const ring of activeRings) { + // Per-lane base colour — kick lane (#ff5a1f) burns red-orange, snare + // (#ffd24a) burns amber-gold. The temperature ramp interpolates from + // white-hot → lane colour → ember. + const [lr, lg, lb] = LANE_FLAME_RGB[ring.lane] || [255, 140, 40]; + // Per-ring intensity — louder hits → longer flames, wider emitter, + // more dynamic velocity. + const intensity = 0.5 + ring.env; // 0.5..1.5 + const ringLife = FLAME_LIFE * (0.65 + 0.70 * ring.env); // 1.17..1.97s + const spread = EMITTER_SPREAD * (0.55 + 0.95 * ring.env); // ~50..140px + for (let i = 0; i < FLAMES_PER_HIT; i++) { + const seedA = ring.spawnT * 17 + i * 0.137; + const seedB = ring.spawnT * 23 + i * 0.413; + const seedC = ring.spawnT * 29 + i * 0.911; + const h1 = _hash01(seedA); + const h2 = _hash01(seedB); + const h3 = _hash01(seedC); + const stagger = h1 * 0.10; // 0–100ms spawn stagger + const age = audioT - ring.spawnT - stagger; + if (age <= 0 || age >= ringLife) continue; + const life01 = age / ringLife; + + // EMITTER: spawn position fans out ALONG the string tangent at + // spawn — emitter geometry rotates WITH the string slope. Bell- + // curve falloff so most particles cluster near the centre. + const tOff = (h3 - 0.5) * spread * (1 - 0.3 * Math.abs(h3 - 0.5)); + const sx0 = ring.sx + ring.tx * tOff; + const sy0 = ring.sy + ring.ty * tOff; + + // Physics: vertical thrust + lateral wobble + envelope-modulated + // velocity range. Loud hits = much taller flames, more burst. + const burst = h1 > 0.85 ? 2.0 : 1.0; // 15% fast jets + const vy0 = -(160 + h1 * 320) * burst * intensity; // px/s upward + const vx0 = (h2 - 0.5) * 40 * (1 + h1 * 0.4); // narrow lateral + const drag = Math.exp(-age * (0.45 + h2 * 0.55)); // per-particle drag + const turbulence = + Math.sin(age * 9.0 + h1 * 11) * 10 * (1 - life01 * 0.3) + + Math.sin(age * 19.0 + h2 * 17) * 5 * (1 - life01 * 0.5) + + Math.sin(age * 31.0 + h1 * 23) * 2 * (1 - life01 * 0.7); + const x = sx0 + vx0 * age * drag + turbulence; + const y = sy0 + vy0 * age * drag + 22 * age * age; + + // Pixel-grid snap (chunky look). + const px = Math.floor(x / PIX_GRID) * PIX_GRID; + const py = Math.floor(y / PIX_GRID) * PIX_GRID; + + // Temperature ramp blended through the lane colour. + // 0..0.18 white-hot core + // 0.18..0.45 lane colour brightened (mix toward white) + // 0.45..0.75 pure lane colour + // 0.75..0.92 lane colour dimmed (mix toward dark-red) + // 0.92..1 ember fade + let r, g, b; + if (life01 < 0.18) { + r = 255; g = 250; b = 220; + } else if (life01 < 0.45) { + // brighten lane colour toward white-hot + const k = (0.45 - life01) / 0.27; // 1→0 across this band + r = Math.round(lr + (255 - lr) * k * 0.6); + g = Math.round(lg + (255 - lg) * k * 0.6); + b = Math.round(lb + (220 - lb) * k * 0.6); + } else if (life01 < 0.75) { + r = lr; g = lg; b = lb; + } else if (life01 < 0.92) { + const k = (life01 - 0.75) / 0.17; // 0→1 + r = Math.round(lr * (1 - k) + 130 * k); + g = Math.round(lg * (1 - k) + 30 * k); + b = Math.round(lb * (1 - k) + 12 * k); + } else { + r = 90; g = 20; b = 8; + } + + const aa = Math.pow(1 - life01, 1.05) * 0.95; + const size = PIX_GRID + (life01 < 0.4 ? PIX_GRID : 0); // brighter cores are 2×2 blocks + ctx.fillStyle = `rgba(${r},${g},${b},${aa.toFixed(3)})`; + ctx.fillRect(px, py, size, size); + } + } + ctx.restore(); + + // ── SMOKE PASS — source-over, low alpha grey pixels rising slowly ── + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + for (const ring of activeRings) { + const xRingC = ring.sx; + for (let i = 0; i < SMOKES_PER_HIT; i++) { + const seedA = ring.spawnT * 29 + i * 0.731 + 7777; + const seedB = ring.spawnT * 37 + i * 0.913 + 8888; + const h1 = _hash01(seedA); + const h2 = _hash01(seedB); + const age = audioT - ring.spawnT - SMOKE_DELAY - h1 * 0.20; + if (age <= 0 || age >= SMOKE_LIFE) continue; + const life01 = age / SMOKE_LIFE; + + // Slower rise + wider lateral drift. + const vy0 = -(28 + h1 * 36); + const vx0 = (h2 - 0.5) * 26; + const turbulence = Math.sin(age * 3 + h1 * 7) * 18 * Math.min(1, age * 0.5); + const x = xRingC + vx0 * age + turbulence; + const y = ring.sy + vy0 * age; + + const px = Math.floor(x / PIX_GRID_SMOKE) * PIX_GRID_SMOKE; + const py = Math.floor(y / PIX_GRID_SMOKE) * PIX_GRID_SMOKE; + + // Warm dark grey → cool light grey as it dissipates. + const grey = Math.round(48 + life01 * 110); + const aa = Math.sin(life01 * Math.PI) * 0.32; // ramp-up-then-fade + const size = PIX_GRID_SMOKE + Math.floor(life01 * 14); // expands + + ctx.fillStyle = `rgba(${grey},${Math.round(grey * 0.93)},${Math.round(grey * 0.86)},${aa.toFixed(3)})`; + ctx.fillRect(px, py, size, size); + } + } + ctx.restore(); +} + +// ── 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 }); +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" : (REEL ? "reel" : "insta-story")} · ${endFrame - startFrame} frames` }); +console.log(` rendering frames ${startFrame}..${endFrame - 1} (${endFrame - startFrame} frames)${TEST_MODE ? " → /tmp/hellsine-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); + + for (const L of LANES) { + const yC = laneCenterY[L.key]; + const lrgb = hexToRgb(L.color); + let amp = L.key === "kick" ? 22 : 11; + 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(); + + const idx = sectionIndexAt(audioT); + const sec = SECTIONS[idx]; + const since = audioT - sec.startSec; + if (flags.debug) { + // Debug mode: panel replaced with solid black so flame/string + // alignment is unambiguous against a flat background. + ctx.fillStyle = "rgb(0,0,0)"; + ctx.fillRect(0, 0, W, H); + } else if (idx > 0 && since >= 0 && since < TRANS_S) { + let p = since / TRANS_S; + p = p * p * (3 - 2 * p); + 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); + } + + const theta = (audioT / DURATION) * Math.PI * 2; + warpUnderString(theta); + _vs.withRotation(theta, () => { + drawLanes(audioT); + _vs.draw(); + drawStringGlow(env, audioT); + }); + // Flame + smoke render in SCREEN space (no rotation transform), so + // particles anchor to the rotated string at spawn time then rise with + // global vertical gravity regardless of further string rotation. + drawStringFireRings(audioT); + + drawWatermark(audioT); + // Progress bar in BOTH reel + insta-story (per-section colored bar, + // no timecode). Timecode stays insta-story-only — the reel format + // doesn't want digits ticking under the action. + drawProgressBar(audioT); + if (!REEL) drawTimecode(audioT); + + if (TEST_MODE) { + const png = canvas.toBuffer("image/png"); + const fname = `/tmp/hellsine-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/hellsine/bin/warp-struct-to-master.mjs b/pop/hellsine/bin/warp-struct-to-master.mjs new file mode 100644 index 000000000..49af6a569 --- /dev/null +++ b/pop/hellsine/bin/warp-struct-to-master.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// pop/hellsine/bin/warp-struct-to-master.mjs — apply the post-bake tempo +// warp to hellsine.struct.json so its event timings line up with the +// finalized hellsine-MASTER.wav timeline (the master gets atempo=1.06 +// from 110.77s onward, then truncates at 162.0s — see bake.mjs). +// +// The engine writes struct.json in ENGINE time (no tempo bump). +// Visualizers consume struct.json against the MASTER wav timeline. +// This script reads the engine struct in-place, walks every event time +// + section boundary, applies the warp, and writes the file back. +// +// Idempotent IF and only if no previous run was warped (a flag is added +// so re-running is a no-op). Re-running after a fresh engine emit is +// always safe. +// +// Usage: +// node pop/hellsine/bin/warp-struct-to-master.mjs + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const STRUCT_PATH = resolve(HERE, "../hellsine.struct.json"); + +// Mirror bake.mjs. +const TEMPO_CUT = 110.77; // s · climax.startSec — the final drop +const TEMPO_MUL = 1.06; +const TRUNCATE = 162.0; // s · master is truncated here + +function warpT(tEngine) { + if (tEngine <= TEMPO_CUT) return tEngine; + return TEMPO_CUT + (tEngine - TEMPO_CUT) / TEMPO_MUL; +} + +function round4(x) { return +x.toFixed(4); } + +const s = JSON.parse(readFileSync(STRUCT_PATH, "utf8")); +if (s.masterWarpApplied) { + console.log("✓ struct already warped to master timing — no-op"); + process.exit(0); +} + +let warpedEvents = 0; +let truncatedEvents = 0; +for (const lane of Object.keys(s.events || {})) { + const evs = s.events[lane]; + const out = []; + for (const ev of evs) { + const w = warpT(ev.t); + if (w >= TRUNCATE) { truncatedEvents++; continue; } + if (w !== ev.t) warpedEvents++; + out.push({ ...ev, t: round4(w) }); + } + s.events[lane] = out; + if (s.counts) s.counts[lane] = out.length; +} + +// Warp section boundaries too — keeps sectionForTime() honest in the +// player + visualizer. +if (Array.isArray(s.sections)) { + for (const sec of s.sections) { + if (typeof sec.startSec === "number") sec.startSec = round4(warpT(sec.startSec)); + if (typeof sec.endSec === "number") sec.endSec = round4(Math.min(TRUNCATE, warpT(sec.endSec))); + } +} + +s.totalSec = TRUNCATE; +s.masterWarpApplied = { tempoCut: TEMPO_CUT, tempoMul: TEMPO_MUL, truncate: TRUNCATE }; + +writeFileSync(STRUCT_PATH, JSON.stringify(s, null, 2) + "\n"); +console.log(`✓ warped ${warpedEvents} events past ${TEMPO_CUT}s by /${TEMPO_MUL}`); +console.log(` truncated ${truncatedEvents} events past ${TRUNCATE}s`); +console.log(` totalSec → ${TRUNCATE}`); +console.log(` ${STRUCT_PATH.replace(process.env.HOME, "~")}`); diff --git a/pop/hellsine/hellsine-forms.json b/pop/hellsine/hellsine-forms.json new file mode 100644 index 000000000..409b52a77 --- /dev/null +++ b/pop/hellsine/hellsine-forms.json @@ -0,0 +1,73 @@ +{ + "_comment": "Per-panel figure + face bboxes for the hellsine portrait preview-score's 3-layer backlight (vignette + transmitted glow + leaded contrast). Image dims: 1024x1536. preview-score.mjs (figureBoxes/faceBoxes) reads two keys: 'jeffrey' = primary figure, 'gates' = secondary cluster (group of pixsies in multi-figure scenes; covers the crew silhouette + a representative pixsie face so the backlight gates the whole ensemble). Boxes hand-placed against each rendered 1024x1536 panel after multi-cascade Haar (frontalface_default + alt2 + profileface) returned mostly noise on felt-puppet faces with fire-eyes. Re-eyeball if a panel is regenerated.", + "imgW": 1024, + "imgH": 1536, + "sections": { + "overture-a": { + "jeffrey": { "figure": [220, 320, 520, 880], "face": [360, 320, 260, 260] } + }, + "overture-b": { + "jeffrey": { "figure": [260, 320, 520, 920], "face": [340, 340, 300, 320] } + }, + "overture-c": { + "jeffrey": { "figure": [280, 360, 500, 900], "face": [380, 380, 280, 300] } + }, + "statement-a": { + "jeffrey": { "figure": [260, 280, 540, 940], "face": [380, 360, 300, 320] } + }, + "statement-b": { + "jeffrey": { "figure": [520, 500, 460, 760], "face": [600, 520, 280, 280] } + }, + "statement-c": { + "jeffrey": { "figure": [220, 320, 600, 760], "face": [360, 340, 280, 280] } + }, + "bridge-a": { + "jeffrey": { "figure": [220, 180, 700, 1040], "face": [380, 240, 300, 320] } + }, + "bridge-b": { + "jeffrey": { "figure": [540, 320, 460, 960], "face": [600, 340, 280, 300] }, + "gates": { "figure": [40, 320, 580, 960], "face": [140, 360, 360, 360] } + }, + "bridge-c": { + "jeffrey": { "figure": [320, 260, 380, 520], "face": [360, 260, 300, 280] }, + "gates": { "figure": [40, 300, 940, 1080], "face": [80, 340, 880, 580] } + }, + "bridge-d": { + "jeffrey": { "figure": [320, 460, 380, 600], "face": [360, 480, 300, 280] }, + "gates": { "figure": [80, 220, 880, 1020], "face": [120, 240, 800, 620] } + }, + "develop-a": { + "jeffrey": { "figure": [280, 520, 480, 740], "face": [380, 540, 280, 280] }, + "gates": { "figure": [40, 360, 940, 540], "face": [60, 380, 900, 360] } + }, + "develop-b": { + "jeffrey": { "figure": [120, 700, 480, 580], "face": [220, 720, 280, 280] }, + "gates": { "figure": [40, 340, 940, 700], "face": [60, 340, 900, 400] } + }, + "develop-c": { + "jeffrey": { "figure": [80, 720, 460, 600], "face": [180, 760, 280, 280] }, + "gates": { "figure": [120, 320, 880, 640], "face": [160, 340, 820, 380] } + }, + "climax-a": { + "jeffrey": { "figure": [280, 320, 460, 940], "face": [340, 440, 320, 320] }, + "gates": { "figure": [40, 420, 940, 880], "face": [60, 440, 900, 480] } + }, + "climax-b": { + "jeffrey": { "figure": [120, 320, 480, 1020], "face": [200, 340, 320, 320] }, + "gates": { "figure": [440, 460, 560, 880], "face": [460, 480, 540, 460] } + }, + "climax-c": { + "jeffrey": { "figure": [360, 880, 280, 440], "face": [400, 880, 220, 220] }, + "gates": { "figure": [180, 820, 680, 520], "face": [200, 820, 640, 300] } + }, + "coda-a": { + "jeffrey": { "figure": [320, 700, 360, 540], "face": [380, 720, 240, 260] }, + "gates": { "figure": [40, 640, 940, 680], "face": [60, 640, 900, 380] } + }, + "coda-b": { + "jeffrey": { "figure": [220, 240, 500, 1020], "face": [300, 260, 320, 320] }, + "gates": { "figure": [540, 440, 400, 600], "face": [580, 460, 320, 280] } + } + } +} + diff --git a/pop/hellsine/hellsine.story.txt b/pop/hellsine/hellsine.story.txt index 87b34b283..b14becc89 100644 --- a/pop/hellsine/hellsine.story.txt +++ b/pop/hellsine/hellsine.story.txt @@ -6,9 +6,13 @@ laptops with glowing PALS lids, photo-real environments, sines that ARE the fire. peer-horizontality everywhere — jeffrey is a member of the crew, not a hero. -the arc: jeffrey at his earth desk → drone strike opens a portal → -he + the pixsies fall through it together → they land in hellsine -and ignite → peak party (the cover) → dawn, they live here now. +the arc: jeffrey at his earth desk → drone strike rips the room, +one drone fires a laser STRAIGHT DOWN and burns a circular hole +through the studio floor exposing hell below → jeffrey facetimes +the squad → the squad busts through the studio door → they all +cannonball into the hole together → splashdown into lava → they +SWIM in lava → ignite → peak party on the basalt shore (the cover) +→ dawn, everyone chilling, they live here now. sub-panel ids match the trancepenta naming convention (

-a / -b / -c, etc.) so they can drop into a forms.json @@ -17,7 +21,7 @@ later. 18 panels total ≈ 9 s each over the 2:42 (162 s) master. per-section weighting (rough — matches README form lengths): overture (~15 s) → 3 panels statement (~25 s) → 3 panels - bridge (~45 s, the study zone) → 4 panels + bridge (~45 s, the call + bust-in + drop) → 4 panels develop (~30 s) → 3 panels climax (~35 s) → 3 panels coda (~17 s) → 2 panels @@ -73,119 +77,145 @@ per-section weighting (rough — matches README form lengths): mid-shock — brow up, mouth open, NO grin yet, NO fire-eyes yet. camera: wide, drones on the left, jeffrey centred, glass mid-air. -▸ statement-b — "the portal opens" - AT THE SAME INSTANT, behind jeffrey a vertical PORTAL tears open - in the studio air — a rip in space, edges crackling with multi- - hue matrix rain (yellow / red / purple / lime), the inside lava- - orange + swirling. desk, chair, mug, sheet music already lifting - + pulling toward it. studio walls warping into the suck. jeffrey - pivoting toward the portal, hair lifting in the pull. the macbook - neo slides off the desk into the air. - camera: from a low angle behind the desk, the portal a vertical - blade of light dominating the back of the frame. - -▸ statement-c — "across the threshold" - jeffrey caught at the lip of the portal — feet off the floor, - drones still firing in from the window-right edge, the macbook + - pen + a single sheet of music in the air around him. half his - body already in the lava-orange swirl, half still in the felt - studio. NO fire-eyes yet — wide eyes, breath held. the felt of - his sleeve is just beginning to fray where it crosses the portal. - camera: wide, jeffrey mid-threshold, earth on one side, hellsine - light on the other. +▸ statement-b — "the floor laser" + ONE lead drone hovers low in the middle of the studio, its barrel + angled STRAIGHT DOWN. it fires a CLEAN PILLAR of cutting laser + light — saturated red-orange — straight into the wooden floor + between jeffrey's feet. the beam is a perfect vertical column, + carving a slow circle through the planks; sparks + curling smoke + + flying splinters fan out around the cut. jeffrey is leapt back + against the desk, arms shielding his face, watching the floor get + carved. NO fire-eyes yet — just wide shock. + camera: low across the floor, the laser column dominating centre + frame, jeffrey braced against the desk on the right. + +▸ statement-c — "the hole to hell" + the circle of floor DROPS AWAY. a perfect round HOLE now opens in + the planks, edges glowing ember-red where the laser bit through, + charred wood smoking. through the hole: HELL, plainly visible — + basalt + sinusoidal lava rivers + bruise-purple sky, miles below + and yet right there, the heat already rolling up into the studio. + jeffrey is on his knees at the rim, peering in, hair lifting in + the updraft, the warm orange glow lighting his face from below + for the first time. felt face: awe, no fear, the first faint + curl of a grin. drones still buzz overhead but the room has gone + quiet around the hole. + camera: high three-quarter on the hole, jeffrey at the rim, the + basalt hellscape clearly readable down inside the circle. ╔══════════════════════════════════════════════════════════════════╗ -║ III. BRIDGE — "the fall" ║ -║ B theme, kick thins to a pulse — the study zone ║ +║ III. BRIDGE — "the call + the bust-in + the drop" ║ +║ B theme, kick thins to a pulse ║ ╚══════════════════════════════════════════════════════════════════╝ -▸ bridge-a — "weightless" - cosmic chute between worlds. jeffrey alone now, tumbling slowly - in freefall through a long descending shaft, weightless, looking - around in wonder — eyes wide but mouth softening, no panic. - around him: streams of glowing code-rain (yellow / red / purple / - lime) on every wall, broken chunks of earth drifting alongside - (a slice of studio floor, the macbook spinning slow, the coffee - mug, pages of sheet music, the desk lamp still glowing). no - pixsies yet. NO fire in eyes. - camera: vertical pan, jeffrey centred, debris in orbit. - -▸ bridge-b — "the pixsies arrive" - the first pixsies EMERGE one by one through colored code-rain - walls into the fall — a kid pixsie steps through the lime stream, - an elder through the violet, a femme grad-student through the - hot-pink, a tactical-vest pixsie through the gold. each joins - the descent in their own felt outfit, drawn in from elsewhere. - jeffrey turns toward them in surprise + relief. peer-horizontal - formation already forming — none centred. soap bubbles starting - to rise past them. - camera: vertical pan, the fall now populated, jeffrey + arriving - pixsies spread across the frame. - -▸ bridge-c — "warmth from below" - full crew falling together — jeffrey + 4-6 pixsies in loose - orbit around each other and the orbiting earth-debris. soap - bubbles rising thick now. warm orange light starting to leak in - from BELOW the frame — they're approaching hellsine. felt hems - beginning to fray from the descent. still NO fire-eyes — but - the orange glow warms the underside of every face. expressions: - curiosity, anticipation, the first hint of grin. - camera: vertical pan, the crew composed peer-horizontally, the - bottom of frame washed warm orange. - -▸ bridge-d — "approach" - TIGHT on jeffrey's face as the fall accelerates toward landing — - warm orange light from below now fully illuminating his underside - + chin + the underside of his felt hair, the cool blue cosmic- - chute light fading behind him. eyes wide, mouth slightly open in - awe. a single pixsie just visible in soft focus over his shoulder, - also lit warm from below. soap bubbles streaming past. no fire- - eyes yet — but the warmth he's about to inherit is already on his - face. the last moment before they hit. - camera: medium-close on jeffrey, three-quarter angle, the warm - glow rising up the frame. +▸ bridge-a — "facetime the squad" + jeffrey kneeling on the studio floor next to the glowing hole, the + hellfire glow underlighting him from below, the cool desk-lamp + + cold drone targeting beams from above — a two-tone light bath. he + has YANKED OUT HIS PHONE and is in a multi-pane FACETIME-style + group call: a grid of 4-6 pixsie faces fills the phone screen, + each in their own little tile — kid pixsie in lime PJs, elder + pixsie in a cardigan, femme grad-student pixsie at her own + kitchen counter, tactical-vest pixsie in a parked car, etc. + each tile is its own warm-lit room. jeffrey is mid-shout into + the phone, mouth open, free hand pointing down at the hole. + the pixsies on the screen are wide-eyed leaning in. peer- + horizontal: he's not commanding, he's calling them in. + camera: over jeffrey's shoulder, the phone screen readable, the + hole glowing orange in the floor beside him. + +▸ bridge-b — "the squad busts in" + the STUDIO DOOR explodes open — door slamming back on its hinges, + splinters flying. 4-6 felt pixsies pile through the doorway in a + jumbled wave, mid-stride, each carrying their own AC laptop in + one hand. outfits: kid pixsie in lime PJs + sneakers, elder + pixsie in a cardigan with cane raised, femme grad-student in + cyberpunk techwear, tactical-vest pixsie in boots, hot-pink-hair + pixsie in a felt hoodie, beanie pixsie clutching coffee. they + arrive READY — wide grins, bright eyes, peer-horizontal stack + in the doorway. jeffrey is on his feet now, half-turned toward + them, free arm thrown up in greeting, the hole still glowing + beside him. drones forgotten. + camera: low and wide from inside the studio, the doorway + bursting open on the left, jeffrey + hole on the right. + +▸ bridge-c — "around the hole" + the whole crew now ringed around the glowing floor hole, peer- + horizontal circle, jeffrey one member among them. they're + leaning in, peering down into hell — warm lava glow hitting every + face from below, fingertips on the rim. one pixsie holds her + laptop out over the hole letting the PALS lid catch the orange + light, one pixsie crouches with hands on knees grinning, the kid + pixsie kneels rim-side eyes huge, the elder grips her cane. + jeffrey is roughly opposite the kid, hand out as if counting it + off. expressions: shared grins, shared awe, the room is hot. + STILL no fire-eyes — but the warm glow already paints them all. + camera: high three-quarter on the hole, the ringed crew visible + all the way around it. + +▸ bridge-d — "all in" + CANNONBALL. the entire crew launching INTO the hole together — + one frozen instant mid-leap, feet off the floor, knees pulled up, + arms thrown around each other's shoulders, the macbook neo + spinning free in the air alongside them, AC laptops tucked under + arms or pinwheeling. jeffrey is one body in the group, not + centred, somewhere in the middle of the cluster. hair lifted, + eyes bright but still felt-clean — no fire yet. behind them the + ruined studio recedes; below them the basalt + lava already + rushes up to meet them. peer-horizontal mid-air. soap bubbles + beginning to rise from below past them. + camera: from below inside the hole, looking up at the falling + cluster against the doorway light, hell-glow on undersides. ╔══════════════════════════════════════════════════════════════════╗ -║ IV. DEVELOP — "the landing" ║ +║ IV. DEVELOP — "splashdown + the swim" ║ ║ theme fragmented + sequenced, hoover screams ║ ╚══════════════════════════════════════════════════════════════════╝ -▸ develop-a — "impact" - they land on basalt. dust + heat-shimmer kicked up around their - boots. jeffrey on his feet, knees flexed from the landing, the - pixsies in a loose semicircle around him in various landing - poses — one still mid-crouch, one standing tall already, one - picking up the macbook neo that landed beside her. expressions: - caught breath, taking stock. NO fire-eyes yet. small scorch - marks just starting at the cuffs from the warm air. - camera: low + wide, the landing zone occupying the lower half, - the hellscape beginning to reveal above. - -▸ develop-b — "discovery" - the hellscape REVEALED. jeffrey + pixsies looking outward in - different directions — discovering pieces of it one at a time: - one looks at a sine river curving past their boots, one watches - the bullet train streaking the horizon, one points at the white - horse galloping mid-distance, one tracks an ember drifting up. - multi-hue matrix-rain falls through distant smoke columns. the - pixsies hold their first laptops up (lids facing camera) — but - the PALS only just FLICKERING ON, one or two glowing, rest still - dark. - camera: wide panorama, hellsine landscape behind, group anchored - lower-third. +▸ develop-a — "splashdown" + IMPACT into a wide LAVA POOL. the crew hits the sinusoidal lava + river in a huge spray — felt-textured splashes of glowing orange- + red lava arcing up in fat droplets, slow-motion sheets cresting + around each body. waves rolling outward in concentric rings. + jeffrey + pixsies frozen mid-plunge, half-submerged at varied + depths, expressions caught between gasp and grin — eyes wide, + mouths open in a shared whoop. the macbook neo splashes down + beside them, lid still glowing softly. NO fire-eyes yet — but + the lava is up to their chests, lighting every felt face from + within the pool. + camera: low across the lava surface, multiple splashes filling + the frame, basalt banks visible on the edges. + +▸ develop-b — "swim in lava" + SWIMMING. the crew floats and strokes through the lava river like + it's a warm pool — the lava holds them up the way water would. + one pixsie on her back floating with arms behind her head, eyes + closed grinning. one pixsie doing a slow felt-arm backstroke, + trailing a sine-shaped wake. the kid pixsie cannonball-bobs in + the middle splashing the elder, who is splashing back. jeffrey + is treading lava beside another pixsie laughing, his AC laptop + floating like a pool toy beside him, lid PALS glowing. tongues + of sine-flame lick off the surface between them. the lava is + their water. felt outfits darkening at the waterline + starting + to char. wicked grins arriving but eyes still felt-clean. + camera: low along the surface, swimming bodies arrayed peer- + horizontally across the lava, sine ripples curving past them. ▸ develop-c — "ignition" - FIRE IGNITES — first in jeffrey's eye sockets (one, then the - other) and then in a CHAIN around the semicircle, each pixsie's - eyes lighting in sequence. all the PALS lids now full-glowing in - seven hues. fingertips beginning to singe, tiny flames flickering - off felt fibres at the tips. wide-eyed grins forming — not full - demon yet, but the wickedness is arriving. felt damage taking - root: first threads pulling loose, first scorch marks. - camera: tighter on the group, mid-shot, the chain of ignition - catchable across the frame. + the crew now climbing out + rising up from the lava onto the + basalt bank — water-line of glowing orange dripping off their + felt as they emerge. AT THIS MOMENT the FIRE IGNITES — first in + jeffrey's eye sockets (one, then the other) and then in a CHAIN + around the crew, each pixsie's eyes lighting in sequence as they + surface. all the PALS lids now full-glowing in seven hues. + fingertips singing, tiny flames flickering off felt fibres at + the tips. wide-eyed grins forming — not full demon yet, but + the wickedness is arriving. felt damage taking root: first + threads pulling loose, first scorch marks. + camera: along the basalt bank looking down the line of rising + bodies, the chain of ignition catchable across the frame. ╔══════════════════════════════════════════════════════════════════╗ @@ -196,13 +226,13 @@ per-section weighting (rough — matches README form lengths): ▸ climax-a — "the cover" (verbatim) THE COVER. the smooshed-into-lens wide-angle group portrait from hellsine.illy.txt — jeffrey + pixsies dancing ON / OVER / AROUND - active fire, both palms up, fire-eyes BLAZING, sinusoidal lava - ribbons weaving between feet, sine-flame tongues dancing between - faces, PALS lids glowing cyan / magenta / lime / hot-pink / gold / - orange / violet, white horse + bullet train on the back horizon, - multi-hue code-rain through smoke columns, soap bubbles rising, - fingertip flames + scorched palms, tattered felt. peak chaos, - peak joy, the PARTY. + active fire on the basalt bank above the lava pool, both palms up, + fire-eyes BLAZING, sinusoidal lava ribbons weaving between feet, + sine-flame tongues dancing between faces, PALS lids glowing cyan / + magenta / lime / hot-pink / gold / orange / violet, white horse + + bullet train on the back horizon, multi-hue code-rain through + smoke columns, soap bubbles rising, fingertip flames + scorched + palms, tattered felt. peak chaos, peak joy, the PARTY. camera: the cover crop verbatim — edge-to-edge, jeffrey at 40% from left. @@ -222,9 +252,10 @@ per-section weighting (rough — matches README form lengths): PULL BACK to the widest shot of the track — the dancing group is now small in the lower-third of the frame, the FULL hellsine vista revealed around them: parallel sinusoidal lava rivers - curving through the basalt foreground at varied amplitudes, - obsidian spires receding into bruise-purple horizon, the white - horse flaming across the mid-distance, the bullet train streaking + curving through the basalt foreground at varied amplitudes (the + same pool they swam in clearly visible behind them), obsidian + spires receding into bruise-purple horizon, the white horse + flaming across the mid-distance, the bullet train streaking the far horizon, multi-hue matrix-rain streaming through smoke columns on either side, soap bubbles + embers everywhere. the dancing crew is the warm pulsing nucleus inside a vast lava world. @@ -234,20 +265,24 @@ per-section weighting (rough — matches README form lengths): ╔══════════════════════════════════════════════════════════════════╗ -║ VI. CODA — "the morning after" ║ +║ VI. CODA — "all chilling" ║ ║ theme dissolves back to strings, continuous fade ║ ╚══════════════════════════════════════════════════════════════════╝ -▸ coda-a — "embers" +▸ coda-a — "chilling on the basalt" dawn breaking over the obsidian horizon — bruise-purple softening to deep coral, the lava glow halved, smoke columns thinning. the - pixsies are scattered across the basalt — sat on rocks, leaning - against each other, one stretched out flat looking up. spent and - content. fire-eyes now just EMBERS — soft orange glow, no flames. - felt is fully tattered + scorched, but the heat is no longer - hurting. PALS lids dimmed to a soft pulse. - camera: wide, the spent crew arranged across the basalt, dawn - filling the upper half. + crew is JUST CHILLING across the basalt — sat on warm rocks, + leaning back on elbows, one stretched out flat looking up at the + coral sky, one floating again in the lava pool arms behind head, + one elder smoking a slow ember off the basalt edge, kid pixsie + curled up dozing against an obsidian wedge. spent and content, + the party's afterglow. fire-eyes now just EMBERS — soft orange + glow, no flames. felt is fully tattered + scorched, but the heat + is no longer hurting. PALS lids dimmed to a soft pulse. nobody is + posing; everyone is at rest. + camera: wide, the chilled-out crew arranged peer-horizontally + across the basalt + lava-pool edge, dawn filling the upper half. ▸ coda-b — "they live here now" jeffrey sits on a low basalt outcrop in mid-shot, the citrus- @@ -274,15 +309,26 @@ usage notes: - material rules constant across all panels: · figures: felt-craft, on-model jeffrey, peer-horizontal pixsies · laptops: shiny plastic, PALS-only on lids, glow per-lid - · environments: photo-real (earth studio I-II, cosmic chute III, - basalt hellscape IV-VI) + · environments: photo-real (earth studio I–III, basalt + lava + hellscape IV–VI). NOTE: no cosmic-chute / freefall section — + the transition is now diegetic (laser → floor hole → drop). · sines ARE the fire/lava — never sine beams from mouths + · phones are OK in bridge-a only (facetime); after that, no + phones — laptops only. - continuity arcs (carry across panels): - eyes: dim → shock → wonder → ignition → blaze → embers - felt: clean → torn → frayed → scorched → tattered → settled - pals: dark → dark → off → flickering → full seven-hue → soft - grin: none → none → wonder → forming → wicked-max → calm + eyes: dim → shock → wonder → wonder → calling → arriving → + grouped → leaping → splashing → swimming → ignition → + blaze → blaze → blaze → embers → embers + felt: clean → clean → clean → clean → clean → clean → clean → + clean → wet+darkening → wet+charring → first scorch → + tattered → tattered → tattered → settled → settled + pals: dark → dark → off → off → off → off → off → off → off → + float+soft → flickering → full seven-hue → full → full → + soft → soft + grin: none → none → wonder → first-curl → calling → joy → + shared → mid-whoop → mid-whoop → laughing → forming → + wicked-max → wicked-max → wicked-max → calm → calm - gen-illy.mjs can be invoked per panel, reusing the same jeffrey identity refs + pals-logo.png; per-panel prompts can be derived diff --git a/pop/hellsine/hellsine.struct.json b/pop/hellsine/hellsine.struct.json new file mode 100644 index 000000000..334e3244d --- /dev/null +++ b/pop/hellsine/hellsine.struct.json @@ -0,0 +1,3390 @@ +{ + "engine": "hellsine", + "allSine": true, + "meter": 4, + "bpm": 182, + "scale": "minor", + "rootMidi": 50, + "totalBars": 124, + "totalSec": 162, + "sections": [ + { + "name": "overture-a", + "t": 0, + "startSec": 0, + "endSec": 5.2747, + "code": "a" + }, + { + "name": "overture-b", + "t": 5.2747, + "startSec": 5.2747, + "endSec": 10.5495, + "code": "b" + }, + { + "name": "overture-c", + "t": 10.5495, + "startSec": 10.5495, + "endSec": 15.8242, + "code": "c" + }, + { + "name": "statement-a", + "t": 15.8242, + "startSec": 15.8242, + "endSec": 26.3736, + "code": "d" + }, + { + "name": "statement-b", + "t": 26.3736, + "startSec": 26.3736, + "endSec": 36.9231, + "code": "e" + }, + { + "name": "statement-c", + "t": 36.9231, + "startSec": 36.9231, + "endSec": 47.4725, + "code": "f" + }, + { + "name": "bridge-a", + "t": 47.4725, + "startSec": 47.4725, + "endSec": 55.3846, + "code": "g" + }, + { + "name": "bridge-b", + "t": 55.3846, + "startSec": 55.3846, + "endSec": 63.2967, + "code": "h" + }, + { + "name": "bridge-c", + "t": 63.2967, + "startSec": 63.2967, + "endSec": 71.2088, + "code": "i" + }, + { + "name": "bridge-d", + "t": 71.2088, + "startSec": 71.2088, + "endSec": 79.1209, + "code": "j" + }, + { + "name": "develop-a", + "t": 79.1209, + "startSec": 79.1209, + "endSec": 89.6703, + "code": "k" + }, + { + "name": "develop-b", + "t": 89.6703, + "startSec": 89.6703, + "endSec": 100.2198, + "code": "l" + }, + { + "name": "develop-c", + "t": 100.2198, + "startSec": 100.2198, + "endSec": 110.7692, + "code": "m" + }, + { + "name": "climax-a", + "t": 110.7692, + "startSec": 110.7692, + "endSec": 120.7215, + "code": "n" + }, + { + "name": "climax-b", + "t": 120.7215, + "startSec": 120.7215, + "endSec": 130.6739, + "code": "o" + }, + { + "name": "climax-c", + "t": 130.6739, + "startSec": 130.6739, + "endSec": 140.6262, + "code": "p" + }, + { + "name": "coda-a", + "t": 140.6262, + "startSec": 140.6262, + "endSec": 150.5785, + "code": "q" + }, + { + "name": "coda-b", + "t": 150.5785, + "startSec": 150.5785, + "endSec": 160.5308, + "code": "r" + } + ], + "counts": { + "kick": 240, + "snare": 231, + "sfx": 455 + }, + "events": { + "kick": [ + { + "t": 15.8213 + }, + { + "t": 16.4839 + }, + { + "t": 17.1406 + }, + { + "t": 17.7995 + }, + { + "t": 18.4611 + }, + { + "t": 19.1183 + }, + { + "t": 19.7826 + }, + { + "t": 20.4391 + }, + { + "t": 20.6074 + }, + { + "t": 21.0968 + }, + { + "t": 21.7558 + }, + { + "t": 22.418 + }, + { + "t": 23.0772 + }, + { + "t": 23.7343 + }, + { + "t": 24.3979 + }, + { + "t": 25.0548 + }, + { + "t": 25.7122 + }, + { + "t": 25.878 + }, + { + "t": 26.3714 + }, + { + "t": 27.0323 + }, + { + "t": 27.6923 + }, + { + "t": 28.3517 + }, + { + "t": 29.0123 + }, + { + "t": 29.6675 + }, + { + "t": 30.3284 + }, + { + "t": 30.9885 + }, + { + "t": 31.156 + }, + { + "t": 31.6477 + }, + { + "t": 32.3093 + }, + { + "t": 32.9669 + }, + { + "t": 33.6267 + }, + { + "t": 34.2854 + }, + { + "t": 34.9465 + }, + { + "t": 35.6048 + }, + { + "t": 36.2642 + }, + { + "t": 36.4313 + }, + { + "t": 36.9202 + }, + { + "t": 37.5841 + }, + { + "t": 38.243 + }, + { + "t": 38.9018 + }, + { + "t": 39.5603 + }, + { + "t": 40.22 + }, + { + "t": 40.8794 + }, + { + "t": 41.5372 + }, + { + "t": 41.7057 + }, + { + "t": 42.1978 + }, + { + "t": 42.8555 + }, + { + "t": 43.519 + }, + { + "t": 44.1734 + }, + { + "t": 44.8361 + }, + { + "t": 45.4961 + }, + { + "t": 46.1517 + }, + { + "t": 46.8136 + }, + { + "t": 46.9772 + }, + { + "t": 47.4725 + }, + { + "t": 48.1319 + }, + { + "t": 48.7912 + }, + { + "t": 49.4505 + }, + { + "t": 50.1099 + }, + { + "t": 50.7692 + }, + { + "t": 51.4286 + }, + { + "t": 52.0879 + }, + { + "t": 52.7473 + }, + { + "t": 53.4066 + }, + { + "t": 54.0659 + }, + { + "t": 54.7253 + }, + { + "t": 55.3846 + }, + { + "t": 56.044 + }, + { + "t": 56.7033 + }, + { + "t": 57.3626 + }, + { + "t": 58.022 + }, + { + "t": 58.6813 + }, + { + "t": 59.3407 + }, + { + "t": 60 + }, + { + "t": 60.6593 + }, + { + "t": 61.3187 + }, + { + "t": 61.978 + }, + { + "t": 62.6374 + }, + { + "t": 63.2967 + }, + { + "t": 63.956 + }, + { + "t": 64.6154 + }, + { + "t": 65.2747 + }, + { + "t": 65.9341 + }, + { + "t": 66.5934 + }, + { + "t": 67.2527 + }, + { + "t": 67.9121 + }, + { + "t": 68.5714 + }, + { + "t": 69.2308 + }, + { + "t": 69.8901 + }, + { + "t": 70.5495 + }, + { + "t": 71.2088 + }, + { + "t": 71.8681 + }, + { + "t": 72.5275 + }, + { + "t": 73.1868 + }, + { + "t": 73.8462 + }, + { + "t": 74.5055 + }, + { + "t": 75.1648 + }, + { + "t": 75.8242 + }, + { + "t": 76.4835 + }, + { + "t": 77.1429 + }, + { + "t": 77.8022 + }, + { + "t": 78.4615 + }, + { + "t": 79.1213 + }, + { + "t": 79.7791 + }, + { + "t": 80.4415 + }, + { + "t": 81.0975 + }, + { + "t": 81.7574 + }, + { + "t": 82.4173 + }, + { + "t": 83.0787 + }, + { + "t": 83.7348 + }, + { + "t": 83.9033 + }, + { + "t": 84.3928 + }, + { + "t": 85.0522 + }, + { + "t": 85.7168 + }, + { + "t": 86.3752 + }, + { + "t": 87.0314 + }, + { + "t": 87.6896 + }, + { + "t": 88.3506 + }, + { + "t": 89.0105 + }, + { + "t": 89.1772 + }, + { + "t": 89.6729 + }, + { + "t": 90.3295 + }, + { + "t": 90.9882 + }, + { + "t": 91.6465 + }, + { + "t": 92.3063 + }, + { + "t": 92.9696 + }, + { + "t": 93.6254 + }, + { + "t": 94.283 + }, + { + "t": 94.4477 + }, + { + "t": 94.9427 + }, + { + "t": 95.6072 + }, + { + "t": 96.2611 + }, + { + "t": 96.9202 + }, + { + "t": 97.5839 + }, + { + "t": 98.2431 + }, + { + "t": 98.8997 + }, + { + "t": 99.5619 + }, + { + "t": 99.7231 + }, + { + "t": 100.2223 + }, + { + "t": 100.877 + }, + { + "t": 101.5384 + }, + { + "t": 102.1964 + }, + { + "t": 102.8544 + }, + { + "t": 103.519 + }, + { + "t": 104.1774 + }, + { + "t": 104.8372 + }, + { + "t": 104.9973 + }, + { + "t": 105.4963 + }, + { + "t": 106.1533 + }, + { + "t": 106.8162 + }, + { + "t": 107.4705 + }, + { + "t": 108.1343 + }, + { + "t": 108.7902 + }, + { + "t": 109.4515 + }, + { + "t": 110.1098 + }, + { + "t": 110.2753 + }, + { + "t": 109.4505 + }, + { + "t": 109.7802 + }, + { + "t": 110.1099 + }, + { + "t": 110.4396 + }, + { + "t": 110.7701 + }, + { + "t": 111.3927 + }, + { + "t": 112.0157 + }, + { + "t": 112.6339 + }, + { + "t": 113.1038 + }, + { + "t": 113.2547 + }, + { + "t": 113.8774 + }, + { + "t": 114.5037 + }, + { + "t": 115.123 + }, + { + "t": 115.5921 + }, + { + "t": 115.7444 + }, + { + "t": 116.3673 + }, + { + "t": 116.9881 + }, + { + "t": 117.6111 + }, + { + "t": 118.0754 + }, + { + "t": 118.2334 + }, + { + "t": 118.8569 + }, + { + "t": 119.4761 + }, + { + "t": 120.0993 + }, + { + "t": 120.5685 + }, + { + "t": 120.7191 + }, + { + "t": 121.346 + }, + { + "t": 121.9678 + }, + { + "t": 122.5875 + }, + { + "t": 123.0535 + }, + { + "t": 123.2091 + }, + { + "t": 123.8308 + }, + { + "t": 124.4548 + }, + { + "t": 125.0783 + }, + { + "t": 125.5414 + }, + { + "t": 125.6992 + }, + { + "t": 126.3188 + }, + { + "t": 126.9394 + }, + { + "t": 127.5619 + }, + { + "t": 128.0307 + }, + { + "t": 128.1886 + }, + { + "t": 128.8097 + }, + { + "t": 129.4298 + }, + { + "t": 130.0513 + }, + { + "t": 130.5157 + }, + { + "t": 130.6712 + }, + { + "t": 131.2943 + }, + { + "t": 131.9161 + }, + { + "t": 132.5423 + }, + { + "t": 133.0042 + }, + { + "t": 133.1618 + }, + { + "t": 133.7825 + }, + { + "t": 134.4079 + }, + { + "t": 135.027 + }, + { + "t": 135.4922 + }, + { + "t": 135.6475 + }, + { + "t": 136.2707 + }, + { + "t": 136.8942 + }, + { + "t": 137.5188 + }, + { + "t": 137.9831 + }, + { + "t": 138.1382 + }, + { + "t": 138.7604 + }, + { + "t": 139.3806 + }, + { + "t": 140.0029 + }, + { + "t": 140.4729 + }, + { + "t": 144.3583 + }, + { + "t": 145.6024 + }, + { + "t": 146.8464 + }, + { + "t": 148.0905 + }, + { + "t": 149.3345 + }, + { + "t": 150.5785 + }, + { + "t": 151.8225 + }, + { + "t": 153.0666 + }, + { + "t": 154.3107 + }, + { + "t": 155.5547 + }, + { + "t": 156.7987 + }, + { + "t": 157.1097 + }, + { + "t": 157.4208 + }, + { + "t": 157.7318 + }, + { + "t": 158.0427 + }, + { + "t": 158.2667 + }, + { + "t": 158.4284 + }, + { + "t": 158.6896 + }, + { + "t": 158.926 + }, + { + "t": 159.1375 + } + ], + "snare": [ + { + "t": 16.1554 + }, + { + "t": 16.8121 + }, + { + "t": 17.4696 + }, + { + "t": 18.1346 + }, + { + "t": 18.7893 + }, + { + "t": 19.4531 + }, + { + "t": 20.108 + }, + { + "t": 20.7691 + }, + { + "t": 21.4291 + }, + { + "t": 22.0905 + }, + { + "t": 22.7465 + }, + { + "t": 23.4094 + }, + { + "t": 24.0686 + }, + { + "t": 24.7255 + }, + { + "t": 25.3848 + }, + { + "t": 26.0423 + }, + { + "t": 26.7031 + }, + { + "t": 27.3609 + }, + { + "t": 28.0232 + }, + { + "t": 28.6804 + }, + { + "t": 29.3383 + }, + { + "t": 29.9975 + }, + { + "t": 30.6606 + }, + { + "t": 31.3192 + }, + { + "t": 31.9785 + }, + { + "t": 32.6373 + }, + { + "t": 33.2968 + }, + { + "t": 33.9561 + }, + { + "t": 34.6146 + }, + { + "t": 35.2773 + }, + { + "t": 35.9336 + }, + { + "t": 36.5958 + }, + { + "t": 37.2522 + }, + { + "t": 37.9109 + }, + { + "t": 38.5726 + }, + { + "t": 39.2311 + }, + { + "t": 39.8874 + }, + { + "t": 40.5498 + }, + { + "t": 41.2059 + }, + { + "t": 41.8682 + }, + { + "t": 42.5264 + }, + { + "t": 43.1891 + }, + { + "t": 43.8451 + }, + { + "t": 44.5035 + }, + { + "t": 45.1659 + }, + { + "t": 45.8226 + }, + { + "t": 46.4837 + }, + { + "t": 47.1419 + }, + { + "t": 47.7995 + }, + { + "t": 48.4644 + }, + { + "t": 49.1202 + }, + { + "t": 49.7822 + }, + { + "t": 50.4414 + }, + { + "t": 51.0991 + }, + { + "t": 51.7595 + }, + { + "t": 52.4148 + }, + { + "t": 53.079 + }, + { + "t": 53.7343 + }, + { + "t": 54.3951 + }, + { + "t": 55.0525 + }, + { + "t": 55.714 + }, + { + "t": 56.3719 + }, + { + "t": 57.0332 + }, + { + "t": 57.6919 + }, + { + "t": 58.3524 + }, + { + "t": 59.0129 + }, + { + "t": 59.6728 + }, + { + "t": 60.3285 + }, + { + "t": 60.9861 + }, + { + "t": 61.6496 + }, + { + "t": 62.3067 + }, + { + "t": 62.9669 + }, + { + "t": 63.6286 + }, + { + "t": 64.2843 + }, + { + "t": 64.9455 + }, + { + "t": 65.6049 + }, + { + "t": 66.2646 + }, + { + "t": 66.9219 + }, + { + "t": 67.5832 + }, + { + "t": 68.2435 + }, + { + "t": 68.9021 + }, + { + "t": 69.5617 + }, + { + "t": 70.2186 + }, + { + "t": 70.8766 + }, + { + "t": 71.5372 + }, + { + "t": 72.1985 + }, + { + "t": 72.8542 + }, + { + "t": 73.5148 + }, + { + "t": 74.176 + }, + { + "t": 74.8336 + }, + { + "t": 75.4971 + }, + { + "t": 76.1553 + }, + { + "t": 76.8155 + }, + { + "t": 77.4754 + }, + { + "t": 78.1318 + }, + { + "t": 78.7886 + }, + { + "t": 79.4484 + }, + { + "t": 80.11 + }, + { + "t": 80.7695 + }, + { + "t": 81.4304 + }, + { + "t": 82.0879 + }, + { + "t": 82.7484 + }, + { + "t": 83.4078 + }, + { + "t": 84.0675 + }, + { + "t": 84.7231 + }, + { + "t": 85.3838 + }, + { + "t": 86.043 + }, + { + "t": 86.7004 + }, + { + "t": 87.3634 + }, + { + "t": 88.0223 + }, + { + "t": 88.68 + }, + { + "t": 89.3424 + }, + { + "t": 90.0029 + }, + { + "t": 90.6583 + }, + { + "t": 91.3167 + }, + { + "t": 91.9809 + }, + { + "t": 92.6356 + }, + { + "t": 93.2981 + }, + { + "t": 93.9544 + }, + { + "t": 94.6175 + }, + { + "t": 95.2724 + }, + { + "t": 95.9353 + }, + { + "t": 96.5908 + }, + { + "t": 97.2529 + }, + { + "t": 97.9106 + }, + { + "t": 98.5741 + }, + { + "t": 99.2324 + }, + { + "t": 99.8905 + }, + { + "t": 100.5505 + }, + { + "t": 101.2103 + }, + { + "t": 101.8671 + }, + { + "t": 102.5261 + }, + { + "t": 103.1861 + }, + { + "t": 103.8441 + }, + { + "t": 104.5071 + }, + { + "t": 105.1649 + }, + { + "t": 105.8261 + }, + { + "t": 106.4821 + }, + { + "t": 107.1446 + }, + { + "t": 107.8017 + }, + { + "t": 108.4601 + }, + { + "t": 109.1238 + }, + { + "t": 109.7819 + }, + { + "t": 110.1099 + }, + { + "t": 110.1923 + }, + { + "t": 110.2747 + }, + { + "t": 110.3571 + }, + { + "t": 110.4396 + }, + { + "t": 110.522 + }, + { + "t": 110.6044 + }, + { + "t": 110.6868 + }, + { + "t": 111.0808 + }, + { + "t": 111.6997 + }, + { + "t": 112.3262 + }, + { + "t": 112.9448 + }, + { + "t": 113.5709 + }, + { + "t": 114.1905 + }, + { + "t": 114.8126 + }, + { + "t": 115.437 + }, + { + "t": 116.0539 + }, + { + "t": 116.6775 + }, + { + "t": 117.2984 + }, + { + "t": 117.9243 + }, + { + "t": 118.5473 + }, + { + "t": 119.1671 + }, + { + "t": 119.7882 + }, + { + "t": 120.4079 + }, + { + "t": 121.0347 + }, + { + "t": 121.6573 + }, + { + "t": 122.2764 + }, + { + "t": 122.8961 + }, + { + "t": 123.5221 + }, + { + "t": 124.1445 + }, + { + "t": 124.7668 + }, + { + "t": 125.3855 + }, + { + "t": 126.0084 + }, + { + "t": 126.6298 + }, + { + "t": 127.2537 + }, + { + "t": 127.8763 + }, + { + "t": 128.4951 + }, + { + "t": 129.1193 + }, + { + "t": 129.7433 + }, + { + "t": 130.3618 + }, + { + "t": 130.9837 + }, + { + "t": 131.608 + }, + { + "t": 132.227 + }, + { + "t": 132.8481 + }, + { + "t": 133.4709 + }, + { + "t": 134.0942 + }, + { + "t": 134.7187 + }, + { + "t": 135.3368 + }, + { + "t": 135.9612 + }, + { + "t": 136.5807 + }, + { + "t": 137.2065 + }, + { + "t": 137.8258 + }, + { + "t": 138.4471 + }, + { + "t": 139.0737 + }, + { + "t": 139.6928 + }, + { + "t": 140.3159 + }, + { + "t": 140.9392 + }, + { + "t": 141.5583 + }, + { + "t": 142.1821 + }, + { + "t": 142.8042 + }, + { + "t": 143.4256 + }, + { + "t": 144.0492 + }, + { + "t": 144.6671 + }, + { + "t": 145.2905 + }, + { + "t": 145.9113 + }, + { + "t": 146.5339 + }, + { + "t": 147.1565 + }, + { + "t": 147.7779 + }, + { + "t": 148.4015 + }, + { + "t": 149.0216 + }, + { + "t": 149.6436 + }, + { + "t": 150.2685 + }, + { + "t": 150.888 + }, + { + "t": 151.5137 + }, + { + "t": 152.135 + }, + { + "t": 152.7537 + }, + { + "t": 153.3792 + }, + { + "t": 154.0021 + }, + { + "t": 154.6189 + }, + { + "t": 155.2415 + }, + { + "t": 155.8673 + }, + { + "t": 156.4852 + }, + { + "t": 157.108 + }, + { + "t": 157.7344 + }, + { + "t": 158.3519 + }, + { + "t": 158.9766 + }, + { + "t": 159.5995 + }, + { + "t": 160.2225 + } + ], + "sfx": [ + { + "t": 0.0108, + "tag": "sfx" + }, + { + "t": 1.3931, + "tag": "sfx" + }, + { + "t": 2.1906, + "tag": "sfx" + }, + { + "t": 2.9932, + "tag": "sfx" + }, + { + "t": 3.8051, + "tag": "sfx" + }, + { + "t": 4.5931, + "tag": "sfx" + }, + { + "t": 5.4167, + "tag": "sfx" + }, + { + "t": 6.2038, + "tag": "sfx" + }, + { + "t": 15.5242, + "tag": "sfx" + }, + { + "t": 15.5992, + "tag": "sfx" + }, + { + "t": 15.6742, + "tag": "sfx" + }, + { + "t": 15.7492, + "tag": "sfx" + }, + { + "t": 15.8233, + "tag": "sfx" + }, + { + "t": 15.9877, + "tag": "sfx" + }, + { + "t": 16.3214, + "tag": "sfx" + }, + { + "t": 16.4859, + "tag": "sfx" + }, + { + "t": 16.7358, + "tag": "sfx" + }, + { + "t": 16.9822, + "tag": "sfx" + }, + { + "t": 17.1426, + "tag": "sfx" + }, + { + "t": 17.3136, + "tag": "sfx" + }, + { + "t": 17.7147, + "tag": "sfx" + }, + { + "t": 17.8015, + "tag": "sfx" + }, + { + "t": 17.967, + "tag": "sfx" + }, + { + "t": 18.2962, + "tag": "sfx" + }, + { + "t": 18.4631, + "tag": "sfx" + }, + { + "t": 18.6209, + "tag": "sfx" + }, + { + "t": 19.0343, + "tag": "sfx" + }, + { + "t": 19.1203, + "tag": "sfx" + }, + { + "t": 19.2855, + "tag": "sfx" + }, + { + "t": 19.6177, + "tag": "sfx" + }, + { + "t": 19.7846, + "tag": "sfx" + }, + { + "t": 19.9451, + "tag": "sfx" + }, + { + "t": 20.3567, + "tag": "sfx" + }, + { + "t": 20.4411, + "tag": "sfx" + }, + { + "t": 20.5995, + "tag": "sfx" + }, + { + "t": 20.6094, + "tag": "sfx" + }, + { + "t": 21.0988, + "tag": "sfx" + }, + { + "t": 21.2648, + "tag": "sfx" + }, + { + "t": 21.4324, + "tag": "sfx" + }, + { + "t": 21.6783, + "tag": "sfx" + }, + { + "t": 21.7578, + "tag": "sfx" + }, + { + "t": 22.0859, + "tag": "sfx" + }, + { + "t": 22.2522, + "tag": "sfx" + }, + { + "t": 22.42, + "tag": "sfx" + }, + { + "t": 22.5861, + "tag": "sfx" + }, + { + "t": 23.0792, + "tag": "sfx" + }, + { + "t": 23.2361, + "tag": "sfx" + }, + { + "t": 23.7363, + "tag": "sfx" + }, + { + "t": 24.3999, + "tag": "sfx" + }, + { + "t": 25.0568, + "tag": "sfx" + }, + { + "t": 25.7142, + "tag": "sfx" + }, + { + "t": 25.88, + "tag": "sfx" + }, + { + "t": 26.3734, + "tag": "sfx" + }, + { + "t": 26.7013, + "tag": "sfx" + }, + { + "t": 27.0343, + "tag": "sfx" + }, + { + "t": 27.3577, + "tag": "sfx" + }, + { + "t": 27.6943, + "tag": "sfx" + }, + { + "t": 28.3537, + "tag": "sfx" + }, + { + "t": 29.0143, + "tag": "sfx" + }, + { + "t": 29.6695, + "tag": "sfx" + }, + { + "t": 30.3304, + "tag": "sfx" + }, + { + "t": 30.9905, + "tag": "sfx" + }, + { + "t": 31.158, + "tag": "sfx" + }, + { + "t": 31.6497, + "tag": "sfx" + }, + { + "t": 31.9747, + "tag": "sfx" + }, + { + "t": 32.3113, + "tag": "sfx" + }, + { + "t": 32.6351, + "tag": "sfx" + }, + { + "t": 32.9689, + "tag": "sfx" + }, + { + "t": 33.6287, + "tag": "sfx" + }, + { + "t": 34.2874, + "tag": "sfx" + }, + { + "t": 34.9485, + "tag": "sfx" + }, + { + "t": 35.6068, + "tag": "sfx" + }, + { + "t": 36.2662, + "tag": "sfx" + }, + { + "t": 36.4333, + "tag": "sfx" + }, + { + "t": 36.9222, + "tag": "sfx" + }, + { + "t": 37.2548, + "tag": "sfx" + }, + { + "t": 37.5861, + "tag": "sfx" + }, + { + "t": 37.916, + "tag": "sfx" + }, + { + "t": 38.245, + "tag": "sfx" + }, + { + "t": 38.9038, + "tag": "sfx" + }, + { + "t": 39.5623, + "tag": "sfx" + }, + { + "t": 39.8929, + "tag": "sfx" + }, + { + "t": 40.222, + "tag": "sfx" + }, + { + "t": 40.3835, + "tag": "sfx" + }, + { + "t": 40.7132, + "tag": "sfx" + }, + { + "t": 40.8814, + "tag": "sfx" + }, + { + "t": 41.0487, + "tag": "sfx" + }, + { + "t": 41.5371, + "tag": "sfx" + }, + { + "t": 41.7077, + "tag": "sfx" + }, + { + "t": 41.8659, + "tag": "sfx" + }, + { + "t": 42.1998, + "tag": "sfx" + }, + { + "t": 42.5286, + "tag": "sfx" + }, + { + "t": 42.8575, + "tag": "sfx" + }, + { + "t": 43.1829, + "tag": "sfx" + }, + { + "t": 43.5142, + "tag": "sfx" + }, + { + "t": 43.5952, + "tag": "sfx" + }, + { + "t": 43.7626, + "tag": "sfx" + }, + { + "t": 44.0151, + "tag": "sfx" + }, + { + "t": 44.1754, + "tag": "sfx" + }, + { + "t": 44.4263, + "tag": "sfx" + }, + { + "t": 44.8381, + "tag": "sfx" + }, + { + "t": 45.0865, + "tag": "sfx" + }, + { + "t": 45.4981, + "tag": "sfx" + }, + { + "t": 46.1537, + "tag": "sfx" + }, + { + "t": 46.8156, + "tag": "sfx" + }, + { + "t": 46.9792, + "tag": "sfx" + }, + { + "t": 47.222, + "tag": "sfx" + }, + { + "t": 47.311, + "tag": "sfx" + }, + { + "t": 47.4709, + "tag": "sfx" + }, + { + "t": 47.7198, + "tag": "sfx" + }, + { + "t": 48.132, + "tag": "sfx" + }, + { + "t": 48.7888, + "tag": "sfx" + }, + { + "t": 49.4525, + "tag": "sfx" + }, + { + "t": 49.8638, + "tag": "sfx" + }, + { + "t": 50.1119, + "tag": "sfx" + }, + { + "t": 50.7712, + "tag": "sfx" + }, + { + "t": 51.4306, + "tag": "sfx" + }, + { + "t": 51.597, + "tag": "sfx" + }, + { + "t": 52.0899, + "tag": "sfx" + }, + { + "t": 52.668, + "tag": "sfx" + }, + { + "t": 52.7493, + "tag": "sfx" + }, + { + "t": 53.3218, + "tag": "sfx" + }, + { + "t": 53.4086, + "tag": "sfx" + }, + { + "t": 54.0679, + "tag": "sfx" + }, + { + "t": 54.7273, + "tag": "sfx" + }, + { + "t": 55.3866, + "tag": "sfx" + }, + { + "t": 56.046, + "tag": "sfx" + }, + { + "t": 56.7053, + "tag": "sfx" + }, + { + "t": 57.3646, + "tag": "sfx" + }, + { + "t": 58, + "tag": "sfx" + }, + { + "t": 58.024, + "tag": "sfx" + }, + { + "t": 58.3297, + "tag": "sfx" + }, + { + "t": 58.6593, + "tag": "sfx" + }, + { + "t": 58.6833, + "tag": "sfx" + }, + { + "t": 58.989, + "tag": "sfx" + }, + { + "t": 59.3187, + "tag": "sfx" + }, + { + "t": 59.3427, + "tag": "sfx" + }, + { + "t": 59.6484, + "tag": "sfx" + }, + { + "t": 59.978, + "tag": "sfx" + }, + { + "t": 60.002, + "tag": "sfx" + }, + { + "t": 60.3077, + "tag": "sfx" + }, + { + "t": 60.6374, + "tag": "sfx" + }, + { + "t": 60.6613, + "tag": "sfx" + }, + { + "t": 61.3207, + "tag": "sfx" + }, + { + "t": 61.98, + "tag": "sfx" + }, + { + "t": 62.6394, + "tag": "sfx" + }, + { + "t": 63.2987, + "tag": "sfx" + }, + { + "t": 63.958, + "tag": "sfx" + }, + { + "t": 64.6174, + "tag": "sfx" + }, + { + "t": 65.2767, + "tag": "sfx" + }, + { + "t": 65.9361, + "tag": "sfx" + }, + { + "t": 66.5954, + "tag": "sfx" + }, + { + "t": 67.2547, + "tag": "sfx" + }, + { + "t": 67.9141, + "tag": "sfx" + }, + { + "t": 68.5734, + "tag": "sfx" + }, + { + "t": 69.2328, + "tag": "sfx" + }, + { + "t": 69.8921, + "tag": "sfx" + }, + { + "t": 70.5515, + "tag": "sfx" + }, + { + "t": 71.2108, + "tag": "sfx" + }, + { + "t": 71.8701, + "tag": "sfx" + }, + { + "t": 72.5295, + "tag": "sfx" + }, + { + "t": 73.1888, + "tag": "sfx" + }, + { + "t": 73.8482, + "tag": "sfx" + }, + { + "t": 74.5075, + "tag": "sfx" + }, + { + "t": 75.1668, + "tag": "sfx" + }, + { + "t": 75.8262, + "tag": "sfx" + }, + { + "t": 76.4855, + "tag": "sfx" + }, + { + "t": 77.1449, + "tag": "sfx" + }, + { + "t": 77.8042, + "tag": "sfx" + }, + { + "t": 78.4635, + "tag": "sfx" + }, + { + "t": 79.1233, + "tag": "sfx" + }, + { + "t": 79.1393, + "tag": "sfx" + }, + { + "t": 79.7811, + "tag": "sfx" + }, + { + "t": 79.7971, + "tag": "sfx" + }, + { + "t": 79.9642, + "tag": "sfx" + }, + { + "t": 80.4435, + "tag": "sfx" + }, + { + "t": 80.4595, + "tag": "sfx" + }, + { + "t": 81.0995, + "tag": "sfx" + }, + { + "t": 81.1155, + "tag": "sfx" + }, + { + "t": 81.7594, + "tag": "sfx" + }, + { + "t": 81.7754, + "tag": "sfx" + }, + { + "t": 82.4193, + "tag": "sfx" + }, + { + "t": 82.4353, + "tag": "sfx" + }, + { + "t": 83.0807, + "tag": "sfx" + }, + { + "t": 83.0967, + "tag": "sfx" + }, + { + "t": 83.7368, + "tag": "sfx" + }, + { + "t": 83.7528, + "tag": "sfx" + }, + { + "t": 83.9053, + "tag": "sfx" + }, + { + "t": 83.9213, + "tag": "sfx" + }, + { + "t": 84.3948, + "tag": "sfx" + }, + { + "t": 84.4108, + "tag": "sfx" + }, + { + "t": 85.0542, + "tag": "sfx" + }, + { + "t": 85.0702, + "tag": "sfx" + }, + { + "t": 85.7188, + "tag": "sfx" + }, + { + "t": 85.7348, + "tag": "sfx" + }, + { + "t": 86.3772, + "tag": "sfx" + }, + { + "t": 86.3932, + "tag": "sfx" + }, + { + "t": 87.0334, + "tag": "sfx" + }, + { + "t": 87.0494, + "tag": "sfx" + }, + { + "t": 87.6916, + "tag": "sfx" + }, + { + "t": 87.7076, + "tag": "sfx" + }, + { + "t": 88.0313, + "tag": "sfx" + }, + { + "t": 88.3526, + "tag": "sfx" + }, + { + "t": 88.3686, + "tag": "sfx" + }, + { + "t": 89.0125, + "tag": "sfx" + }, + { + "t": 89.0285, + "tag": "sfx" + }, + { + "t": 89.1792, + "tag": "sfx" + }, + { + "t": 89.1952, + "tag": "sfx" + }, + { + "t": 89.6749, + "tag": "sfx" + }, + { + "t": 89.6909, + "tag": "sfx" + }, + { + "t": 90.3315, + "tag": "sfx" + }, + { + "t": 90.3475, + "tag": "sfx" + }, + { + "t": 90.9902, + "tag": "sfx" + }, + { + "t": 91.0062, + "tag": "sfx" + }, + { + "t": 91.6485, + "tag": "sfx" + }, + { + "t": 91.6645, + "tag": "sfx" + }, + { + "t": 92.3083, + "tag": "sfx" + }, + { + "t": 92.3243, + "tag": "sfx" + }, + { + "t": 92.9716, + "tag": "sfx" + }, + { + "t": 92.9876, + "tag": "sfx" + }, + { + "t": 93.6274, + "tag": "sfx" + }, + { + "t": 93.6434, + "tag": "sfx" + }, + { + "t": 94.285, + "tag": "sfx" + }, + { + "t": 94.301, + "tag": "sfx" + }, + { + "t": 94.4497, + "tag": "sfx" + }, + { + "t": 94.4657, + "tag": "sfx" + }, + { + "t": 94.9447, + "tag": "sfx" + }, + { + "t": 94.9607, + "tag": "sfx" + }, + { + "t": 95.6092, + "tag": "sfx" + }, + { + "t": 95.6252, + "tag": "sfx" + }, + { + "t": 96.2631, + "tag": "sfx" + }, + { + "t": 96.2791, + "tag": "sfx" + }, + { + "t": 96.9222, + "tag": "sfx" + }, + { + "t": 96.9382, + "tag": "sfx" + }, + { + "t": 97.5859, + "tag": "sfx" + }, + { + "t": 97.6019, + "tag": "sfx" + }, + { + "t": 97.974, + "tag": "sfx" + }, + { + "t": 98.2451, + "tag": "sfx" + }, + { + "t": 98.2611, + "tag": "sfx" + }, + { + "t": 98.9017, + "tag": "sfx" + }, + { + "t": 98.9177, + "tag": "sfx" + }, + { + "t": 99.5639, + "tag": "sfx" + }, + { + "t": 99.5799, + "tag": "sfx" + }, + { + "t": 99.7251, + "tag": "sfx" + }, + { + "t": 99.7411, + "tag": "sfx" + }, + { + "t": 100.2243, + "tag": "sfx" + }, + { + "t": 100.2403, + "tag": "sfx" + }, + { + "t": 100.879, + "tag": "sfx" + }, + { + "t": 100.895, + "tag": "sfx" + }, + { + "t": 101.5404, + "tag": "sfx" + }, + { + "t": 101.5564, + "tag": "sfx" + }, + { + "t": 102.1984, + "tag": "sfx" + }, + { + "t": 102.2144, + "tag": "sfx" + }, + { + "t": 102.8564, + "tag": "sfx" + }, + { + "t": 102.8724, + "tag": "sfx" + }, + { + "t": 103.521, + "tag": "sfx" + }, + { + "t": 103.537, + "tag": "sfx" + }, + { + "t": 104.1794, + "tag": "sfx" + }, + { + "t": 104.1954, + "tag": "sfx" + }, + { + "t": 104.8392, + "tag": "sfx" + }, + { + "t": 104.8552, + "tag": "sfx" + }, + { + "t": 104.9993, + "tag": "sfx" + }, + { + "t": 105.0153, + "tag": "sfx" + }, + { + "t": 105.4983, + "tag": "sfx" + }, + { + "t": 105.5143, + "tag": "sfx" + }, + { + "t": 106.1553, + "tag": "sfx" + }, + { + "t": 106.1713, + "tag": "sfx" + }, + { + "t": 106.8182, + "tag": "sfx" + }, + { + "t": 106.8342, + "tag": "sfx" + }, + { + "t": 107.4725, + "tag": "sfx" + }, + { + "t": 107.4885, + "tag": "sfx" + }, + { + "t": 108.1363, + "tag": "sfx" + }, + { + "t": 108.1523, + "tag": "sfx" + }, + { + "t": 108.5062, + "tag": "sfx" + }, + { + "t": 108.7922, + "tag": "sfx" + }, + { + "t": 108.8082, + "tag": "sfx" + }, + { + "t": 109.2692, + "tag": "sfx" + }, + { + "t": 109.3492, + "tag": "sfx" + }, + { + "t": 109.4292, + "tag": "sfx" + }, + { + "t": 109.4525, + "tag": "sfx" + }, + { + "t": 109.4685, + "tag": "sfx" + }, + { + "t": 109.7822, + "tag": "sfx" + }, + { + "t": 109.7982, + "tag": "sfx" + }, + { + "t": 110.1118, + "tag": "sfx" + }, + { + "t": 110.1278, + "tag": "sfx" + }, + { + "t": 110.2773, + "tag": "sfx" + }, + { + "t": 110.2933, + "tag": "sfx" + }, + { + "t": 110.4416, + "tag": "sfx" + }, + { + "t": 110.4576, + "tag": "sfx" + }, + { + "t": 110.772, + "tag": "sfx" + }, + { + "t": 110.7871, + "tag": "sfx" + }, + { + "t": 111.3946, + "tag": "sfx" + }, + { + "t": 111.4097, + "tag": "sfx" + }, + { + "t": 112.0175, + "tag": "sfx" + }, + { + "t": 112.0326, + "tag": "sfx" + }, + { + "t": 112.6358, + "tag": "sfx" + }, + { + "t": 112.6508, + "tag": "sfx" + }, + { + "t": 113.1057, + "tag": "sfx" + }, + { + "t": 113.1208, + "tag": "sfx" + }, + { + "t": 113.2566, + "tag": "sfx" + }, + { + "t": 113.2717, + "tag": "sfx" + }, + { + "t": 113.8792, + "tag": "sfx" + }, + { + "t": 113.8943, + "tag": "sfx" + }, + { + "t": 114.5056, + "tag": "sfx" + }, + { + "t": 114.5207, + "tag": "sfx" + }, + { + "t": 115.1249, + "tag": "sfx" + }, + { + "t": 115.14, + "tag": "sfx" + }, + { + "t": 115.2308, + "tag": "sfx" + }, + { + "t": 115.594, + "tag": "sfx" + }, + { + "t": 115.6091, + "tag": "sfx" + }, + { + "t": 115.7463, + "tag": "sfx" + }, + { + "t": 115.7614, + "tag": "sfx" + }, + { + "t": 116.3692, + "tag": "sfx" + }, + { + "t": 116.3842, + "tag": "sfx" + }, + { + "t": 116.99, + "tag": "sfx" + }, + { + "t": 117.0051, + "tag": "sfx" + }, + { + "t": 117.613, + "tag": "sfx" + }, + { + "t": 117.6281, + "tag": "sfx" + }, + { + "t": 118.0773, + "tag": "sfx" + }, + { + "t": 118.0924, + "tag": "sfx" + }, + { + "t": 118.2353, + "tag": "sfx" + }, + { + "t": 118.2504, + "tag": "sfx" + }, + { + "t": 118.8588, + "tag": "sfx" + }, + { + "t": 118.8739, + "tag": "sfx" + }, + { + "t": 119.478, + "tag": "sfx" + }, + { + "t": 119.4931, + "tag": "sfx" + }, + { + "t": 120.1012, + "tag": "sfx" + }, + { + "t": 120.1163, + "tag": "sfx" + }, + { + "t": 120.5704, + "tag": "sfx" + }, + { + "t": 120.5855, + "tag": "sfx" + }, + { + "t": 120.7209, + "tag": "sfx" + }, + { + "t": 120.736, + "tag": "sfx" + }, + { + "t": 121.3479, + "tag": "sfx" + }, + { + "t": 121.363, + "tag": "sfx" + }, + { + "t": 121.9697, + "tag": "sfx" + }, + { + "t": 121.9848, + "tag": "sfx" + }, + { + "t": 122.5893, + "tag": "sfx" + }, + { + "t": 122.6044, + "tag": "sfx" + }, + { + "t": 123.0554, + "tag": "sfx" + }, + { + "t": 123.0705, + "tag": "sfx" + }, + { + "t": 123.2109, + "tag": "sfx" + }, + { + "t": 123.226, + "tag": "sfx" + }, + { + "t": 123.8326, + "tag": "sfx" + }, + { + "t": 123.8477, + "tag": "sfx" + }, + { + "t": 124.4567, + "tag": "sfx" + }, + { + "t": 124.4718, + "tag": "sfx" + }, + { + "t": 125.0802, + "tag": "sfx" + }, + { + "t": 125.0953, + "tag": "sfx" + }, + { + "t": 125.5433, + "tag": "sfx" + }, + { + "t": 125.5584, + "tag": "sfx" + }, + { + "t": 125.701, + "tag": "sfx" + }, + { + "t": 125.7161, + "tag": "sfx" + }, + { + "t": 126.3207, + "tag": "sfx" + }, + { + "t": 126.3358, + "tag": "sfx" + }, + { + "t": 126.9413, + "tag": "sfx" + }, + { + "t": 126.9564, + "tag": "sfx" + }, + { + "t": 127.5638, + "tag": "sfx" + }, + { + "t": 127.5789, + "tag": "sfx" + }, + { + "t": 128.0325, + "tag": "sfx" + }, + { + "t": 128.0476, + "tag": "sfx" + }, + { + "t": 128.1905, + "tag": "sfx" + }, + { + "t": 128.2056, + "tag": "sfx" + }, + { + "t": 128.8116, + "tag": "sfx" + }, + { + "t": 128.8267, + "tag": "sfx" + }, + { + "t": 129.4317, + "tag": "sfx" + }, + { + "t": 129.4468, + "tag": "sfx" + }, + { + "t": 130.0532, + "tag": "sfx" + }, + { + "t": 130.0683, + "tag": "sfx" + }, + { + "t": 130.5175, + "tag": "sfx" + }, + { + "t": 130.5326, + "tag": "sfx" + }, + { + "t": 130.6731, + "tag": "sfx" + }, + { + "t": 130.6882, + "tag": "sfx" + }, + { + "t": 130.7983, + "tag": "sfx" + }, + { + "t": 130.876, + "tag": "sfx" + }, + { + "t": 130.9538, + "tag": "sfx" + }, + { + "t": 131.0316, + "tag": "sfx" + }, + { + "t": 131.1093, + "tag": "sfx" + }, + { + "t": 131.1871, + "tag": "sfx" + }, + { + "t": 131.2648, + "tag": "sfx" + }, + { + "t": 131.2962, + "tag": "sfx" + }, + { + "t": 131.3113, + "tag": "sfx" + }, + { + "t": 131.918, + "tag": "sfx" + }, + { + "t": 131.9331, + "tag": "sfx" + }, + { + "t": 132.5442, + "tag": "sfx" + }, + { + "t": 132.5592, + "tag": "sfx" + }, + { + "t": 133.006, + "tag": "sfx" + }, + { + "t": 133.0211, + "tag": "sfx" + }, + { + "t": 133.1637, + "tag": "sfx" + }, + { + "t": 133.1788, + "tag": "sfx" + }, + { + "t": 133.7843, + "tag": "sfx" + }, + { + "t": 133.7994, + "tag": "sfx" + }, + { + "t": 134.4098, + "tag": "sfx" + }, + { + "t": 134.4249, + "tag": "sfx" + }, + { + "t": 135.0289, + "tag": "sfx" + }, + { + "t": 135.044, + "tag": "sfx" + }, + { + "t": 135.4941, + "tag": "sfx" + }, + { + "t": 135.5092, + "tag": "sfx" + }, + { + "t": 135.6494, + "tag": "sfx" + }, + { + "t": 135.6645, + "tag": "sfx" + }, + { + "t": 136.2725, + "tag": "sfx" + }, + { + "t": 136.2876, + "tag": "sfx" + }, + { + "t": 136.8961, + "tag": "sfx" + }, + { + "t": 136.9112, + "tag": "sfx" + }, + { + "t": 137.5207, + "tag": "sfx" + }, + { + "t": 137.5358, + "tag": "sfx" + }, + { + "t": 137.985, + "tag": "sfx" + }, + { + "t": 138.0001, + "tag": "sfx" + }, + { + "t": 138.1401, + "tag": "sfx" + }, + { + "t": 138.1552, + "tag": "sfx" + }, + { + "t": 138.6285, + "tag": "sfx" + }, + { + "t": 138.7228, + "tag": "sfx" + }, + { + "t": 138.7623, + "tag": "sfx" + }, + { + "t": 138.7774, + "tag": "sfx" + }, + { + "t": 138.8643, + "tag": "sfx" + }, + { + "t": 139.053, + "tag": "sfx" + }, + { + "t": 139.1945, + "tag": "sfx" + }, + { + "t": 139.2606, + "tag": "sfx" + }, + { + "t": 139.3825, + "tag": "sfx" + }, + { + "t": 139.3975, + "tag": "sfx" + }, + { + "t": 139.4587, + "tag": "sfx" + }, + { + "t": 139.6191, + "tag": "sfx" + }, + { + "t": 139.8549, + "tag": "sfx" + }, + { + "t": 140.0048, + "tag": "sfx" + }, + { + "t": 140.0199, + "tag": "sfx" + }, + { + "t": 140.0436, + "tag": "sfx" + }, + { + "t": 140.2511, + "tag": "sfx" + }, + { + "t": 140.4209, + "tag": "sfx" + }, + { + "t": 140.4748, + "tag": "sfx" + }, + { + "t": 140.4899, + "tag": "sfx" + }, + { + "t": 144.3602, + "tag": "sfx" + }, + { + "t": 145.6042, + "tag": "sfx" + }, + { + "t": 146.8483, + "tag": "sfx" + }, + { + "t": 148.0924, + "tag": "sfx" + }, + { + "t": 149.3364, + "tag": "sfx" + }, + { + "t": 150.5804, + "tag": "sfx" + }, + { + "t": 151.8244, + "tag": "sfx" + }, + { + "t": 152.0247, + "tag": "sfx" + }, + { + "t": 153.0685, + "tag": "sfx" + }, + { + "t": 153.6285, + "tag": "sfx" + }, + { + "t": 154.3125, + "tag": "sfx" + }, + { + "t": 155.1379, + "tag": "sfx" + }, + { + "t": 155.5566, + "tag": "sfx" + }, + { + "t": 156.553, + "tag": "sfx" + }, + { + "t": 156.8006, + "tag": "sfx" + }, + { + "t": 157.1116, + "tag": "sfx" + }, + { + "t": 157.4226, + "tag": "sfx" + }, + { + "t": 157.7337, + "tag": "sfx" + }, + { + "t": 157.8738, + "tag": "sfx" + }, + { + "t": 158.0446, + "tag": "sfx" + }, + { + "t": 158.2686, + "tag": "sfx" + }, + { + "t": 158.4303, + "tag": "sfx" + }, + { + "t": 158.6915, + "tag": "sfx" + }, + { + "t": 158.9279, + "tag": "sfx" + }, + { + "t": 159.1002, + "tag": "sfx" + }, + { + "t": 159.1394, + "tag": "sfx" + } + ] + }, + "masterWarpApplied": { + "tempoCut": 110.77, + "tempoMul": 1.06, + "truncate": 162 + }, + "subBeatsExpanded": true +} diff --git a/pop/lib/pixel-art/ac24.png b/pop/lib/pixel-art/ac24.png new file mode 100644 index 0000000000000000000000000000000000000000..20e671e69c5d7618264b268f7143159d9c62db76 GIT binary patch literal 387 zcmeAS@N?(olHy`uVBq!ia0y~yV31&7U|{56W?*3WTrh7Y0|SFXvPY0F14ES>14Ba# z1H&%{28M?NMQuI#T^Wf&MtIwmkKV_;xVEpd$~Nl7e8wMs5ZO)N=e zFfuSQ(={;FHL?gXFt9Q(urf8$1~V+SFXTnhkei>9nO2EggXb&BjSLJ78gLs*GILXl zOA>PnaO;u#Z;{8qz@X^q;uyklJvkvEB`Ga2HTn62Cy$;zeEL{iKtxDPP*j+mfm4)G V)O2oa0RsaAgQu&X%Q~loCIF14dE5X1 literal 0 HcmV?d00001 diff --git a/pop/lib/preview-shared.mjs b/pop/lib/preview-shared.mjs index da7c0135c..542808732 100644 --- a/pop/lib/preview-shared.mjs +++ b/pop/lib/preview-shared.mjs @@ -482,7 +482,8 @@ export function spawnFFmpegEncode({ audioPath, w, h, fps, outPath, crf = 20 }) { "-r", String(fps), "-i", "-", "-i", audioPath, - "-c:v", "libx264", "-preset", "medium", "-crf", String(crf), + "-c:v", "libx264", "-preset", "faster", "-crf", String(crf), + "-threads", "0", "-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", "-shortest", diff --git a/system/public/aesthetic.computer/disks/hellsine.mjs b/system/public/aesthetic.computer/disks/hellsine.mjs new file mode 100644 index 000000000..de6b68359 --- /dev/null +++ b/system/public/aesthetic.computer/disks/hellsine.mjs @@ -0,0 +1,27 @@ +// hellsine, 2026.05.27 +// pop/hellsine/ released single — see pop/RELEASES.md. +// Thin wrapper around lib/pop.mjs (mirror of marimbaba.mjs). + +import * as pop from "../lib/pop.mjs"; + +const MANIFEST_URL = "/aesthetic.computer/disks/pop/hellsine.json"; +let manifest = null; + +async function boot($) { + if (!manifest) { + manifest = await fetch(MANIFEST_URL).then((r) => r.json()); + } + return pop.boot($, manifest); +} + +function paint($) { return pop.paint($); } +function sim($) { return pop.sim($); } +function act($) { return pop.act($); } +function leave($) { return pop.leave($); } +function meta() { + return manifest + ? pop.meta(manifest) + : { title: "hellsine — Aesthetic Dot Computer", desc: "felt-puppet hellfire waltz." }; +} + +export { boot, paint, sim, act, leave, meta }; diff --git a/system/public/aesthetic.computer/disks/pop/hellsine.json b/system/public/aesthetic.computer/disks/pop/hellsine.json new file mode 100644 index 000000000..bd88d26d7 --- /dev/null +++ b/system/public/aesthetic.computer/disks/pop/hellsine.json @@ -0,0 +1,33 @@ +{ + "slug": "hellsine", + "title": "hellsine", + "artist": "Aesthetic Dot Computer", + "album": "pixsies", + "bpm": 182, + "key": "F# minor", + "meter": "4/4", + "duration": 161.961247, + "audio": "/assets/pop/hellsine.mp3", + "cover": "/assets/pop/hellsine.jpg", + "sections": [ + { "name": "overture-a", "t": 0, "illy": "/assets/pop/hellsine/sec-0.pixel.png", "code": "a" }, + { "name": "overture-b", "t": 5.124274847078675, "illy": "/assets/pop/hellsine/sec-1.pixel.png", "code": "b" }, + { "name": "overture-c", "t": 10.24854969415735, "illy": "/assets/pop/hellsine/sec-2.pixel.png", "code": "c" }, + { "name": "statement-a", "t": 15.372824541236028, "illy": "/assets/pop/hellsine/sec-3.pixel.png", "code": "d" }, + { "name": "statement-b", "t": 25.621374235393375, "illy": "/assets/pop/hellsine/sec-4.pixel.png", "code": "e" }, + { "name": "statement-c", "t": 35.86992392955072, "illy": "/assets/pop/hellsine/sec-5.pixel.png", "code": "f" }, + { "name": "bridge-a", "t": 46.118473623708084, "illy": "/assets/pop/hellsine/sec-6.pixel.png", "code": "g" }, + { "name": "bridge-b", "t": 53.8048858943261, "illy": "/assets/pop/hellsine/sec-7.pixel.png", "code": "h" }, + { "name": "bridge-c", "t": 61.49129816494411, "illy": "/assets/pop/hellsine/sec-8.pixel.png", "code": "i" }, + { "name": "bridge-d", "t": 69.17771043556212, "illy": "/assets/pop/hellsine/sec-9.pixel.png", "code": "j" }, + { "name": "develop-a", "t": 76.86412270618014, "illy": "/assets/pop/hellsine/sec-10.pixel.png", "code": "k" }, + { "name": "develop-b", "t": 87.11267240033749, "illy": "/assets/pop/hellsine/sec-11.pixel.png", "code": "l" }, + { "name": "develop-c", "t": 97.36122209449483, "illy": "/assets/pop/hellsine/sec-12.pixel.png", "code": "m" }, + { "name": "climax-a", "t": 107.6097717886522, "illy": "/assets/pop/hellsine/sec-13.pixel.png", "code": "n" }, + { "name": "climax-b", "t": 117.85832148280954, "illy": "/assets/pop/hellsine/sec-14.pixel.png", "code": "o" }, + { "name": "climax-c", "t": 128.1068711769669, "illy": "/assets/pop/hellsine/sec-15.pixel.png", "code": "p" }, + { "name": "coda-a", "t": 138.35542087112424, "illy": "/assets/pop/hellsine/sec-16.pixel.png", "code": "q" }, + { "name": "coda-b", "t": 148.6039705652816, "illy": "/assets/pop/hellsine/sec-17.pixel.png", "code": "r" } + ], + "credits": "felt-puppet hellfire waltz for the pixsies body. all instruments + composition + lava sines: aesthetic dot computer." +} diff --git a/system/public/aesthetic.computer/disks/pop/helpabeach.json b/system/public/aesthetic.computer/disks/pop/helpabeach.json index 66ca356a9..e825e038a 100644 --- a/system/public/aesthetic.computer/disks/pop/helpabeach.json +++ b/system/public/aesthetic.computer/disks/pop/helpabeach.json @@ -10,15 +10,60 @@ "audio": "/assets/pop/helpabeach.mp3", "cover": "/assets/pop/helpabeach.jpg", "sections": [ - { "name": "tide-in", "t": 0, "illy": "/assets/pop/helpabeach/sec-0.jpg" }, - { "name": "drift 1", "t": 15.7143, "illy": "/assets/pop/helpabeach/sec-1.jpg" }, - { "name": "swell 1", "t": 35.7143, "illy": "/assets/pop/helpabeach/sec-2.jpg" }, - { "name": "deep-current", "t": 47.1429, "illy": "/assets/pop/helpabeach/sec-3.jpg" }, - { "name": "drift 2", "t": 65.7143, "illy": "/assets/pop/helpabeach/sec-4.jpg" }, - { "name": "undertow", "t": 84.2857, "illy": "/assets/pop/helpabeach/sec-5.jpg" }, - { "name": "swell 2", "t": 102.857, "illy": "/assets/pop/helpabeach/sec-6.jpg" }, - { "name": "tide-out", "t": 115.714, "illy": "/assets/pop/helpabeach/sec-7.jpg" }, - { "name": "ebb", "t": 132.857, "illy": "/assets/pop/helpabeach/sec-8.jpg" } + { + "name": "tide-in", + "t": 0, + "illy": "/assets/pop/helpabeach/sec-0.jpg", + "code": "a" + }, + { + "name": "drift 1", + "t": 15.7143, + "illy": "/assets/pop/helpabeach/sec-1.jpg", + "code": "b" + }, + { + "name": "swell 1", + "t": 35.7143, + "illy": "/assets/pop/helpabeach/sec-2.jpg", + "code": "c" + }, + { + "name": "deep-current", + "t": 47.1429, + "illy": "/assets/pop/helpabeach/sec-3.jpg", + "code": "d" + }, + { + "name": "drift 2", + "t": 65.7143, + "illy": "/assets/pop/helpabeach/sec-4.jpg", + "code": "e" + }, + { + "name": "undertow", + "t": 84.2857, + "illy": "/assets/pop/helpabeach/sec-5.jpg", + "code": "f" + }, + { + "name": "swell 2", + "t": 102.857, + "illy": "/assets/pop/helpabeach/sec-6.jpg", + "code": "g" + }, + { + "name": "tide-out", + "t": 115.714, + "illy": "/assets/pop/helpabeach/sec-7.jpg", + "code": "h" + }, + { + "name": "ebb", + "t": 132.857, + "illy": "/assets/pop/helpabeach/sec-8.jpg", + "code": "i" + } ], "links": { "spotify": "https://open.spotify.com/track/3jzlAylJQLSsNIXjnEY1e8" diff --git a/system/public/aesthetic.computer/disks/pop/marimbaba.json b/system/public/aesthetic.computer/disks/pop/marimbaba.json index b3733cace..5205f5339 100644 --- a/system/public/aesthetic.computer/disks/pop/marimbaba.json +++ b/system/public/aesthetic.computer/disks/pop/marimbaba.json @@ -10,16 +10,66 @@ "audio": "/assets/pop/marimbaba.mp3", "cover": "/assets/pop/marimbaba.jpg", "sections": [ - { "name": "hush1", "t": 0, "illy": "/assets/pop/marimbaba/sec-0.jpg" }, - { "name": "hush2", "t": 6.4286, "illy": "/assets/pop/marimbaba/sec-1.jpg" }, - { "name": "twinkle1", "t": 12.8571, "illy": "/assets/pop/marimbaba/sec-2.jpg" }, - { "name": "twinkle2", "t": 22.5, "illy": "/assets/pop/marimbaba/sec-3.jpg" }, - { "name": "wow1", "t": 32.1429, "illy": "/assets/pop/marimbaba/sec-4.jpg" }, - { "name": "wow2", "t": 38.5714, "illy": "/assets/pop/marimbaba/sec-5.jpg" }, - { "name": "baba1", "t": 45, "illy": "/assets/pop/marimbaba/sec-6.jpg" }, - { "name": "baba2", "t": 51.4286, "illy": "/assets/pop/marimbaba/sec-7.jpg" }, - { "name": "sleep1", "t": 57.8571, "illy": "/assets/pop/marimbaba/sec-8.jpg" }, - { "name": "sleep2", "t": 70.7143, "illy": "/assets/pop/marimbaba/sec-9.jpg" } + { + "name": "hush1", + "t": 0, + "illy": "/assets/pop/marimbaba/sec-0.jpg", + "code": "a" + }, + { + "name": "hush2", + "t": 6.4286, + "illy": "/assets/pop/marimbaba/sec-1.jpg", + "code": "b" + }, + { + "name": "twinkle1", + "t": 12.8571, + "illy": "/assets/pop/marimbaba/sec-2.jpg", + "code": "c" + }, + { + "name": "twinkle2", + "t": 22.5, + "illy": "/assets/pop/marimbaba/sec-3.jpg", + "code": "d" + }, + { + "name": "wow1", + "t": 32.1429, + "illy": "/assets/pop/marimbaba/sec-4.jpg", + "code": "e" + }, + { + "name": "wow2", + "t": 38.5714, + "illy": "/assets/pop/marimbaba/sec-5.jpg", + "code": "f" + }, + { + "name": "baba1", + "t": 45, + "illy": "/assets/pop/marimbaba/sec-6.jpg", + "code": "g" + }, + { + "name": "baba2", + "t": 51.4286, + "illy": "/assets/pop/marimbaba/sec-7.jpg", + "code": "h" + }, + { + "name": "sleep1", + "t": 57.8571, + "illy": "/assets/pop/marimbaba/sec-8.jpg", + "code": "i" + }, + { + "name": "sleep2", + "t": 70.7143, + "illy": "/assets/pop/marimbaba/sec-9.jpg", + "code": "j" + } ], "links": { "spotify": "https://open.spotify.com/track/1gopbVPw6LoinIpOANOnEG" diff --git a/system/public/aesthetic.computer/disks/pop/trancenwaltz.json b/system/public/aesthetic.computer/disks/pop/trancenwaltz.json index faf0eb2d5..d7831dc60 100644 --- a/system/public/aesthetic.computer/disks/pop/trancenwaltz.json +++ b/system/public/aesthetic.computer/disks/pop/trancenwaltz.json @@ -10,14 +10,54 @@ "audio": "/assets/pop/trancenwaltz.mp3", "cover": "/assets/pop/trancenwaltz.jpg", "sections": [ - { "name": "intro", "t": 2.7, "illy": "/assets/pop/trancenwaltz/sec-0.jpg" }, - { "name": "break1", "t": 10.575, "illy": "/assets/pop/trancenwaltz/sec-1.jpg" }, - { "name": "build1", "t": 26.325, "illy": "/assets/pop/trancenwaltz/sec-2.jpg" }, - { "name": "drop1", "t": 34.2, "illy": "/assets/pop/trancenwaltz/sec-3.jpg" }, - { "name": "break2", "t": 49.95, "illy": "/assets/pop/trancenwaltz/sec-4.jpg" }, - { "name": "build2", "t": 57.825, "illy": "/assets/pop/trancenwaltz/sec-5.jpg" }, - { "name": "drop2", "t": 61.762, "illy": "/assets/pop/trancenwaltz/sec-6.jpg" }, - { "name": "outro", "t": 77.512, "illy": "/assets/pop/trancenwaltz/sec-7.jpg" } + { + "name": "intro", + "t": 2.7, + "illy": "/assets/pop/trancenwaltz/sec-0.jpg", + "code": "a" + }, + { + "name": "break1", + "t": 10.575, + "illy": "/assets/pop/trancenwaltz/sec-1.jpg", + "code": "b" + }, + { + "name": "build1", + "t": 26.325, + "illy": "/assets/pop/trancenwaltz/sec-2.jpg", + "code": "c" + }, + { + "name": "drop1", + "t": 34.2, + "illy": "/assets/pop/trancenwaltz/sec-3.jpg", + "code": "d" + }, + { + "name": "break2", + "t": 49.95, + "illy": "/assets/pop/trancenwaltz/sec-4.jpg", + "code": "e" + }, + { + "name": "build2", + "t": 57.825, + "illy": "/assets/pop/trancenwaltz/sec-5.jpg", + "code": "f" + }, + { + "name": "drop2", + "t": 61.762, + "illy": "/assets/pop/trancenwaltz/sec-6.jpg", + "code": "g" + }, + { + "name": "outro", + "t": 77.512, + "illy": "/assets/pop/trancenwaltz/sec-7.jpg", + "code": "h" + } ], "links": { "spotify": "https://open.spotify.com/track/3PIPwPqptVlWy71rCEhQum" diff --git a/system/public/aesthetic.computer/disks/pop/trancepenta.json b/system/public/aesthetic.computer/disks/pop/trancepenta.json index fe3b11ac9..e35e7f194 100644 --- a/system/public/aesthetic.computer/disks/pop/trancepenta.json +++ b/system/public/aesthetic.computer/disks/pop/trancepenta.json @@ -10,14 +10,54 @@ "audio": "/assets/pop/trancepenta.mp3", "cover": "/assets/pop/trancepenta.jpg", "sections": [ - { "name": "intro", "t": 0.25, "illy": "/assets/pop/trancepenta/sec-0.jpg" }, - { "name": "break1", "t": 9.774, "illy": "/assets/pop/trancepenta/sec-1.jpg" }, - { "name": "build1", "t": 19.298, "illy": "/assets/pop/trancepenta/sec-2.jpg" }, - { "name": "drop1", "t": 28.821, "illy": "/assets/pop/trancepenta/sec-3.jpg" }, - { "name": "break2", "t": 85.964, "illy": "/assets/pop/trancepenta/sec-4.jpg" }, - { "name": "build2", "t": 95.488, "illy": "/assets/pop/trancepenta/sec-5.jpg" }, - { "name": "drop2", "t": 105.012, "illy": "/assets/pop/trancepenta/sec-6.jpg" }, - { "name": "outro", "t": 171.679, "illy": "/assets/pop/trancepenta/sec-7.jpg" } + { + "name": "intro", + "t": 0.25, + "illy": "/assets/pop/trancepenta/sec-0.jpg", + "code": "a" + }, + { + "name": "break1", + "t": 9.774, + "illy": "/assets/pop/trancepenta/sec-1.jpg", + "code": "b" + }, + { + "name": "build1", + "t": 19.298, + "illy": "/assets/pop/trancepenta/sec-2.jpg", + "code": "c" + }, + { + "name": "drop1", + "t": 28.821, + "illy": "/assets/pop/trancepenta/sec-3.jpg", + "code": "d" + }, + { + "name": "break2", + "t": 85.964, + "illy": "/assets/pop/trancepenta/sec-4.jpg", + "code": "e" + }, + { + "name": "build2", + "t": 95.488, + "illy": "/assets/pop/trancepenta/sec-5.jpg", + "code": "f" + }, + { + "name": "drop2", + "t": 105.012, + "illy": "/assets/pop/trancepenta/sec-6.jpg", + "code": "g" + }, + { + "name": "outro", + "t": 171.679, + "illy": "/assets/pop/trancepenta/sec-7.jpg", + "code": "h" + } ], "links": { "spotify": "https://open.spotify.com/track/4SVH80CTkq2BihSlSyiJhG" diff --git a/system/public/aesthetic.computer/lib/pop.mjs b/system/public/aesthetic.computer/lib/pop.mjs index af0206242..29107ee49 100644 --- a/system/public/aesthetic.computer/lib/pop.mjs +++ b/system/public/aesthetic.computer/lib/pop.mjs @@ -14,14 +14,16 @@ let manifest = null; let sfx = null; // preloaded sample handle let cover = null; // preloaded cover image let illys = []; // preloaded section illustrations, parallel to manifest.sections +let illyErrors = []; // url string per failed load, for in-frame diagnosis +let sectionRgbs = []; // [r,g,b] per section, cached after manifest load let playing = null; // active `sound.play(...)` handle let progress = 0; // 0..1 — driven by playingSample.progress() let currentSec = 0; let activeSection = 0; let wantPlay = false; // user intent — flips on first tap let needsPlayGesture = true; +let pendingSeekFrac = null; // set by boot section-jump, applied once sfx loads let playBtn = null; -let spotifyBtn = null; let galleryOpen = false; let galleryIndex = 0; let frame = 0; @@ -41,15 +43,42 @@ async function boot($, m) { galleryOpen = false; needsPlayGesture = true; wantPlay = false; + pendingSeekFrac = null; - const { net, hud, ui, screen } = $; + const { net, hud, ui, screen, params, colon } = $; hud.label(manifest.title); hud.labelBack(); - if (manifest.links?.spotify) { - spotifyBtn = new ui.TextButton("spotify", { x: 6, bottom: 6, screen }); + playBtn = new ui.TextButton("play", { right: 6, bottom: 6, screen }); + + // Precompute the per-section RGB tints used by the scrub bar fill, + // illy-strip border, and gallery footer. Manifest may carry an + // explicit `color: [r,g,b]` per section; otherwise we derive HSL from + // index so the timeline reads as a perceptible arc. + sectionRgbs = manifest.sections.map((sec, i) => { + if (Array.isArray(sec.color) && sec.color.length === 3) return sec.color.slice(); + const hue = (i / max(1, manifest.sections.length)) * 360; + return hslToRgb(hue, 0.55, 0.55); + }); + + // Section-jump: `marimbaba c` or `marimbaba:c` jumps to section letter c. + // The letter comes from manifest.sections[i].code (a..z) added by + // pop/bin/codify-sections.mjs. Either params (space-separated) or + // colon (URL path style) carries it. + const sectionArg = (params?.[0] || colon?.[0] || "").toString().trim().toLowerCase(); + if (sectionArg) { + const idx = manifest.sections.findIndex((s) => s.code === sectionArg); + if (idx >= 0) { + const t0 = manifest.sections[idx].t || 0; + const dur = manifest.duration || 1; + pendingSeekFrac = max(0, min(1, t0 / dur)); + activeSection = idx; + currentSec = t0; + progress = pendingSeekFrac; + } else { + console.warn(`pop: no section with code "${sectionArg}" (have: ${manifest.sections.map((s) => s.code).join(",")})`); + } } - playBtn = new ui.TextButton("play", { x: screen.width - 60, bottom: 6, screen }); // Preload audio + cover up front; illys lazily (fire-and-forget so the // player paints immediately even if the section art is still streaming). @@ -65,10 +94,28 @@ async function boot($, m) { } illys = new Array(manifest.sections.length).fill(null); + illyErrors = new Array(manifest.sections.length).fill(null); manifest.sections.forEach((sec, i) => { if (!sec.illy) return; - net.preload(sec.illy).then((img) => { illys[i] = img; }).catch(() => {}); + net.preload(sec.illy).then((img) => { + if (!img || !img.width || !img.height) { + illyErrors[i] = `${sec.illy} (no dimensions)`; + console.warn(`pop: illy ${i} loaded but has no dimensions:`, sec.illy); + return; + } + illys[i] = img; + }).catch((err) => { + illyErrors[i] = `${sec.illy}: ${err?.message || err}`; + console.warn(`pop: illy ${i} preload failed:`, sec.illy, err); + }); }); + + // Apply the queued section-jump as soon as sfx is loaded. + if (sfx && pendingSeekFrac !== null) { + const frac = pendingSeekFrac; + pendingSeekFrac = null; + seek($, frac); + } } function paint($) { @@ -129,13 +176,6 @@ function act($) { push: () => togglePlayback($), }); - // Spotify button - spotifyBtn?.btn?.act(e, { - push: () => { - if (manifest.links?.spotify) jump(`out:${manifest.links.spotify}`); - }, - }); - // Tap on cover area — toggle play/pause if (e.is("touch") && !insideButtons(e, screen)) { // Tap on an illy thumb → open gallery to that section @@ -231,7 +271,6 @@ function pointIn(e, g) { function insideButtons(e, screen) { if (playBtn?.btn?.box && pointIn(e, playBtn.btn.box)) return true; - if (spotifyBtn?.btn?.box && pointIn(e, spotifyBtn.btn.box)) return true; return false; } @@ -302,10 +341,10 @@ function paintTitleBar($) { ink(0, 0, 0, 140).write(sub, { x: 7, y: 18 }); ink(220, 210, 195).write(sub, { x: 6, y: 17 }); - // Section name (right-aligned) + // Section name + letter code (right-aligned). const sec = manifest.sections[activeSection]; if (sec) { - const label = sec.name; + const label = sec.code ? `${sec.name} · ${sec.code}` : sec.name; const tx = w - label.length * 6 - 6; ink(0, 0, 0, 140).write(label, { x: tx + 1, y: 7 }); ink(255, 220, 160).write(label, { x: tx, y: 6 }); @@ -313,28 +352,124 @@ function paintTitleBar($) { } function paintScrubBar($) { - const { ink, screen } = $; + const { ink, screen, sound } = $; const w = screen.width; const h = screen.height; const margin = 6; - const barH = 6; - const barY = h - 56; + const barH = 8; + const barY = h - 58; const barW = w - margin * 2; scrub = { x: margin, y: barY, w: barW, h: barH }; - ink(0, 0, 0, 160).box(margin, barY, barW, barH, "fill"); - ink(255, 240, 200, 60).box(margin, barY, barW, barH, "outline"); + // Dark backdrop trough. + ink(0, 0, 0, 180).box(margin, barY, barW, barH, "fill"); + + // Per-section coloured blocks — the timeline's section colour arc. + // Unplayed = dim, played = bright (alpha doubles past the playhead). + const playedX = margin + floor(barW * progress); + const lastIdx = manifest.sections.length - 1; + for (let i = 0; i < manifest.sections.length; i++) { + const sec = manifest.sections[i]; + const next = manifest.sections[i + 1]; + const x0 = margin + floor((sec.t / manifest.duration) * barW); + const x1 = i === lastIdx + ? margin + barW + : margin + floor((next.t / manifest.duration) * barW); + const [r, g, b] = sectionRgbs[i] || [200, 200, 200]; + // Dim base across the whole block. + ink(r, g, b, 70).box(x0, barY, x1 - x0, barH, "fill"); + // Bright overlay across the played portion of this block. + const fx1 = min(x1, playedX); + if (fx1 > x0) ink(r, g, b, 230).box(x0, barY, fx1 - x0, barH, "fill"); + // Section divider. + if (i > 0) ink(0, 0, 0, 200).box(x0, barY - 1, 1, barH + 2, "fill"); + } + + // — VHS-style overlays on the played portion — + // Ported from disks/common/tape-player.mjs renderVHSProgressBar, but + // adapted to the AC piece API (no canvas / no per-pixel imageData). + // Per-pixel image sampling becomes section-palette sampling; canvas + // glow becomes overlapping translucent boxes. + if (playedX > margin) { + // 1) Scan-line texture — every 2px the played row gets a darker stripe. + // Cycles slowly with `frame` so it reads as analog tracking jitter. + const scanOff = frame % 2; + for (let x = margin + scanOff; x < playedX; x += 2) { + ink(0, 0, 0, 60).box(x, barY, 1, barH, "fill"); + } + // A second, slower-moving brightness flicker pass — the "tracking" jitter + // from calculateVHSEffects. Picks the local section colour so the + // flicker tints rather than washes the bar. + const trackPhase = (frame * 0.08) % (PI * 2); + const trackStripeX = margin + floor(((sin(trackPhase) * 0.5 + 0.5) * (playedX - margin))); + if (trackStripeX > margin && trackStripeX < playedX) { + const tIdx = sectionAtX(trackStripeX, margin, barW); + const [tr, tg, tb] = sectionRgbs[tIdx] || [255, 220, 160]; + ink(tr, tg, tb, 90).box(trackStripeX, barY, 1, barH, "fill"); + } + } - const fillW = floor(barW * progress); - ink(255, 220, 160).box(margin, barY, fillW, barH, "fill"); + // 2) Audio-driven sweep highlight — adapted from + // renderLoadingProgressBar's sin-wave sweep. A 60–80px-wide bright band + // pulses across the entire bar while audio amplitude is above threshold. + // Built from three overlapping ink(...).box(...) passes at decreasing + // alpha to fake a soft glow without a canvas blur filter. + const amp = sound?.speaker?.amplitudes?.left || 0; + if (amp > 0.06) { + const ampClamp = min(1, amp * 2.4); + const audioT = frame * 0.04; + const sweepW = floor(max(60, min(80, barW * 0.18))); + const travel = barW - sweepW; + const sweepX = margin + floor((sin(audioT) * 0.5 + 0.5) * travel); + // Sweep tints with the section colour under its centre. + const sIdx = sectionAtX(sweepX + sweepW / 2, margin, barW); + const [sr, sg, sb] = sectionRgbs[sIdx] || [255, 240, 200]; + const baseA = floor(ampClamp * 110); + // Wide soft halo (low alpha). + ink(sr, sg, sb, floor(baseA * 0.45)).box(sweepX - 6, barY - 1, sweepW + 12, barH + 2, "fill"); + // Mid band. + ink(sr, sg, sb, floor(baseA * 0.75)).box(sweepX - 2, barY, sweepW + 4, barH, "fill"); + // Hot core. + ink(255, 245, 220, baseA).box(sweepX + floor(sweepW * 0.35), barY + 1, floor(sweepW * 0.3), barH - 2, "fill"); + } - // Section ticks - manifest.sections.forEach((sec) => { - const tx = margin + floor((sec.t / manifest.duration) * barW); - ink(255, 255, 255, 100).box(tx, barY - 1, 1, barH + 2, "fill"); - }); + // 3) Leading-edge "leader pixel" + warm trailing fade — replaces VHS's + // cycling pixel. Single warm-orange dot rather than the 4-colour cycle, + // since the per-section blocks already give us colour variety. + if (playedX > margin && playedX < margin + barW) { + // Trail: 6–8px gradient behind the playhead. + const trailLen = 8; + for (let i = 1; i <= trailLen; i++) { + const tx = playedX - i; + if (tx < margin) break; + // Quadratic falloff so the tail dies cleanly. + const a = floor(220 * (1 - i / trailLen) * (1 - i / trailLen)); + ink(255, 180, 80, a).box(tx, barY, 1, barH, "fill"); + } + // Leader dot: 2px wide, 1px taller than the bar at top + bottom, with a + // pulse synced to frame. + const leadPulse = sin(frame * 0.4) * 0.25 + 0.75; + const leadA = floor(255 * leadPulse); + ink(255, 230, 170, leadA).box(playedX - 1, barY - 1, 2, barH + 2, "fill"); + // Inner hot core. + ink(255, 255, 240, leadA).box(playedX - 1, barY + floor(barH / 2) - 1, 2, 2, "fill"); + } - // Time text + // Outline + active section underline. + ink(255, 240, 200, 60).box(margin, barY, barW, barH, "outline"); + { + const sec = manifest.sections[activeSection]; + const next = manifest.sections[activeSection + 1]; + if (sec) { + const x0 = margin + floor((sec.t / manifest.duration) * barW); + const x1 = activeSection === lastIdx + ? margin + barW + : margin + floor((next.t / manifest.duration) * barW); + ink(255, 240, 200, 220).box(x0, barY + barH, x1 - x0, 1, "fill"); + } + } + + // Time text + active section code/name. const cur = fmtTime(currentSec); const total = fmtTime(manifest.duration); const tStr = `${cur} / ${total}`; @@ -342,15 +477,24 @@ function paintScrubBar($) { ink(255, 240, 200).write(tStr, { x: margin, y: barY - 12 }); } +// Map a pixel X on the scrub bar back to the section index under it. Used +// by the VHS overlay passes so the sweep/jitter tints with the local +// section colour rather than a single fixed hue. +function sectionAtX(x, margin, barW) { + if (!manifest) return 0; + const frac = max(0, min(1, (x - margin) / barW)); + const t = frac * manifest.duration; + return sectionForTime(t); +} + function paintIllyStrip($) { - const { ink, paste, screen, box } = $; + const { ink, paste, write, screen } = $; const w = screen.width; - const h = screen.height; const n = manifest.sections.length; - const stripY = h - 42; - const stripH = 30; + const stripY = w >= 400 ? screen.height - 40 : screen.height - 38; + const stripH = 32; const gap = 2; - const thumbW = max(8, floor((w - 12 - gap * (n - 1)) / n)); + const thumbW = max(10, floor((w - 12 - gap * (n - 1)) / n)); stripGeoms = new Array(n); for (let i = 0; i < n; i++) { @@ -367,11 +511,27 @@ function paintIllyStrip($) { const dy = stripY + floor((stripH - dh) / 2); paste(img, dx, dy, s); } else { - ink(20, 14, 24).box(x, stripY, thumbW, stripH, "fill"); + // Empty thumb — paint a dim section-coloured placeholder so the + // timeline arc still reads while illys stream in (or if they fail). + const [r, g, b] = sectionRgbs[i] || [20, 14, 24]; + ink(r, g, b, 100).box(x, stripY, thumbW, stripH, "fill"); + if (illyErrors[i]) { + ink(180, 60, 60, 200).box(x + thumbW - 4, stripY + 2, 2, 2, "fill"); + } } - // Border + active highlight + + // Letter code overlay (top-left corner of each thumb). + const code = manifest.sections[i]?.code; + if (code && thumbW >= 16) { + ink(0, 0, 0, 200).write(code, { x: x + 2, y: stripY + 2 }); + ink(255, 240, 200).write(code, { x: x + 1, y: stripY + 1 }); + } + + // Border + active highlight (section colour). if (i === activeSection) { - ink(255, 220, 160).box(x, stripY, thumbW, stripH, "outline"); + const [r, g, b] = sectionRgbs[i] || [255, 220, 160]; + ink(r, g, b).box(x, stripY, thumbW, stripH, "outline"); + ink(r, g, b).box(x - 1, stripY - 1, thumbW + 2, stripH + 2, "outline"); } else { ink(0, 0, 0, 140).box(x, stripY, thumbW, stripH, "outline"); } @@ -380,16 +540,11 @@ function paintIllyStrip($) { function paintButtons($) { const { screen, ink } = $; - // play/pause text reflects playback state already. if (playBtn) { playBtn.txt = playing ? "pause" : "play"; - playBtn.reposition({ x: screen.width - 60, bottom: 6, screen }); + playBtn.reposition({ right: 6, bottom: 6, screen }); playBtn.paint({ ink }); } - if (spotifyBtn) { - spotifyBtn.reposition({ x: 6, bottom: 6, screen }); - spotifyBtn.paint({ ink }); - } } function paintPlayPrompt($) { @@ -434,7 +589,9 @@ function paintGallery($) { } else { ink(255, 240, 200).write("loading…", { center: "xy", screen }); } - const label = sec ? `${galleryIndex + 1}/${manifest.sections.length} · ${sec.name}` : ""; + const label = sec + ? `${sec.code ? sec.code + " · " : ""}${galleryIndex + 1}/${manifest.sections.length} · ${sec.name}` + : ""; ink(0, 0, 0, 160).write(label, { center: "x", y: h - 22 }); ink(255, 240, 200).write(label, { center: "x", y: h - 23 }); ink(180, 180, 200).write("← → · tap to close", { center: "x", y: h - 12 });