From 52f7e54f6f21623236b758130ca702a36bf6817a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 7 Jun 2026 18:12:48 -0700 Subject: [PATCH] serpentine video: bed plays full length + Slab render progress - compose-widescreen: ambient bed was 0:88 but narration runs ~2:56, so music dropped out halfway. Loop the bed (-stream_loop -1), trim to runtime, add in/out fades so it carries the whole piece. - render-progress heartbeats wired into captions-train, opener-anim, and the compose main encode + opener-join (ffmpeg frame= parsing) so long widescreen renders show a live Slab menubar bar like /pop video renders do. - youtube-description.txt for the unlisted share cut (youtu.be/nUxW8mCzv-E). Co-Authored-By: Claude Opus 4.8 --- .../youtube-description.txt | 8 +++ marketing/bin/captions-train.mjs | 6 ++- marketing/bin/compose-widescreen.mjs | 49 ++++++++++++++----- marketing/bin/opener-anim.mjs | 8 ++- 4 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 grants/serpentine-fae-2026/youtube-description.txt diff --git a/grants/serpentine-fae-2026/youtube-description.txt b/grants/serpentine-fae-2026/youtube-description.txt new file mode 100644 index 000000000..4fb21c4c6 --- /dev/null +++ b/grants/serpentine-fae-2026/youtube-description.txt @@ -0,0 +1,8 @@ +A ~3-minute video proposal for the Serpentine Future Art Ecosystems (FAE) R&D Fellowship — Art × Convergence. + +As demand for AI compute pulls everything toward the data center, the personal computer is turning into a thin client. This is a pitch for the opposite move: re-founding the personal computer as a creative commons — a bare-metal creative operating system, shared languages (you make pieces in JavaScript or KidLisp), and a planetary laptop orchestra that runs on the surplus laptops the upgrade cycle throws away. + +Aesthetic Computer is a runtime, an operating system, and a social network for creative computing, built and given away over six years. A fifty-dollar ThinkPad boots into the instrument in about seven seconds. + +— Jeffrey Alan Scudder +https://aesthetic.computer diff --git a/marketing/bin/captions-train.mjs b/marketing/bin/captions-train.mjs index 670edc862..e1d9eda93 100644 --- a/marketing/bin/captions-train.mjs +++ b/marketing/bin/captions-train.mjs @@ -16,6 +16,7 @@ import { readFileSync, mkdirSync, existsSync } from "node:fs"; import { spawn, spawnSync, execSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import * as progress from "../../pop/lib/render-progress.mjs"; const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const FONT = `${process.env.HOME}/Library/Fonts/ywft-processing-bold.ttf`; @@ -177,6 +178,7 @@ const ff = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "bgra", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "-", "-c:v", "qtrle", OUT], { stdio: ["pipe", "inherit", "inherit"] }); console.log(`▸ phrase-karaoke · ${phrases.length} phrases · ${DURATION.toFixed(1)}s · ${nFrames} frames -> ${OUT}`); +progress.begin({ type: "video", label: `${cfg.name} captions · ${nFrames} frames` }); let frame = startFrame, phScroll = null; function drawFrame() { @@ -215,8 +217,10 @@ function drawFrame() { const ok = ff.stdin.write(Buffer.from(canvas.toBuffer("raw"))); frame++; + const done = frame - startFrame; + progress.update((done / nFrames) * 100, { done, total: nFrames }); if (frame >= startFrame + nFrames) { ff.stdin.end(); return; } if (ok) drawFrame(); else ff.stdin.once("drain", drawFrame); } -ff.on("close", (code) => console.log(code === 0 ? `✓ ${OUT}` : `✗ ffmpeg exit ${code}`)); +ff.on("close", (code) => { progress.end(); console.log(code === 0 ? `✓ ${OUT}` : `✗ ffmpeg exit ${code}`); }); drawFrame(); diff --git a/marketing/bin/compose-widescreen.mjs b/marketing/bin/compose-widescreen.mjs index 9100fd916..ac25bb445 100644 --- a/marketing/bin/compose-widescreen.mjs +++ b/marketing/bin/compose-widescreen.mjs @@ -11,9 +11,30 @@ // All text is pre-rendered to PNG via ImageMagick (YWFT renders correctly there, // unlike node-canvas) and overlaid timed by ffmpeg. import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; -import { execSync, spawnSync } from "node:child_process"; +import { execSync, spawnSync, spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import * as progress from "../../pop/lib/render-progress.mjs"; + +// Run an ffmpeg command (bash -c string), parsing `frame=N` from stderr to feed +// a Slab render-progress heartbeat. Returns the exit status. +function ffmpegWithProgress(cmd, total, label) { + progress.begin({ type: "video", label }); + return new Promise((res) => { + const p = spawn("bash", ["-c", cmd], { stdio: ["ignore", "inherit", "pipe"] }); + let buf = ""; + p.stderr.on("data", (d) => { + buf += d.toString(); + const lines = buf.split("\n"); buf = lines.pop(); + for (const ln of lines) { + const m = ln.match(/frame=\s*(\d+)/); + if (m && total) progress.update((Math.min(+m[1], total) / total) * 100, { done: +m[1], total }); + process.stderr.write(ln + "\n"); + } + }); + p.on("close", (code) => { progress.end(); res(code); }); + }); +} const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const FONT = `${process.env.HOME}/Library/Fonts/ywft-processing-bold.ttf`; @@ -265,9 +286,12 @@ idx = sideOverlay(inputs, fc, cur, idx); // audio: narration + ambient bed (quiet) inputs.push(`-i ${NARR}`); const aNarr = idx++; const hasBed = BED && existsSync(BED); -if (hasBed) { inputs.push(`-i ${BED}`); const aBed = idx++; - fc.push(`[${aNarr}:a]volume=1.0[an]`); fc.push(`[${aBed}:a]volume=0.32[ab]`); - fc.push(`[an][ab]amix=inputs=2:duration=longest:dropout_transition=0[aout]`); +if (hasBed) { inputs.push(`-stream_loop -1 -i ${BED}`); const aBed = idx++; + // bed is shorter than the narration — loop it (stream_loop) and trim to the + // full runtime so the music plays all the way through, with gentle in/out. + fc.push(`[${aNarr}:a]volume=1.0[an]`); + fc.push(`[${aBed}:a]atrim=0:${DURATION.toFixed(2)},asetpts=N/SR/TB,volume=0.32,afade=t=in:st=0:d=0.8,afade=t=out:st=${(DURATION - 2.5).toFixed(2)}:d=2.5[ab]`); + fc.push(`[an][ab]amix=inputs=2:duration=first:dropout_transition=0[aout]`); } else { fc.push(`[${aNarr}:a]volume=1.0[aout]`); } const fcPath = `${TMP}/filter.txt`; @@ -277,11 +301,11 @@ const cmd = `ffmpeg -y ${inputs.join(" ")} -filter_complex_script ${fcPath} ` + `-c:v libx264 -pix_fmt yuv420p -crf 12 -preset slow -c:a aac -b:a 192k "${OUT}"`; writeFileSync(`${TMP}/cmd.sh`, cmd); console.log(`inputs: ${inputs.length}, overlays: ${idx} · running ffmpeg → ${OUT}`); -const r = spawnSync("bash", ["-c", cmd], { stdio: "inherit" }); -console.log(r.status === 0 ? `✓ DONE ${OUT}` : `✗ ffmpeg exit ${r.status}`); +const status = await ffmpegWithProgress(cmd, Math.ceil(DURATION * FPS), `${cfg.name} compose · ${Math.ceil(DURATION * FPS)} frames`); +console.log(status === 0 ? `✓ DONE ${OUT}` : `✗ ffmpeg exit ${status}`); // ── opening title card: render the branded card → a short push-in clip → concat ahead ─ -if (r.status === 0 && cfg.titleCard) { +if (status === 0 && cfg.titleCard) { console.log("rendering animated opener…"); const opener = `${TMP}/opener.mp4`; const ro = spawnSync("node", [`${REPO}/marketing/bin/opener-anim.mjs`, campArg], { stdio: "inherit" }); @@ -289,12 +313,13 @@ if (r.status === 0 && cfg.titleCard) { // re-encode the join (concat filter) → clean, continuous timestamps so every // player handles the boundary (stream-copy concat stalls QuickTime at the cut). const final = `${TMP}/final.mp4`; - const rc = spawnSync("bash", ["-c", + const concatTotal = Math.ceil((DURATION + 4) * FPS); // opener (~4s) + recap + const rc = await ffmpegWithProgress( `ffmpeg -y -i "${opener}" -i "${OUT}" -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" ` + - `-map "[v]" -map "[a]" -r ${FPS} -c:v libx264 -pix_fmt yuv420p -crf 12 -preset medium -c:a aac -b:a 192k -movflags +faststart "${final}"`], - { stdio: "inherit" }); - if (rc.status === 0) { execSync(`mv "${final}" "${OUT}"`); console.log(`✓ opener prepended → ${OUT}`); } + `-map "[v]" -map "[a]" -r ${FPS} -c:v libx264 -pix_fmt yuv420p -crf 12 -preset medium -c:a aac -b:a 192k -movflags +faststart "${final}"`, + concatTotal, `${cfg.name} opener-join`); + if (rc === 0) { execSync(`mv "${final}" "${OUT}"`); console.log(`✓ opener prepended → ${OUT}`); } else console.error("✗ opener concat failed"); } } -process.exit(r.status === 0 ? 0 : 1); +process.exit(status === 0 ? 0 : 1); diff --git a/marketing/bin/opener-anim.mjs b/marketing/bin/opener-anim.mjs index b50849321..5f85f60bd 100644 --- a/marketing/bin/opener-anim.mjs +++ b/marketing/bin/opener-anim.mjs @@ -14,6 +14,7 @@ import { mkdirSync, existsSync } from "node:fs"; import { spawn, execSync } from "node:child_process"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import * as progress from "../../pop/lib/render-progress.mjs"; const FONT = `${process.env.HOME}/Library/Fonts/ywft-processing-bold.ttf`; const W = 1920, H = 1080, FPS = 30; @@ -140,6 +141,8 @@ const ff = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", { stdio: ["pipe", "inherit", "inherit"] }); console.log(`▸ animated opener · ${DUR}s · ${Math.round(DUR * FPS)} frames -> ${OUT}`); +const _openerTotal = Math.round(DUR * FPS); +progress.begin({ type: "video", label: `${cfg.name} opener · ${_openerTotal} frames` }); const scrimY = titleY + Math.round(tpx * 0.5); let frame = 0; function draw() { @@ -174,8 +177,9 @@ function draw() { if (t < 0.6) { ctx.fillStyle = `rgba(0,0,0,${1 - t / 0.6})`; ctx.fillRect(0, 0, W, H); } const ok = ff.stdin.write(Buffer.from(canvas.toBuffer("raw"))); - if (++frame >= Math.round(DUR * FPS)) { ff.stdin.end(); return; } + progress.update((frame / _openerTotal) * 100, { done: frame, total: _openerTotal }); + if (++frame >= _openerTotal) { ff.stdin.end(); return; } if (ok) draw(); else ff.stdin.once("drain", draw); } -ff.on("close", (code) => { console.log(code === 0 ? `✓ ${OUT}` : `✗ ffmpeg exit ${code}`); if (code === 0 && process.argv.includes("--open")) execSync(`open "${OUT}"`); }); +ff.on("close", (code) => { progress.end(); console.log(code === 0 ? `✓ ${OUT}` : `✗ ffmpeg exit ${code}`); if (code === 0 && process.argv.includes("--open")) execSync(`open "${OUT}"`); }); draw(); -- 2.51.2