diff --git a/ac-shop/.frontpage-backup.json b/ac-shop/.frontpage-backup.json new file mode 100644 index 0000000000..92e3039406 --- /dev/null +++ b/ac-shop/.frontpage-backup.json @@ -0,0 +1,116 @@ +{ + "collection_id": 369657512117, + "savedAt": "2026-06-04", + "collects": [ + { + "collect_id": 50602224648373, + "product_id": 10420190871733, + "position": 3217344949883876 + }, + { + "collect_id": 50602351001781, + "product_id": 10420261224629, + "position": 3217527757805879 + }, + { + "collect_id": 50602234544309, + "product_id": 10415495807157, + "position": 3217710565727882 + }, + { + "collect_id": 50602234577077, + "product_id": 10327933092021, + "position": 3218076181571888 + }, + { + "collect_id": 50602234609845, + "product_id": 10327944954037, + "position": 3218441797415894 + }, + { + "collect_id": 50602234642613, + "product_id": 10307648913589, + "position": 3218807413259900 + }, + { + "collect_id": 50602234675381, + "product_id": 10307648749749, + "position": 3219173029103906 + }, + { + "collect_id": 50602234740917, + "product_id": 10277242077365, + "position": 3219538644947912 + }, + { + "collect_id": 50602234773685, + "product_id": 10307732668597, + "position": 3219904260791918 + }, + { + "collect_id": 50602234806453, + "product_id": 8205836320949, + "position": 3220269876635924 + }, + { + "collect_id": 50602234839221, + "product_id": 8199586316469, + "position": 3220635492479930 + }, + { + "collect_id": 50602234904757, + "product_id": 8206620360885, + "position": 3221001108323936 + }, + { + "collect_id": 50602234937525, + "product_id": 10307732766901, + "position": 3221366724167942 + }, + { + "collect_id": 50602235003061, + "product_id": 10307732734133, + "position": 3221732340011948 + }, + { + "collect_id": 50602235035829, + "product_id": 10307732701365, + "position": 3222097955855954 + }, + { + "collect_id": 50602235068597, + "product_id": 10307732635829, + "position": 3222463571699960 + }, + { + "collect_id": 50602235101365, + "product_id": 8205836943541, + "position": 3222829187543966 + }, + { + "collect_id": 50602235134133, + "product_id": 8205836878005, + "position": 3223194803387972 + }, + { + "collect_id": 50602235199669, + "product_id": 8205835829429, + "position": 3223926035075984 + }, + { + "collect_id": 50602235265205, + "product_id": 8199583006901, + "position": 3224291650919990 + }, + { + "collect_id": 50602235297973, + "product_id": 8199579762869, + "position": 3224657266763996 + }, + { + "collect_id": 50602235330741, + "product_id": 10279331430581, + "position": 3225022882608002 + } + ] +} \ No newline at end of file diff --git a/grants/serpentine-fae-2026/FORM-ANSWERS.md b/grants/serpentine-fae-2026/FORM-ANSWERS.md index 50209d764b..17ecbf1def 100644 --- a/grants/serpentine-fae-2026/FORM-ANSWERS.md +++ b/grants/serpentine-fae-2026/FORM-ANSWERS.md @@ -21,7 +21,7 @@ ## Personal details **Full Name:** Jeffrey Alan Scudder -**Email Address:** me@jas.life **⟨confirm⟩** *(or mail@aesthetic.computer)* +**Email Address:** mail@aesthetic.computer **Primary Residence:** Los Angeles, California, USA **Website or Online Presence:** https://aesthetic.computer **Are you applying as an individual or a collective?** Individual @@ -137,15 +137,12 @@ Leave blank, or: "Travelling from Los Angeles for each intensive; covered travel --- -## References *(two — name, role/organisation, email, relationship)* **⟨your call⟩** +## References *(two — name, role/organisation, email, relationship)* — LOCKED 2026-06-04 -A reference is lighter than a written letter (they may simply be contacted). Candidates: -- **Camille Klein** / **Alex Freundlich** — Whistlegraph co-owners; long-term collaborators. -- **Julia Yerger** — collaborator (Radical Digital Painting; Johannes Vogt show). -- **Artur Erman** — collaborator / peer. · A **KADIST** curator who acquired your work. -- **Goodiepal** (Kristian Vester) — toured Europe together. · **Casey Reas** — in reserve per your call. +A reference is lighter than a written letter (they may simply be contacted). -→ Suggested pair: **Camille + Alex** (community/commons story), or one collaborator + one KADIST curator. +1. **Casey Reas** — artist; Professor, UCLA Design Media Arts; host of the Social Software residency Jeffrey is currently in. Relationship: residency host / peer. **⟨add email⟩** +2. **Julia Yerger** — collaborator (Radical Digital Painting; Johannes Vogt gallery show). Relationship: collaborator. **⟨add email⟩** --- diff --git a/marketing/bin/captions-train.mjs b/marketing/bin/captions-train.mjs new file mode 100644 index 0000000000..670edc862e --- /dev/null +++ b/marketing/bin/captions-train.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +// captions-train.mjs — GENERIC phrase-karaoke caption layer. ONE sentence/phrase +// is shown at a time, held STILL while it's read; each word colors in +// left-to-right (character-by-character) in the slide's accent color as it is +// spoken; when the phrase finishes the WHOLE phrase slides up and the next rises +// in from below. "slide · speak · slide · speak." Plus the bottom segmented +// progress bar. Nothing moves while a word is being spoken. +// +// Usage: node marketing/bin/captions-train.mjs [--start S --frames N] +// +// NOTE: node-canvas/freetype cannot rasterize the YWFT "WebFont" TTF (renders +// .notdef tofu) → each word is pre-rendered to a PNG via ImageMagick (cream + +// per-slide-colored), drawn downscaled (crisp at any size). +import { createCanvas, loadImage } from "canvas"; +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"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const FONT = `${process.env.HOME}/Library/Fonts/ywft-processing-bold.ttf`; +const W = 1920, H = 1080, FPS = 30; + +const argv = process.argv.slice(2); +const flag = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : null; }; +const campArg = argv.find((a) => !a.startsWith("--")); +if (!campArg) { console.error("usage: captions-train.mjs [--start S --frames N]"); process.exit(2); } +const campDir = resolve(process.cwd(), campArg); +const { default: cfg } = await import(pathToFileURL(`${campDir}/campaign.mjs`).href); + +const TMP = `/tmp/wsvideo-${cfg.name}`; mkdirSync(TMP, { recursive: true }); +const WORDDIR = `${TMP}/words`; mkdirSync(WORDDIR, { recursive: true }); +const OUT = `${TMP}/captions.mov`; +const CREAM = cfg.palette?.cream || "#fcf7c5"; +const SHADOW = cfg.palette?.shadow || "#000000"; +const DEF_COLOR = cfg.palette?.green || "#3dff88"; + +const C = { + renderPx: 112, phrasePx: 76, maxLineW: 1740, wordGap: 26, lineH: 104, + phraseDy: 132, bandY: 0.83, phraseSmooth: 0.20, fadeSpan: 1.2, lead: 0.18, + ...(cfg.caption || {}), +}; +const BAND_Y = Math.round(H * C.bandY); +const sc = C.phrasePx / C.renderPx; +const clamp01 = (t) => t < 0 ? 0 : t > 1 ? 1 : t; +const smoothstep = (t) => { t = clamp01(t); return t * t * (3 - 2 * t); }; + +// ── segments + per-slide colors (needed before word PNGs, to color each word) ─ +const segs = JSON.parse(readFileSync(`${REPO}/recap/out/segments.json`, "utf8")); +const DURATIONsegs = Math.max(...segs.map((s) => s.endSec)); +const COLORS = cfg.colors || {}; +const hexRgb = (h) => { h = h.replace("#", ""); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }; +const segColorHex = (t) => { const s = segs.find((x) => t >= x.startSec && t < x.endSec) || segs[segs.length - 1]; return COLORS[s.name] || DEF_COLOR; }; +const EXTRACTS = cfg.extracts || {}; +const extractWins = segs.filter((s) => EXTRACTS[s.name]).map((s) => [s.startSec, s.endSec]); +const inExtract = (t) => extractWins.some(([a, b]) => t >= a && t < b); + +// ── word PNG (ImageMagick, YWFT + baked hard shadow), cream + colored ──────── +function wordPng(out, text, fill) { + const oy = 7, ox = 6; + const args = ["-background", "none", "-fill", fill, "-font", FONT, "-pointsize", String(C.renderPx), + `label:${text}`, "(", "+clone", "-background", SHADOW, "-shadow", `100x0+${ox}+${oy}`, ")", + "+swap", "-background", "none", "-layers", "merge", "+repage", out]; + const r = spawnSync("magick", args, { encoding: "utf8" }); + if (r.status !== 0) { console.error("magick failed:", text, r.stderr); process.exit(1); } + const [w, h] = execSync(`magick identify -format "%w %h" ${out}`).toString().trim().split(" ").map(Number); + return { w, h }; +} + +const raw = JSON.parse(readFileSync(`${REPO}/recap/out/words.json`, "utf8")); +const FIX = [[/^[“”'"`]+|[“”'"`]+$/g, ""], ...(cfg.textFixes || []).map(([p, r, f]) => [new RegExp(p, f || ""), r])]; +const clean = (t) => FIX.reduce((s, [a, b]) => s.replace(a, b), t).trim(); +const words = raw.map((w) => ({ text: clean(w.text), from: w.fromMs / 1000, to: w.toMs / 1000, rawText: w.text })) + .filter((w) => w.text.length > 0); + +console.log(`▸ [${cfg.name}] pre-rendering word PNGs (cream + per-slide color)…`); +const cache = new Map(); +async function load(text, fill) { + const key = `${text}|${fill}`; + let e = cache.get(key); + if (!e) { + const safe = text.replace(/[^a-zA-Z0-9]/g, "_").slice(0, 20) + "_" + [...text + fill].reduce((a, c) => a + c.charCodeAt(0), 0); + const png = `${WORDDIR}/${safe}.png`; + const dim = existsSync(png) ? (() => { const [a, b] = execSync(`magick identify -format "%w %h" ${png}`).toString().trim().split(" ").map(Number); return { w: a, h: b }; })() : wordPng(png, text, fill); + e = { ...dim, img: await loadImage(png) }; + cache.set(key, e); + } + return e; +} +for (const w of words) { + w.color = segColorHex(w.from); + const cr = await load(w.text, CREAM); + const co = await load(w.text, w.color); + w.imgCream = cr.img; w.imgColor = co.img; w.iw = cr.w; w.ih = cr.h; w.dw = cr.w * sc; +} + +const DURATION = Math.max(words.length ? words[words.length - 1].to + 0.6 : 1, DURATIONsegs); + +// ── bottom segmented progress bar ──────────────────────────────────── +const BAR_H = 12, BAR_Y = H - BAR_H; +function drawProgressBar(ctx, t) { + const playedX = Math.max(0, t) / DURATION * W; + for (let i = 0; i < segs.length; i++) { + const s = segs[i]; + const x0 = i === 0 ? 0 : (s.startSec / DURATION) * W; + const x1 = i === segs.length - 1 ? W : (s.endSec / DURATION) * W; + const [r, g, b] = hexRgb(COLORS[s.name] || DEF_COLOR); + ctx.globalAlpha = 1; + ctx.fillStyle = `rgba(${Math.round(r * 0.16)},${Math.round(g * 0.16)},${Math.round(b * 0.16)},0.85)`; + ctx.fillRect(x0, BAR_Y, x1 - x0, BAR_H); + const fx1 = Math.min(x1, playedX); + if (fx1 > x0) { ctx.fillStyle = `rgba(${r},${g},${b},0.96)`; ctx.fillRect(x0, BAR_Y, fx1 - x0, BAR_H); } + ctx.fillStyle = "rgba(255,253,242,0.35)"; ctx.fillRect(x1 - 1, BAR_Y, 1, BAR_H); + } +} + +// bottom-right timecode in YWFT — render the WHOLE "M:SS / M:SS" string per +// second as ONE ImageMagick image (correct YWFT kerning, pop-style), pick per +// frame. Windowed to the render range so previews stay fast. +const totMM = Math.floor(DURATION / 60), totSS = String(Math.floor(DURATION % 60)).padStart(2, "0"); +const TC_PX = 56; +const _sf = flag("--start") ? Math.round(Number(flag("--start")) * FPS) : 0; +const _nf = flag("--frames") ? Number(flag("--frames")) : (Math.ceil(DURATION * FPS) - _sf); +const tcByS = {}; +for (let s = Math.floor(_sf / FPS); s <= Math.min(Math.floor(DURATION), Math.ceil((_sf + _nf) / FPS)); s++) { + const label = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")} / ${totMM}:${totSS}`; + const png = `${WORDDIR}/tc_${s}.png`; + if (!existsSync(png)) { + const r = spawnSync("magick", ["-background", "none", "-fill", CREAM, "-font", FONT, "-pointsize", String(TC_PX), `label:${label}`, + "(", "+clone", "-background", SHADOW, "-shadow", "100x0+4+5", ")", "+swap", "-background", "none", "-layers", "merge", "+repage", png], { encoding: "utf8" }); + if (r.status !== 0) { console.error("tc failed", label); process.exit(1); } + } + tcByS[s] = await loadImage(png); +} +function drawTimecode(ctx, t) { + const img = tcByS[Math.min(Math.max(0, Math.floor(t)), Math.floor(DURATION))]; + if (!img) return; + ctx.globalAlpha = 0.95; + ctx.drawImage(img, W - img.width - 34, BAR_Y - 18 - img.height); + ctx.globalAlpha = 1; +} + +// ── group words into phrases (sentence / clause sized) + layout ────── +// group into phrases that ALWAYS fit ONE line: break on sentence end, or before +// a word would overflow maxLineW (never wrap to a second row). +const endRe = /[.?!]["”]?$/; +const phrases = []; +let cur = [], curW = 0; +for (const w of words) { + if (cur.length && curW + C.wordGap + w.dw > C.maxLineW) { phrases.push(cur); cur = []; curW = 0; } + curW += (cur.length ? C.wordGap : 0) + w.dw; + cur.push(w); + if (endRe.test(w.rawText.trim())) { phrases.push(cur); cur = []; curW = 0; } +} +if (cur.length) phrases.push(cur); +// single-line layout: centre each phrase horizontally +for (const ph of phrases) { + const tot = ph.reduce((a, w) => a + w.dw, 0) + C.wordGap * (ph.length - 1); + let x = -tot / 2; + for (const w of ph) { w.cx = x + w.dw / 2; w.line = 0; x += w.dw + C.wordGap; } + ph.nLines = 1; + ph.start = ph[0].from; +} +// advance to a phrase slightly before its first word so the slide settles in the +// gap (the word is then read while STILL). +function targetIdx(t) { let i = 0; for (let k = 0; k < phrases.length; k++) if (t >= phrases[k].start - C.lead) i = k; return i; } + +// ── render ─────────────────────────────────────────────────────────── +const startFrame = flag("--start") ? Math.round(Number(flag("--start")) * FPS) : 0; +const nFrames = flag("--frames") ? Number(flag("--frames")) : (Math.ceil(DURATION * FPS) - startFrame); + +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); +ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; + +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}`); + +let frame = startFrame, phScroll = null; +function drawFrame() { + const t = frame / FPS; + ctx.clearRect(0, 0, W, H); + + if (!inExtract(t)) { + const ti = targetIdx(t); + phScroll = phScroll === null ? ti : phScroll + (ti - phScroll) * C.phraseSmooth; + const lo = Math.max(0, Math.floor(phScroll) - 1), hi = Math.min(phrases.length - 1, Math.ceil(phScroll) + 1); + for (let j = lo; j <= hi; j++) { + const ph = phrases[j]; + const fade = smoothstep(1 - Math.abs(j - phScroll) / C.fadeSpan); + if (fade <= 0.01) continue; + const baseY = BAND_Y + (j - phScroll) * C.phraseDy; + for (const w of ph) { + const dh = w.ih * sc, x = W / 2 + w.cx, y = baseY + (w.line - (ph.nLines - 1) / 2) * C.lineH; + const dx = x - w.dw / 2, dy = y - dh / 2; + const spoken = t >= w.to, active = t >= w.from && t < w.to; + if (spoken) { // fully read → accent color + ctx.globalAlpha = 0.97 * fade; ctx.drawImage(w.imgColor, dx, dy, w.dw, dh); + } else if (active) { // colouring in, left→right (char by char) + const p = clamp01((t - w.from) / Math.max(0.001, w.to - w.from)); + ctx.globalAlpha = fade; ctx.drawImage(w.imgCream, dx, dy, w.dw, dh); + ctx.save(); ctx.beginPath(); ctx.rect(dx, dy, w.dw * p, dh); ctx.clip(); + ctx.drawImage(w.imgColor, dx, dy, w.dw, dh); ctx.restore(); + } else { // not yet read → dim cream + ctx.globalAlpha = 0.32 * fade; ctx.drawImage(w.imgCream, dx, dy, w.dw, dh); + } + } + } + ctx.globalAlpha = 1; + } + drawProgressBar(ctx, t); + drawTimecode(ctx, t); + + const ok = ff.stdin.write(Buffer.from(canvas.toBuffer("raw"))); + frame++; + 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}`)); +drawFrame(); diff --git a/marketing/bin/compose-widescreen.mjs b/marketing/bin/compose-widescreen.mjs new file mode 100644 index 0000000000..f81a86e770 --- /dev/null +++ b/marketing/bin/compose-widescreen.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +// compose-widescreen.mjs — GENERIC 1920x1080 compositor for narrated +// application videos. Drives the whole picture from a campaign config: +// per-segment illustrations (with Ken Burns), top-left titles + slide-number +// superscripts, a top-right bouncing wordmark, the karaoke word-train caption +// layer, and a narration + ambient-bed audio mix. +// +// Prereq: node recap/cli.mjs build (writes recap/out/…) +// Usage: node marketing/bin/compose-widescreen.mjs +// +// 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 { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const FONT = `${process.env.HOME}/Library/Fonts/ywft-processing-bold.ttf`; +const W = 1920, H = 1080, FPS = 30; + +const campArg = process.argv[2]; +if (!campArg) { console.error("usage: compose-widescreen.mjs "); process.exit(2); } +const campDir = resolve(process.cwd(), campArg); +const { default: cfg } = await import(pathToFileURL(`${campDir}/campaign.mjs`).href); + +const TMP = `/tmp/wsvideo-${cfg.name}`; mkdirSync(TMP, { recursive: true }); +const NARR = `${REPO}/recap/out/recap.mp3`; +const BED = cfg.bed; +const OUT = cfg.out; +const CREAM = cfg.palette?.cream || "#fcf7c5"; +const GREEN = cfg.palette?.green || "#3dff88"; +const SHADOW = cfg.palette?.shadow || "#000000"; +const TITLES = cfg.titles || {}; + +const segs = JSON.parse(readFileSync(`${REPO}/recap/out/segments.json`, "utf8")); +const DURATION = Math.max(...segs.map((s) => s.endSec)); +const EXTRACTS = cfg.extracts || {}; // paper-extract scenes +const hasIllo = (s) => existsSync(`${campDir}/${s.name}/gens/native.png`) || existsSync(`${campDir}/${s.name}/gens/pencil.png`); +const hasBg = (s) => hasIllo(s) || EXTRACTS[s.name]; +const imgSegs = segs.filter(hasBg); // segments with a background +const lastIx = imgSegs.length - 1; // last bg holds to the end (covers tail) + +// --preview T0 DUR → render just a short window (with all chrome) for fast feedback +const pvI = process.argv.indexOf("--preview"); +const PREVIEW = pvI >= 0 ? { t0: Number(process.argv[pvI + 1]), dur: Number(process.argv[pvI + 2]) } : null; + +// --contact → one composite frame per scene, montaged into a contact sheet (a +// fast "read the whole video" before committing to a full render). +if (process.argv.includes("--contact")) { + const sheetDir = `${TMP}/contact`; mkdirSync(sheetDir, { recursive: true }); + const self = fileURLToPath(import.meta.url); + // spoken narration per segment (from the word timings, text-fixes applied) + const words = JSON.parse(readFileSync(`${REPO}/recap/out/words.json`, "utf8")); + const FIXc = (cfg.textFixes || []).map(([p, r, f]) => [new RegExp(p, f || ""), r]); + const mmss = (t) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; + const spokenFor = (s, segEnd) => FIXc.reduce((a, [re, r]) => a.replace(re, r), + words.filter((w) => (w.fromMs / 1000) >= s.startSec - 0.05 && (w.fromMs / 1000) < segEnd - 0.05) + .map((w) => w.text).join(" ").replace(/\s+([,.;:!?])/g, "$1").replace(/^["“]/, "")).trim(); + const tiles = []; + imgSegs.forEach((s, k) => { + const segEnd = (k === imgSegs.length - 1) ? DURATION : s.endSec; + const tMid = Math.min(segEnd - 0.2, Math.max(s.startSec + 0.6, (s.startSec + segEnd) / 2)); + console.log(`contact ${k + 1}/${imgSegs.length}: ${s.name} @ ${tMid.toFixed(1)}s`); + const r = spawnSync("node", [self, campArg, "--preview", tMid.toFixed(2), "0.12"], { stdio: "inherit" }); + if (r.status !== 0) { console.error("✗ contact frame failed", s.name); return; } + const raw = `${sheetDir}/raw_${String(k).padStart(2, "0")}.png`; + execSync(`ffmpeg -y -i "${OUT.replace(/\.mp4$/, "-preview.mp4")}" -frames:v 1 "${raw}" 2>/dev/null`); + // caption text (header + the spoken line) → write to file to avoid escaping + const header = `${k + 1}. ${TITLES[s.name] || s.name} · ${mmss(s.startSec)}`; + const capFile = `${sheetDir}/cap_${k}.txt`; + writeFileSync(capFile, `${header}\n${spokenFor(s, segEnd) || "—"}`); + // resize frame to tile width, render wrapped caption below, stack + const tile = `${sheetDir}/tile_${String(k).padStart(2, "0")}.png`; + execSync(`magick "${raw}" -resize 760x ` + + `\\( -background "#0b0f0d" -fill "#e8ffe8" -font "${FONT}" -pointsize 23 -size 720x caption:@"${capFile}" -splice 20x14 -background "#0b0f0d" -gravity West \\) ` + + `-background "#0b0f0d" -append "${tile}"`, { stdio: "ignore" }); + tiles.push(tile); + }); + const sheet = OUT.replace(/\.mp4$/, "-contact.png"); + execSync(`magick montage ${tiles.map((t) => `"${t}"`).join(" ")} -tile 2x -geometry +12+12 -background "#0a0a0a" "${sheet}"`); + console.log(`✓ contact sheet → ${sheet}`); + execSync(`open "${sheet}"`); + process.exit(0); +} + +// Ken Burns motion: per-segment zoom + pan + subtle wobble (pop-style, varied). +// KW/KH sized just above peak zoom (1.13) so zoompan never upscales — the only +// scale is one sharp lanczos pass from the source. +const KW = 2208, KH = 1242; +const MOVES = [ + { z0: 1.00, z1: 1.10, dx: 70, dy: -34 }, // push in, drift up-right + { z0: 1.10, z1: 1.00, dx: -66, dy: 26 }, // pull out, drift down-left + { z0: 1.00, z1: 1.08, dx: -58, dy: 36 }, // push in, drift down-left + { z0: 1.07, z1: 1.13, dx: 48, dy: 44 }, // slow push, diagonal down-right +]; +function kenBurns(k, frames, phase = 0) { + const m = MOVES[k % MOVES.length], f = Math.max(1, frames), ph = `(on+${phase})`; + const z = `(${m.z0}+(${m.z1}-${m.z0})*${ph}/${f})`; + const wob = `5*sin(2*PI*0.11*${ph}/${FPS})`; + const x = `iw/2-(iw/zoom/2)+(${m.dx})*${ph}/${f}+${wob}`; + const y = `ih/2-(ih/zoom/2)+(${m.dy})*${ph}/${f}+${wob}`; + return `scale=${KW}:${KH}:force_original_aspect_ratio=increase:flags=lanczos,crop=${KW}:${KH},` + + `zoompan=z='${z}':x='${x}':y='${y}':d=1:s=${W}x${H}:fps=${FPS},setsar=1,format=yuv420p`; +} + +// ── ImageMagick text/dot PNG helpers (hard pop shadow) ─────────────── +function textPng(out, text, { size, fill, width, oy = 6, align = "Center" }) { + const ox = Math.round(oy * 0.85); + const src = width + ? ["-size", `${width}x`, "-background", "none", "-fill", fill, "-font", FONT, "-pointsize", String(size), "-gravity", align, `caption:${text}`] + : ["-background", "none", "-fill", fill, "-font", FONT, "-pointsize", String(size), `label:${text}`]; + const r = spawnSync("magick", [...src, "(", "+clone", "-background", SHADOW, "-shadow", `100x0+${ox}+${oy}`, ")", + "+swap", "-background", "none", "-layers", "merge", "+repage", out], { encoding: "utf8" }); + if (r.status !== 0) { console.error("magick failed:", text, r.stderr); process.exit(1); } + const [w, h] = execSync(`magick identify -format "%w %h" ${out}`).toString().trim().split(" ").map(Number); + return { w, h }; +} +function dotPng(out, side) { // small green square "period" with the pop shadow + spawnSync("magick", ["-size", `${side + 8}x${side + 8}`, "xc:none", "-fill", GREEN, "-draw", `rectangle 0,0 ${side - 1},${side - 1}`, + "(", "+clone", "-background", SHADOW, "-shadow", "100x0+3+4", ")", "+swap", "-background", "none", "-layers", "merge", "+repage", out], { encoding: "utf8" }); +} +const idim = (p) => execSync(`magick identify -format "%w %h" ${p}`).toString().trim().split(" ").map(Number); + +// ── 1) karaoke word-train caption layer (windowed under --preview) ─── +console.log(`[${cfg.name}] rendering word-train caption layer…`); +{ + const capArgs = [`${REPO}/marketing/bin/captions-train.mjs`, campDir]; + if (PREVIEW) capArgs.push("--start", String(PREVIEW.t0), "--frames", String(Math.round(PREVIEW.dur * FPS))); + const r = spawnSync("node", capArgs, { stdio: "inherit" }); + if (r.status !== 0) { console.error("✗ captions-train failed"); process.exit(1); } +} +const CAPTIONS_MOV = `${TMP}/captions.mov`; + +// ── 2) title / number / wordmark PNGs ──────────────────────────────── +console.log("rendering text pngs…"); +const COLORS = cfg.colors || {}; +const titleDims = {}; +// titles + slide-number superscripts in the per-slide accent color (same as captions) +for (const s of segs) if (TITLES[s.name]) titleDims[s.name] = textPng(`${TMP}/title_${s.name}.png`, TITLES[s.name], { size: 84, fill: COLORS[s.name] || CREAM, oy: 7 }); +// superscript number in a contrasting color (cream) against the accent title +segs.forEach((s, k) => { if (TITLES[s.name]) textPng(`${TMP}/num_${s.name}.png`, String(k + 1), { size: 38, fill: cfg.numColor || CREAM, oy: 4 }); }); + +// right-side vertical wordmark label: [Aesthetic] · bouncing green "." · [Computer] +const wmParts = cfg.wordmark || []; +let haveSide = false, sideEls = [], dotEl = null; +if (wmParts.length >= 2) { + textPng(`${TMP}/wm_a.png`, wmParts[0], { size: 36, fill: CREAM, oy: 4 }); + textPng(`${TMP}/wm_c.png`, wmParts[1], { size: 36, fill: CREAM, oy: 4 }); + execSync(`magick ${TMP}/wm_a.png -rotate 90 ${TMP}/wm_a_s.png`); + execSync(`magick ${TMP}/wm_c.png -rotate 90 ${TMP}/wm_c_s.png`); + dotPng(`${TMP}/wm_dot.png`, 9); + const [aw, ah] = idim(`${TMP}/wm_a_s.png`), [cw] = idim(`${TMP}/wm_c_s.png`), [dw, dh] = idim(`${TMP}/wm_dot.png`); + const colW = Math.max(aw, cw, dw), rightX = W - colW - 30, topY = 60, gap = 5; + const dotY = topY + ah + gap, cY = dotY + dh + gap; + sideEls = [ + { png: `${TMP}/wm_a_s.png`, x: Math.round(rightX + (colW - aw) / 2), y: topY }, + { png: `${TMP}/wm_c_s.png`, x: Math.round(rightX + (colW - cw) / 2), y: Math.round(cY) }, + ]; + dotEl = { png: `${TMP}/wm_dot.png`, x: Math.round(rightX + (colW - dw) / 2), y: Math.round(dotY), amp: 11, f: 0.3 }; + haveSide = true; +} +// push the side-label overlays (words faint, dot bounces left) → [vout] +function sideOverlay(inputs, fc, cur, idx) { + if (!haveSide) { fc.push(`[${cur}]copy[vout]`); return idx; } + for (const el of sideEls) { + inputs.push(`-i ${el.png}`); + fc.push(`[${idx}:v]format=rgba,colorchannelmixer=aa=0.72[w${idx}]`); + fc.push(`[${cur}][w${idx}]overlay=${el.x}:${el.y}[t${idx}]`); cur = `t${idx}`; idx++; + } + inputs.push(`-i ${dotEl.png}`); + fc.push(`[${cur}][${idx}:v]overlay='${dotEl.x}-${dotEl.amp}*abs(sin(2*PI*${dotEl.f}*t))':${dotEl.y}[vout]`); idx++; + return idx; +} + +const TX = 70, TY = 94; // title anchor (nudged down for a nicer safe zone) + +// ── 2.5) PREVIEW: render only a short window (local timeline) for fast feedback ─ +if (PREVIEW) { + const { t0, dur } = PREVIEW, t1 = t0 + dur; + const inputs = [], fc = []; + const ov = imgSegs.map((s, k) => ({ s, k, segEnd: (k === lastIx ? DURATION : s.endSec) })) + .filter(({ s, segEnd }) => segEnd > t0 && s.startSec < t1); + ov.forEach(({ s, k, segEnd }, j) => { + const subStart = Math.max(t0, s.startSec), subEnd = Math.min(t1, segEnd), subDur = subEnd - subStart; + if (EXTRACTS[s.name]) { + const ex = EXTRACTS[s.name], clip = `${TMP}/extract_${s.name}.mp4`; + if (!existsSync(clip)) spawnSync("node", [`${REPO}/marketing/bin/paper-extract-scene.mjs`, "--pdf", resolve(REPO, ex.pdf), "--quote", ex.quote, "--out", clip, "--start", String(s.startSec), "--dur", (segEnd - s.startSec).toFixed(3), ...(ex.cite ? ["--cite", ex.cite] : [])], { stdio: "inherit" }); + inputs.push(`-ss ${(subStart - s.startSec).toFixed(3)} -t ${subDur.toFixed(3)} -i ${clip}`); + fc.push(`[${j}:v]fps=${FPS},setsar=1,format=yuv420p[p${j}]`); + } else { + const img = existsSync(`${campDir}/${s.name}/gens/native.png`) ? `${campDir}/${s.name}/gens/native.png` : `${campDir}/${s.name}/gens/pencil.png`; + inputs.push(`-framerate ${FPS} -loop 1 -t ${subDur.toFixed(3)} -i ${img}`); + fc.push(`[${j}:v]${kenBurns(k, Math.round((segEnd - s.startSec) * FPS), Math.round((subStart - s.startSec) * FPS))}[p${j}]`); + } + }); + fc.push(ov.map((_, j) => `[p${j}]`).join("") + `concat=n=${ov.length}:v=1:a=0[base]`); + let idx = ov.length, cur = "base"; + ov.forEach(({ s, segEnd }) => { + if (!TITLES[s.name] || EXTRACTS[s.name]) return; + const win = `between(t,${Math.max(0, s.startSec - t0).toFixed(2)},${Math.min(dur, segEnd - t0).toFixed(2)})`; + inputs.push(`-i ${TMP}/title_${s.name}.png`); + fc.push(`[${cur}][${idx}:v]overlay=${TX}:${TY}:enable='${win}'[t${idx}]`); cur = `t${idx}`; idx++; + inputs.push(`-i ${TMP}/num_${s.name}.png`); + fc.push(`[${cur}][${idx}:v]overlay=${TX + titleDims[s.name].w + 8}:${TY - 10}:enable='${win}'[t${idx}]`); cur = `t${idx}`; idx++; + }); + inputs.push(`-i ${CAPTIONS_MOV}`); + fc.push(`[${cur}][${idx}:v]overlay=0:0[t${idx}]`); cur = `t${idx}`; idx++; + idx = sideOverlay(inputs, fc, cur, idx); + inputs.push(`-ss ${t0} -t ${dur} -i ${NARR}`); const aN = idx++; + if (BED && existsSync(BED)) { inputs.push(`-ss ${t0} -t ${dur} -i ${BED}`); const aB = idx++; fc.push(`[${aN}:a]volume=1.0[an]`); fc.push(`[${aB}:a]volume=0.32[ab]`); fc.push(`[an][ab]amix=inputs=2:duration=longest:dropout_transition=0[aout]`); } + else fc.push(`[${aN}:a]volume=1.0[aout]`); + const pOut = OUT.replace(/\.mp4$/, "-preview.mp4"); + const fcPath = `${TMP}/filter_preview.txt`; writeFileSync(fcPath, fc.join(";\n")); + const cmd = `ffmpeg -y ${inputs.join(" ")} -filter_complex_script ${fcPath} -map "[vout]" -map "[aout]" -r ${FPS} -t ${dur.toFixed(2)} -c:v libx264 -pix_fmt yuv420p -crf 12 -preset medium -c:a aac -b:a 192k "${pOut}"`; + writeFileSync(`${TMP}/cmd_preview.sh`, cmd); + console.log(`PREVIEW [${t0}..${t1}s] → ${pOut}`); + const rr = spawnSync("bash", ["-c", cmd], { stdio: "inherit" }); + console.log(rr.status === 0 ? `✓ PREVIEW ${pOut}` : `✗ ffmpeg exit ${rr.status}`); + process.exit(rr.status === 0 ? 0 : 1); +} + +// ── 3) ffmpeg graph ────────────────────────────────────────────────── +console.log("assembling ffmpeg graph…"); +const inputs = [], fc = []; +imgSegs.forEach((s, k) => { + const end = (k === lastIx) ? DURATION : s.endSec; + const dur = end - s.startSec; + // paper-extract scene: render the PDF-highlight clip and use it as the bg + if (EXTRACTS[s.name]) { + const ex = EXTRACTS[s.name]; + const clip = `${TMP}/extract_${s.name}.mp4`; + const r = spawnSync("node", [`${REPO}/marketing/bin/paper-extract-scene.mjs`, + "--pdf", resolve(REPO, ex.pdf), "--quote", ex.quote, "--out", clip, + "--start", String(s.startSec), "--dur", dur.toFixed(3), ...(ex.cite ? ["--cite", ex.cite] : [])], + { stdio: "inherit" }); + if (r.status !== 0) { console.error("✗ paper-extract failed"); process.exit(1); } + inputs.push(`-i ${clip}`); + fc.push(`[${k}:v]fps=${FPS},setsar=1,format=yuv420p[p${k}]`); // normalize for concat + return; + } + const img = existsSync(`${campDir}/${s.name}/gens/native.png`) ? `${campDir}/${s.name}/gens/native.png` : `${campDir}/${s.name}/gens/pencil.png`; + inputs.push(`-framerate ${FPS} -loop 1 -t ${dur.toFixed(3)} -i ${img}`); + fc.push(`[${k}:v]${kenBurns(k, Math.round(dur * FPS))}[p${k}]`); +}); +fc.push(imgSegs.map((_, k) => `[p${k}]`).join("") + `concat=n=${imgSegs.length}:v=1:a=0[base]`); + +let idx = imgSegs.length, cur = "base"; +imgSegs.forEach((s, k) => { + if (!TITLES[s.name] || EXTRACTS[s.name]) return; // extract scenes carry their own citation + const end = (k === lastIx) ? DURATION : s.endSec; + const win = `between(t,${s.startSec.toFixed(2)},${end.toFixed(2)})`; + inputs.push(`-i ${TMP}/title_${s.name}.png`); + fc.push(`[${cur}][${idx}:v]overlay=${TX}:${TY}:enable='${win}'[t${idx}]`); cur = `t${idx}`; idx++; + const nx = TX + titleDims[s.name].w + 8; + inputs.push(`-i ${TMP}/num_${s.name}.png`); + fc.push(`[${cur}][${idx}:v]overlay=${nx}:${TY - 10}:enable='${win}'[t${idx}]`); cur = `t${idx}`; idx++; +}); +// word-train caption layer (full-frame transparent overlay) +inputs.push(`-i ${CAPTIONS_MOV}`); +fc.push(`[${cur}][${idx}:v]overlay=0:0[t${idx}]`); cur = `t${idx}`; idx++; +// right-side vertical wordmark label with bouncing dot +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]`); +} else { fc.push(`[${aNarr}:a]volume=1.0[aout]`); } + +const fcPath = `${TMP}/filter.txt`; +writeFileSync(fcPath, fc.join(";\n")); +const cmd = `ffmpeg -y ${inputs.join(" ")} -filter_complex_script ${fcPath} ` + + `-map "[vout]" -map "[aout]" -r ${FPS} -t ${DURATION.toFixed(2)} ` + + `-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}`); +process.exit(r.status === 0 ? 0 : 1); diff --git a/marketing/bin/paper-extract-scene.mjs b/marketing/bin/paper-extract-scene.mjs new file mode 100644 index 0000000000..c4b4e0718d --- /dev/null +++ b/marketing/bin/paper-extract-scene.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// paper-extract-scene.mjs — a "paper-extract" scene: shows a real AC-paper PDF +// page on screen with a yellow highlighter that wipes across the quoted line(s) +// word-by-word, synced to the spoken narration, while the page drifts up. The +// documentary / newscast "here's the cited passage" move. Outputs an opaque +// 1920x1080 clip used as that segment's background by compose-widescreen.mjs. +// +// Usage: node paper-extract-scene.mjs --pdf --quote "" \ +// --out --start --dur \ +// [--cite "Author, Title (Year), p.N"] +import { createCanvas, loadImage } from "canvas"; +import { readFileSync, mkdirSync } from "node:fs"; +import { spawn, execSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const W = 1920, H = 1080, FPS = 30; + +const argv = process.argv.slice(2); +const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; }; +const pdf = resolve(REPO, arg("--pdf")); +const quote = arg("--quote"); +const OUT = arg("--out"); +const segStart = Number(arg("--start")); +const dur = Number(arg("--dur")); +const cite = arg("--cite", ""); +const DPI = Number(arg("--dpi", 300)); // zoomed-in close-up on the passage +const SCALE = DPI / 72; +const TMP = `/tmp/paper-extract`; mkdirSync(TMP, { recursive: true }); + +const norm = (s) => s.replace(/&[^;]+;/g, "").replace(/[^A-Za-z]/g, "").toLowerCase(); + +// ── 1) locate the quote: page + per-word boxes (pdftotext -bbox) ───── +const bboxHtml = execSync(`pdftotext -bbox "${pdf}" - 2>/dev/null`).toString(); +const pages = [...bboxHtml.matchAll(/([\s\S]*?)<\/page>/g)]; +const qWords = quote.split(/\s+/).filter(Boolean); +const qn = qWords.map(norm); +let found = null; +pages.forEach((p, pi) => { + if (found) return; + const ws = [...p[3].matchAll(/([\s\S]*?)<\/word>/g)] + .map((m) => ({ x0: +m[1], y0: +m[2], x1: +m[3], y1: +m[4], t: m[5] })); + const wn = ws.map((w) => norm(w.t)); + for (let i = 0; i + qn.length <= wn.length; i++) { + if (qn.every((q, k) => wn[i + k] === q)) { + found = { page: pi + 1, boxes: ws.slice(i, i + qn.length) }; + break; + } + } +}); +if (!found) { console.error(`✗ quote not found verbatim in ${pdf}`); process.exit(1); } +console.log(`▸ quote on page ${found.page}, ${found.boxes.length} words`); + +// ── 2) render that page to PNG ─────────────────────────────────────── +const pageBase = `${TMP}/page`; +execSync(`pdftoppm -png -r ${DPI} -f ${found.page} -l ${found.page} "${pdf}" "${pageBase}" 2>/dev/null`); +const pagePng = execSync(`ls ${pageBase}*.png | head -1`).toString().trim(); +const pageImg = await loadImage(pagePng); + +// quote boxes in page-pixel space + its centre (the close-up frames on this) +const boxes = found.boxes.map((b) => ({ x: b.x0 * SCALE, y: b.y0 * SCALE, w: (b.x1 - b.x0) * SCALE, h: (b.y1 - b.y0) * SCALE })); +const qTop = Math.min(...boxes.map((b) => b.y)), qBot = Math.max(...boxes.map((b) => b.y + b.h)); +const qLeft = Math.min(...boxes.map((b) => b.x)), qRight = Math.max(...boxes.map((b) => b.x + b.w)); +const qMid = (qTop + qBot) / 2, qCenterX = (qLeft + qRight) / 2; +// centre on the quote's column, but clamp so the paper always fills the frame +const pageX = Math.round(Math.max(W - pageImg.width, Math.min(0, W / 2 - qCenterX))); + +// ── 3) spoken word timings for the wipe (words.json in the segment) ── +const words = JSON.parse(readFileSync(`${REPO}/recap/out/words.json`, "utf8")); +const segWords = words.filter((w) => (w.fromMs / 1000) >= segStart - 0.05 && (w.fromMs / 1000) < segStart + dur) + .map((w) => ({ from: w.fromMs / 1000, to: w.toMs / 1000 })); +// zip in order with the quote boxes (segment narration IS the quote) +const reveal = boxes.map((b, i) => ({ box: b, w: segWords[i] || segWords[segWords.length - 1] || { from: segStart, to: segStart + dur } })); + +// ── 4) render frames ───────────────────────────────────────────────── +const canvas = createCanvas(W, H), ctx = canvas.getContext("2d"); +ctx.imageSmoothingQuality = "high"; +const nFrames = Math.max(1, Math.round(dur * FPS)); +// LOSSLESS intermediate (qp 0, full chroma) — this clip gets re-encoded again +// by compose, and fine paper text must not be softened by a first lossy pass. +const ff = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pix_fmt", "bgra", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "-", + "-an", "-c:v", "libx264", "-qp", "0", "-pix_fmt", "yuv444p", "-preset", "medium", OUT], + { stdio: ["pipe", "inherit", "inherit"] }); +console.log(`▸ paper-extract · page ${found.page} · ${dur.toFixed(1)}s · ${nFrames} frames -> ${OUT}`); + +let frame = 0; +function draw() { + const t = frame / FPS; // 0..dur, local to the segment (scroll) + const tAbs = segStart + t; // absolute time (matches words.json reveal) + const u = t / Math.max(0.001, dur); + + // backdrop (soft dark, slight radial warmth) + ctx.globalCompositeOperation = "source-over"; + const bg = ctx.createRadialGradient(W / 2, H / 2, 100, W / 2, H / 2, 1100); + bg.addColorStop(0, "#14201a"); bg.addColorStop(1, "#06120c"); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + // page: quote sits ~just below centre at start, drifts up across the scene + const pageY = Math.round(Math.max(H - pageImg.height, Math.min(0, (H * 0.56 - qMid) - 150 * u))); + ctx.save(); + ctx.shadowColor = "rgba(0,0,0,0.55)"; ctx.shadowBlur = 38; ctx.shadowOffsetY = 10; + ctx.drawImage(pageImg, pageX, pageY); + ctx.restore(); + + // yellow highlighter wipe (multiply → real marker over the paper) + ctx.globalCompositeOperation = "multiply"; + for (const { box, w } of reveal) { + const p = tAbs >= w.to ? 1 : tAbs < w.from ? 0 : (tAbs - w.from) / Math.max(0.001, w.to - w.from); + if (p <= 0) continue; + const pad = box.h * 0.18; + ctx.fillStyle = "rgba(255,224,38,0.55)"; + ctx.fillRect(pageX + box.x - 2, pageY + box.y - pad, (box.w + 4) * p, box.h + pad * 2); + } + ctx.globalCompositeOperation = "source-over"; + + // citation (plain font is fine — small meta text, not a YWFT headline) + if (cite) { + ctx.font = "26px Georgia, serif"; ctx.textBaseline = "alphabetic"; ctx.textAlign = "left"; + ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillText(cite, 70, H - 60 + 2); + ctx.fillStyle = "rgba(232,255,232,0.92)"; ctx.fillText(cite, 68, H - 62); + } + + const ok = ff.stdin.write(Buffer.from(canvas.toBuffer("raw"))); + if (++frame >= nFrames) { ff.stdin.end(); return; } + if (ok) draw(); else ff.stdin.once("drain", draw); +} +ff.on("close", (c) => console.log(c === 0 ? `✓ ${OUT}` : `✗ ffmpeg exit ${c}`)); +draw(); diff --git a/marketing/bin/storyboard-widescreen.mjs b/marketing/bin/storyboard-widescreen.mjs new file mode 100644 index 0000000000..eb31dee9ac --- /dev/null +++ b/marketing/bin/storyboard-widescreen.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// storyboard-widescreen.mjs — GENERIC single-page "broadside" storyboard PDF for +// a narrated application video: per-segment illustration + spoken narration + +// on-screen text + illustration prompt + timing/beat/marker, all driven by the +// campaign config. Output to the Desktop (or cfg.storyboard.out), opened in Preview. +// +// Usage: node marketing/bin/storyboard-widescreen.mjs [--open] +import { readFileSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import puppeteer from "puppeteer"; +import sharp from "sharp"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +const argv = process.argv.slice(2); +const campArg = argv.find((a) => !a.startsWith("--")); +if (!campArg) { console.error("usage: storyboard-widescreen.mjs [--open]"); process.exit(2); } +const campDir = resolve(process.cwd(), campArg); +const { default: cfg } = await import(pathToFileURL(`${campDir}/campaign.mjs`).href); +const sb = cfg.storyboard || {}; +const OUT = sb.out || `${process.env.HOME}/Desktop/${cfg.name}-storyboard.pdf`; + +const segs = JSON.parse(readFileSync(`${REPO}/recap/out/segments.json`, "utf8")); +const words = JSON.parse(readFileSync(`${REPO}/recap/out/words.json`, "utf8")); +const recapDur = Math.max(...segs.map((s) => s.endSec)); +const TITLES = { ...(cfg.titles || {}), "10_end": "—" }; + +const FIX = (cfg.textFixes || []).map(([p, r, f]) => [new RegExp(p, f || ""), r]); +const fixWords = (s) => FIX.reduce((a, [re, r]) => a.replace(re, r), s); +const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); +const mmss = (t) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; + +const spokenFor = (s) => fixWords(words + .filter((w) => (w.fromMs / 1000) >= s.startSec - 0.05 && (w.fromMs / 1000) < s.endSec - 0.05) + .map((w) => w.text).join(" ").replace(/\s+([,.;:!?])/g, "$1").replace(/^["“]/, "")).trim(); + +async function dataUri(p) { + const buf = await sharp(p).resize({ width: 1400, withoutEnlargement: true }).jpeg({ quality: 82 }).toBuffer(); + return `data:image/jpeg;base64,${buf.toString("base64")}`; +} + +const rows = await Promise.all(segs.map(async (s, i) => { + const dir = `${campDir}/${s.name}`; + let img = `${dir}/gens/native.png`; if (!existsSync(img)) img = `${dir}/gens/pencil.png`; + const hasImg = existsSync(img); + const src = img.includes("native") ? "native" : (img.includes("pencil") ? "pencil" : "—"); + let prompt = `${dir}/cover-prompt-native.txt`; if (!existsSync(prompt)) prompt = `${dir}/cover-prompt.txt`; + const promptText = existsSync(prompt) ? readFileSync(prompt, "utf8").trim() : "(no prompt on file)"; + const dur = s.endSec - s.startSec; + const spoken = spokenFor(s) || (TITLES[s.name] ? "" : "(silent tail · wordmark hold)"); + const wc = spoken && !spoken.startsWith("(") ? spoken.split(/\s+/).length : 0; + return { + n: i + 1, name: s.name, title: TITLES[s.name] || s.name, start: s.startSec, end: s.endSec, dur, wc, + wpm: wc && dur ? Math.round(wc / dur * 60) : 0, + marker: (sb.markers || {})[s.name] || "", beat: (sb.beats || {})[s.name] || "", + spoken, onscreen: (sb.onscreen || {})[s.name] || "", img: hasImg ? await dataUri(img) : null, src, promptText, + }; +})); + +const PAGE_W = 1180; +const html = `
+
+
${esc(sb.coverKicker || "storyboard")}
+

${esc(sb.coverTitle || cfg.name)}

+

${esc(sb.coverSub || "")}

+
${esc(sb.spine || "")}
+
Widescreen 1920×1080 · ${mmss(recapDur)} · ${segs.length} segments
+ Audience: ${esc(cfg.audience || cfg.name)} · karaoke word-train captions
+ Pipeline: recap/audience/${esc(cfg.audience || cfg.name)}.mjs → marketing/bin/compose-widescreen.mjs ${esc(campArg)}
+
+ ${rows.map((r) => ` +
+

${String(r.n).padStart(2, "0")}  ${esc(r.title)} · ${mmss(r.start)}–${mmss(r.end)} (${r.dur.toFixed(1)}s) · ${esc(r.name)}

+
${esc(r.beat)}
+
marker cut: “${esc(r.marker)}”${r.wc ? `  ·  ${r.wc} words · ${r.wpm} wpm` : ""}  ·  illustration: ${r.src}.png
+
+
${r.img ? `` : `
(no illustration)
`}
+
+
Spoken (narration)
${esc(r.spoken)}
+
On-screen text
${esc(r.onscreen)}
+
Illustration prompt
${esc(r.promptText)}
+
+
+
`).join("")} +
`; + +const browser = await puppeteer.launch({ headless: "new", executablePath: CHROME, args: ["--no-sandbox", "--allow-file-access-from-files"] }); +try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: "networkidle0" }); + const heightPx = await page.evaluate(() => Math.ceil(document.documentElement.scrollHeight)); + await page.pdf({ path: OUT, width: `${PAGE_W}px`, height: `${heightPx}px`, printBackground: true, pageRanges: "1", margin: { top: 0, right: 0, bottom: 0, left: 0 } }); + console.log(`✓ broadside PDF (${PAGE_W}×${heightPx}px) → ${OUT}`); +} finally { await browser.close(); } +if (argv.includes("--open")) spawnSyncOpen(OUT); +function spawnSyncOpen(p) { execSync(`open -a Preview "${p}"`); } diff --git a/marketing/bin/title-card.mjs b/marketing/bin/title-card.mjs new file mode 100644 index 0000000000..9300ac0e97 --- /dev/null +++ b/marketing/bin/title-card.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// title-card.mjs — compose the branded title card: a custom colored-pencil +// "Silly Symphonies" frame illustration (gen) + YWFT/AC typography laid into its +// empty centre cartouche. Outputs /tmp/wsvideo-/title_card.png. +// +// Usage: node marketing/bin/title-card.mjs +import { mkdirSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const FONT = `${process.env.HOME}/Library/Fonts/ywft-processing-bold.ttf`; +const W = 1920, H = 1080; + +const campDir = resolve(process.cwd(), process.argv[2] || "."); +const { default: cfg } = await import(pathToFileURL(`${campDir}/campaign.mjs`).href); +const tc = cfg.titleCard || {}; +const TMP = `/tmp/wsvideo-${cfg.name}`; mkdirSync(TMP, { recursive: true }); +const OUT = `${TMP}/title_card.png`; + +const gen = tc.gen ? resolve(campDir, tc.gen) : `${campDir}/title-card/gens/v1.png`; +if (!existsSync(gen)) { console.error(`✗ no title-card illustration at ${gen}`); process.exit(1); } + +const BG = tc.bg || "#0a1a13"; // deep green letterbox (matches the frame ground) +const titleParts = tc.title || cfg.wordmark || ["Aesthetic", "Computer"]; +const sub = tc.subtitle || ""; +const INK = tc.ink || "#0e2c20"; // dark ink for the cream cartouche +const DOT = tc.dot || "#1c9b5e"; // brand-green accent dot +const AMBER = tc.subInk || "#7a5512"; +const cx = tc.cx ?? Math.round(W / 2), titleY = tc.titleY ?? 322; +const titlePx = tc.titlePx ?? 92, subPx = tc.subPx ?? 46; + +const idim = (p) => execSync(`magick identify -format "%w %h" "${p}"`).toString().trim().split(" ").map(Number); +function textPng(out, text, px, fill) { + execSync(`magick -background none -fill "${fill}" -font "${FONT}" -pointsize ${px} label:"${text}" "${out}"`); + return idim(out); +} + +// base: gen scaled to fill height, centred on the letterbox ground +execSync(`magick -size ${W}x${H} xc:'${BG}' \\( "${gen}" -resize x${H} \\) -gravity center -composite "${TMP}/tc_base.png"`); + +// title: two words STACKED (fits the oval cartouche), small green brand dot +// between them, subtitle below — all centred. +const [pa, pb] = titleParts; +const [waw, wah] = textPng(`${TMP}/tc_a.png`, pa, titlePx, INK); +const [wbw, wbh] = textPng(`${TMP}/tc_b.png`, pb, titlePx, INK); +const dotSide = Math.round(titlePx * 0.15); +execSync(`magick -size ${dotSide}x${dotSide} xc:'${DOT}' "${TMP}/tc_dot.png"`); +const lg = Math.round(titlePx * 0.16); +const y1 = titleY, dy = y1 + wah + lg, y2 = dy + dotSide + lg; +const cmds = [`magick "${TMP}/tc_base.png"`, + `\\( "${TMP}/tc_a.png" \\) -gravity NorthWest -geometry +${cx - Math.round(waw / 2)}+${y1} -composite`, + `\\( "${TMP}/tc_dot.png" \\) -gravity NorthWest -geometry +${cx - Math.round(dotSide / 2)}+${dy} -composite`, + `\\( "${TMP}/tc_b.png" \\) -gravity NorthWest -geometry +${cx - Math.round(wbw / 2)}+${y2} -composite`]; + +// subtitle (centred, below the stacked title) +if (sub) { + const [sw] = textPng(`${TMP}/tc_sub.png`, sub, subPx, AMBER); + const sy = y2 + wbh + Math.round(lg * 2.2); + cmds.push(`\\( "${TMP}/tc_sub.png" \\) -gravity NorthWest -geometry +${cx - Math.round(sw / 2)}+${sy} -composite`); +} +cmds.push(`"${OUT}"`); +execSync(cmds.join(" ")); +console.log(`✓ title card → ${OUT}`); +execSync(`open -a Preview "${OUT}"`); diff --git a/marketing/campaigns/README.md b/marketing/campaigns/README.md new file mode 100644 index 0000000000..1e3a282b96 --- /dev/null +++ b/marketing/campaigns/README.md @@ -0,0 +1,64 @@ +# Widescreen narrated application videos + +A reusable pipeline for 1920×1080 narrated proposal/application videos: +illustrated per-segment slides with **Ken Burns** zoom, top-left titles with +**slide-number superscripts**, a top-right bouncing **wordmark**, a karaoke +**word-train** caption layer, and a narration + ambient-bed audio mix. A +matching single-page **broadside storyboard** PDF falls out of the same config. + +Serpentine (`serpentine-video/`) is the template. The engines are generic; each +video is just a config + illustrations. + +## Engines (generic — don't edit per-campaign) + +- `marketing/bin/compose-widescreen.mjs ` — builds the .mp4 +- `marketing/bin/captions-train.mjs ` — the word-train caption .mov (called by compose) +- `marketing/bin/storyboard-widescreen.mjs [--open]` — the broadside PDF + +## A campaign = a folder under `marketing/campaigns/` + +``` +my-video/ + campaign.mjs # config (see serpentine-video/campaign.mjs) + 01_intro/ + gens/native.png # the illustration (or pencil.png fallback) + cover-prompt-native.txt# the gen prompt (shown in the storyboard) + 02_…/ … # one folder per segment, named to match the recap audience +``` + +Segment **names + timings** come from the recap audience, not the campaign — +the folder names must match the segment `name`s in `recap/out/segments.json`. + +## How to make a new one + +1. **Author the narration** as a recap audience: `recap/audience/.mjs` + (narration text + segment markers + on-screen slide text). Set + `voice.speed` (~1.08 reads well) and `sing: false`. +2. **Build the transcript/audio:** `node recap/cli.mjs build ` → + writes `recap/out/{recap.mp3, words.json, segments.json}`. +3. **Make the illustrations:** one `gens/native.png` per segment folder + (1024×1536 portrait works; it's cover-cropped to 1920×1080). The pop-style + prompt convention lives in `serpentine-video/STYLE.md`. +4. **Write `campaign.mjs`** (copy serpentine's): `name`, `audience`, `out`, + `bed`, `palette`, `wordmark`, `titles{}`, `textFixes[]`, and `storyboard{}` + metadata (titles/onscreen/markers/beats/spine). +5. **Compose:** `node marketing/bin/compose-widescreen.mjs marketing/campaigns/-video` +6. **Storyboard:** `node marketing/bin/storyboard-widescreen.mjs marketing/campaigns/-video --open` + +## Tunables (optional, in `campaign.mjs`) + +- `caption: { smooth, activePx, nearPx, ghostPx, pop, spread, gap, bandY }` — + word-train scaling + scroll feel. `smooth` lower = smoother/laggier scroll + (default 0.18). `activePx`/`nearPx`/`ghostPx` set the size gradient. +- `palette: { cream, green, shadow }` — text + glow + shadow colors. +- `wordmark: [left, right]` — top-right wordmark (omit to skip it). + +## Gotchas + +- **node-canvas cannot render the YWFT font** (renders `.notdef` tofu) — that's + why every glyph is pre-rendered via ImageMagick. Don't "simplify" it back to + `fillText`. +- Recap's `recap/out/` is shared scratch — build one audience, then compose, + before building another. +- Intermediates live in `/tmp/wsvideo-/` (word PNGs, captions.mov, the + ffmpeg filter graph + `cmd.sh` for debugging). diff --git a/marketing/campaigns/serpentine-video/campaign.mjs b/marketing/campaigns/serpentine-video/campaign.mjs new file mode 100644 index 0000000000..03240258ee --- /dev/null +++ b/marketing/campaigns/serpentine-video/campaign.mjs @@ -0,0 +1,105 @@ +// campaign.mjs — config for the Serpentine widescreen application video. +// Consumed by marketing/bin/{compose-widescreen,storyboard-widescreen}.mjs. +// The campaign DIR (this folder) also holds the per-segment illustration +// folders (01_title/gens/native.png …). Segment names + timings come from +// recap/out/segments.json after `node recap/cli.mjs build `. +import { homedir } from "node:os"; +const HOME = homedir(); + +export default { + name: "serpentine", + // recap audience that produces recap/out/{recap.mp3,words.json,segments.json}. + // Build it first: node recap/cli.mjs build serpentine + audience: "serpentine", + + out: `${HOME}/Documents/Shelf/serpentine-fae-widescreen.mp4`, + bed: `${HOME}/Documents/Shelf/serpentine-ambient-bed.mp3`, + + palette: { cream: "#fcf7c5", green: "#3dff88", shadow: "#000000" }, + wordmark: ["Aesthetic", "Computer"], // rendered top-right with a bouncing dot between + + // opening title card — a custom colored-pencil "Silly Symphonies" frame + // (title-card/gens/v1.png) with YWFT/AC type laid into its centre cartouche. + titleCard: { title: ["Aesthetic", "Computer"], subtitle: "A Computer of Your Own" }, + caption: { bandY: 0.85 }, // word-train vertical centre (fraction of height; lower = nearer bottom) + + // per-segment accent color — drives the active-word glow + the slide-number + // superscript. An evolving arc across the film (AC palette). + colors: { + "01_title": "#3dff88", // green + "02_problem": "#70f0e0", // cyan — the enclosure + "03_os_layer": "#ffbf3d", // amber — the idea + "04_creative_os": "#3dff88",// green — the instrument + "05_commons": "#ff70d0", // magenta — the community + "05b_quote": "#ffe026", // yellow — the highlighted quote + "06_llms": "#70f0e0", // cyan — the machines + "07_poised": "#ffbf3d", // amber — the argument + "09_outro": "#3dff88", // green — the question + "10_end": "#3dff88", + }, + + // segment name → on-screen display title (top-left, gets a slide-number superscript) + titles: { + "01_title": "A Computer of Your Own", "02_problem": "The Condition", + "03_os_layer": "The Need", "04_creative_os": "The Creative OS", + "05_commons": "The Commons", "05b_quote": "From the Paper", "06_llms": "What LLMs Change", + "07_poised": "Why Aesthetic.Computer", "08_plan": "The Six Months", + "09_outro": "Art x Convergence", + }, + + // paper-extract scenes: a segment shows a real AC-paper PDF page with a + // yellow highlighter wiping across the quoted line(s), synced to the spoken + // narration (the segment's narration IS the verbatim quote). marketing/bin/ + // paper-extract-scene.mjs renders it; compose uses it as that segment's bg. + extracts: { + "05b_quote": { + pdf: "papers/arxiv-plork/plork.pdf", + quote: "Three people with surplus laptops in a park can form a laptop orchestra.", + cite: "Scudder, PLOrk'ing the Planet (2026), p.5", + }, + }, + + // whisper mis-hearing → correct caption spelling [pattern, replacement, flags] + textFixes: [ + ["Scutter", "Scudder", "gi"], ["\\bAllen\\b", "Alan", ""], + ["notepad", "notepat", "gi"], ["kid-?lisp", "KidLisp", "gi"], + ], + + // storyboard-only metadata (marketing/bin/storyboard-widescreen.mjs) + storyboard: { + coverKicker: "aesthetic computer · storyboard", + coverTitle: "A Computer of Your Own", + coverSub: "Serpentine — Future Art Ecosystems R&D Fellowship · Art × Convergence", + spine: "A ~2-minute spoken video proposal. Personal computing is being pulled into the data center; Aesthetic Computer is the divergent counter-move — a bare-metal creative OS + commons that re-founds the personal computer on surplus hardware, with LLMs taken in as a local, owned faculty rather than a landlord.", + onscreen: { + "01_title": "a computer of your own — a proposal · art × convergence", + "02_problem": "computing is being pulled into the data center · your laptop → a thin client", + "03_os_layer": "a revolution at the OS layer · not another app — a different ground", + "04_creative_os": "notepat — tunes you can type · picture · code · music — one medium", + "05_commons": "a planetary laptop orchestra · kidlisp · a url per piece · held in common", + "05b_quote": "from the paper · PLOrk'ing the Planet (2026), p.5", + "06_llms": "a faculty, not a landlord · local · owned · on your own terms", + "07_poised": "it holds the whole stack · kernel · language · community · archive", + "09_outro": "keep a computer personal — and held in common", + "10_end": "aesthetic computer · a computer of your own", + }, + markers: { + "01_title": "hey", "02_problem": "the data center", "03_os_layer": "operating system layer", + "04_creative_os": "surplus laptop", "05_commons": "it is a commons", + "05b_quote": "three people", "06_llms": "language models are the force", "07_poised": "can a creative", + "09_outro": "the question of", "10_end": "__END__", + }, + beats: { + "01_title": "Who you are — title card over jeffrey at the citrus-green Neo.", + "02_problem": "The enclosure — a laptop dissolving into the data center, tethered as a thin client.", + "03_os_layer": "The pivot — home computing needs a revolution at the OS layer, not another app.", + "04_creative_os": "The instrument — notepat: typable tunes, folk transmission, picture/code/music as one medium, on a dedicated OS.", + "05_commons": "The commons — KidLisp + a planetary laptop orchestra on surplus hardware (PLOrk $1,500/seat vs 240M obsolete PCs).", + "05b_quote": "Paper extract — the PLOrk paper fills the frame; a yellow highlighter wipes across the cited line as it's read aloud.", + "06_llms": "The turn — LLMs as a local, owned faculty rather than a landlord.", + "07_poised": "Why AC — it already holds the whole stack: kernel, language, community, archive.", + "09_outro": "The question — keep a computer personal, and held in common.", + "10_end": "Wordmark hold — Aesthetic•Computer.", + }, + }, +}; diff --git a/marketing/campaigns/serpentine-video/compose.mjs b/marketing/campaigns/serpentine-video/compose.mjs deleted file mode 100644 index 0e4675d02f..0000000000 --- a/marketing/campaigns/serpentine-video/compose.mjs +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env node -// serp-comp.mjs — widescreen (1920x1080) compositor for the Serpentine video. -// No libass/drawtext in ffmpeg -> all text is pre-rendered PNG (ImageMagick, -// YWFT Processing, sharp /pop-style -shadow) and overlaid timed by ffmpeg. -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; -import { execSync, spawnSync } from "node:child_process"; - -const REPO = "/Users/jas/aesthetic-computer"; -const CAMP = `${REPO}/marketing/campaigns/serpentine-video`; -const OUTDIR = "/tmp/serp-comp"; mkdirSync(OUTDIR, { recursive: true }); -const FONT = `${process.env.HOME}/Library/Fonts/ywft-processing-bold.ttf`; -const NARR = `${REPO}/recap/out/recap.mp3`; -const BED = `${process.env.HOME}/Documents/Shelf/serpentine-ambient-bed.mp3`; -const W = 1920, H = 1080, FPS = 30; -const CREAM = "#fcf7c5", GREEN = "#3dff88", SHADOW = "#000000"; - -const segs = JSON.parse(readFileSync(`${REPO}/recap/out/segments.json`, "utf8")); -const subsRaw = JSON.parse(readFileSync(`${REPO}/recap/out/subs.json`, "utf8")); -const DURATION = Math.max(...segs.map(s => s.endSec)); - -const TITLES = { - "01_title": "A Computer of Your Own", "02_problem": "The Condition", - "03_os_layer": "The Need", "04_creative_os": "The Creative OS", - "05_commons": "The Commons", "06_llms": "What LLMs Change", - "07_poised": "Why Aesthetic.Computer", "08_plan": "The Six Months", - "09_outro": "Art x Convergence", -}; -const FIXES = [[/aesthetic\.?\s?computer/gi,"Aesthetic.Computer"],[/twenty[ -]?twenty[ -]?six/gi,"2026"],[/Scutter/g,"Scudder"],[/Allen/g,"Alan"],[/[Kk]id ?[Ll]isp/g,"KidLisp"],[/[Tt]hink ?[Pp]ad/g,"ThinkPad"],[/\bAC ?OS\b/gi,"AC Native OS"]]; -const fix = t => FIXES.reduce((s,[a,b])=>s.replace(a,b), t); - -// ── ImageMagick text PNG with sharp /pop shadow (-shadow 100x0+ox+oy) ── -function textPng(out, text, { size, fill, width, oy = 6, align = "Center" }) { - const ox = Math.round(oy * 0.85); - const src = width - ? ["-size", `${width}x`, "-background", "none", "-fill", fill, "-font", FONT, - "-pointsize", String(size), "-gravity", align, `caption:${text}`] - : ["-background", "none", "-fill", fill, "-font", FONT, "-pointsize", String(size), - `label:${text}`]; - // render text, then composite a hard black shadow behind it - const args = [...src, - "(", "+clone", "-background", SHADOW, "-shadow", `100x0+${ox}+${oy}`, ")", - "+swap", "-background", "none", "-layers", "merge", "+repage", out]; - const r = spawnSync("magick", args, { encoding: "utf8" }); - if (r.status !== 0) { console.error("magick failed:", text, r.stderr); process.exit(1); } - // return rendered size - const dim = execSync(`magick identify -format "%w %h" ${out}`).toString().trim().split(" ").map(Number); - return { w: dim[0], h: dim[1] }; -} -function dotPng(out, side) { - // small green SQUARE (pixel-period feel) with the sharp /pop shadow - spawnSync("magick", ["-size", `${side + 8}x${side + 8}`, "xc:none", "-fill", GREEN, - "-draw", `rectangle 0,0 ${side - 1},${side - 1}`, - "(", "+clone", "-background", SHADOW, "-shadow", "100x0+3+4", ")", - "+swap", "-background", "none", "-layers", "merge", "+repage", out], { encoding: "utf8" }); -} - -console.log("rendering text pngs…"); -// titles (top-left) -for (const s of segs) { if (TITLES[s.name]) textPng(`${OUTDIR}/title_${s.name}.png`, TITLES[s.name], { size: 84, fill: CREAM, oy: 7 }); } -// captions (bottom center, wrapped) -const caps = subsRaw.map((c, i) => { - const txt = fix(c.text); - textPng(`${OUTDIR}/cap_${i}.png`, txt, { size: 58, fill: CREAM, width: 1500, oy: 6, align: "Center" }); - return { i, start: c.startSec, end: c.endSec }; -}); -// wordmark parts + dot -const wmSize = 44; -const aDim = textPng(`${OUTDIR}/wm_a.png`, "Aesthetic", { size: wmSize, fill: CREAM, oy: 4 }); -const cDim = textPng(`${OUTDIR}/wm_c.png`, "Computer", { size: wmSize, fill: CREAM, oy: 4 }); -const dotSide = Math.round(wmSize * 0.22); -dotPng(`${OUTDIR}/wm_dot.png`, dotSide); -const dotImgW = parseInt(execSync(`magick identify -format "%w" ${OUTDIR}/wm_dot.png`).toString().trim(), 10); -// layout wordmark top-right: [Aesthetic][gap][dot][gap][Computer], snug -const gap = Math.round(wmSize * 0.07); -const wmTotal = aDim.w + gap + dotImgW + gap + cDim.w; -const wmX = W - 68 - wmTotal; -const wmY = 60; -const aX = wmX, dotX = aX + aDim.w + gap, cX = dotX + dotImgW + gap; -// dot sits low like a period; bounces upward -const dotRestY = wmY + Math.round(wmSize * 0.66); -const bounceAmp = 18, bounceF = 1.3; - -// ── build ffmpeg ── -console.log("assembling ffmpeg graph…"); -const inputs = []; -const fc = []; -// 9 pencil stills, each looped for its segment duration -segs.filter(s => TITLES[s.name] || s.name === "09_outro").forEach(() => {}); -const imgSegs = segs.filter(s => s.name !== "10_end"); // 9 segments -imgSegs.forEach((s, k) => { - const dur = (s.name === "09_outro") ? (DURATION - s.startSec) : (s.endSec - s.startSec); - const nativeImg = `${CAMP}/${s.name}/gens/native.png`; - const img = existsSync(nativeImg) ? nativeImg : `${CAMP}/${s.name}/gens/pencil.png`; - inputs.push(`-loop 1 -t ${dur.toFixed(3)} -i ${img}`); - fc.push(`[${k}:v]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},setsar=1,fps=${FPS}[p${k}]`); -}); -fc.push(imgSegs.map((_, k) => `[p${k}]`).join("") + `concat=n=${imgSegs.length}:v=1:a=0[base]`); - -let idx = imgSegs.length; // next input index -let cur = "base"; -// title overlays -const titleInputIdx = {}; -imgSegs.forEach((s) => { - if (!TITLES[s.name]) return; - inputs.push(`-i ${OUTDIR}/title_${s.name}.png`); - const end = (s.name === "09_outro") ? DURATION : s.endSec; - fc.push(`[${cur}][${idx}:v]overlay=68:56:enable='between(t,${s.startSec.toFixed(2)},${end.toFixed(2)})'[t${idx}]`); - cur = `t${idx}`; idx++; -}); -// caption overlays -caps.forEach((c) => { - inputs.push(`-i ${OUTDIR}/cap_${c.i}.png`); - fc.push(`[${cur}][${idx}:v]overlay=(W-w)/2:H-h-86:enable='between(t,${c.start.toFixed(2)},${c.end.toFixed(2)})'[t${idx}]`); - cur = `t${idx}`; idx++; -}); -// per-slide reference letters (A, B, C…) under the caption, for feedback -imgSegs.forEach((s, k) => { - const letter = String.fromCharCode(65 + k); - const lp = `${OUTDIR}/letter_${k}.png`; - textPng(lp, letter, { size: 48, fill: GREEN, oy: 5 }); - inputs.push(`-i ${lp}`); - const end = (s.name === "09_outro") ? DURATION : s.endSec; - fc.push(`[${cur}][${idx}:v]overlay=(W-w)/2:H-h-26:enable='between(t,${s.startSec.toFixed(2)},${end.toFixed(2)})'[t${idx}]`); - cur = `t${idx}`; idx++; -}); -// wordmark Aesthetic -inputs.push(`-i ${OUTDIR}/wm_a.png`); -fc.push(`[${cur}][${idx}:v]overlay=${aX}:${wmY}[t${idx}]`); cur = `t${idx}`; idx++; -// wordmark Computer -inputs.push(`-i ${OUTDIR}/wm_c.png`); -fc.push(`[${cur}][${idx}:v]overlay=${cX}:${wmY}[t${idx}]`); cur = `t${idx}`; idx++; -// bouncing dot -inputs.push(`-i ${OUTDIR}/wm_dot.png`); -fc.push(`[${cur}][${idx}:v]overlay=${dotX}:'${dotRestY}-${bounceAmp}*abs(sin(2*PI*${bounceF}*t))'[vout]`); idx++; - -// audio: narration + ambient bed (bed quiet), match narration length -inputs.push(`-i ${NARR}`); const aNarr = idx; idx++; -inputs.push(`-i ${BED}`); const aBed = idx; 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]`); - -const fcPath = `${OUTDIR}/filter.txt`; -writeFileSync(fcPath, fc.join(";\n")); -const OUT = `${process.env.HOME}/Documents/Shelf/serpentine-fae-widescreen.mp4`; -const cmd = `ffmpeg -y ${inputs.join(" ")} -filter_complex_script ${fcPath} ` + - `-map "[vout]" -map "[aout]" -r ${FPS} -t ${DURATION.toFixed(2)} ` + - `-c:v libx264 -pix_fmt yuv420p -crf 18 -preset medium -c:a aac -b:a 192k "${OUT}"`; -writeFileSync(`${OUTDIR}/cmd.sh`, cmd); -console.log(`inputs: ${inputs.length}, overlays: ${idx}`); -console.log("running ffmpeg… ->", OUT); -const r = spawnSync("bash", ["-c", cmd], { stdio: "inherit" }); -console.log(r.status === 0 ? `✓ DONE ${OUT}` : `✗ ffmpeg exit ${r.status}`); diff --git a/marketing/campaigns/serpentine-video/title-card/cover-prompt.txt b/marketing/campaigns/serpentine-video/title-card/cover-prompt.txt new file mode 100644 index 0000000000..37b967cc74 --- /dev/null +++ b/marketing/campaigns/serpentine-video/title-card/cover-prompt.txt @@ -0,0 +1,7 @@ +a horizontal 16:9 vintage 1930s cartoon TITLE CARD in the spirit of an old Silly Symphonies opening card, drawn as a COLORED-PENCIL illustration on warm paper — not a photograph, not flat vector. + +an ornate hand-drawn decorative border frames the whole card: curling art-nouveau flourishes, scrollwork, little stars and a few floating musical notes, drawn in terminal-green, warm gold-amber, soft cream and a touch of pink, all against a deep green-black ground. in each of the four corners a larger ornamental curl. woven small into the border, whimsical aesthetic-computer-world details: a tiny citrus-green laptop, a little surplus black laptop, a pixel cursor block, a couple of eighth-notes. at top center, a small pink two-line-figure "pals" emblem (two simple rounded line-art people holding hands). + +the CENTER of the card is an EMPTY ornate cartouche / banner panel — a calm, slightly-lighter open oval-ish space left blank for a title to be added later. DO NOT draw any text, letters, words, or numbers anywhere; leave the center clear and uncluttered. + +colors mix optically with visible hatching and parallel pencil striping; tapered, observed, hand-drawn marks; everything in crisp focus — NO blur, NO bokeh, NO soft wash, NO motion blur. warm, joyful, hand-made, symmetrical vintage-cartoon feeling. compose wide for 16:9. diff --git a/papers/arxiv-creator-system/ac-paper-layout.sty b/papers/arxiv-creator-system/ac-paper-layout.sty new file mode 100644 index 0000000000..9f302a4ddb --- /dev/null +++ b/papers/arxiv-creator-system/ac-paper-layout.sty @@ -0,0 +1,120 @@ +% ac-paper-layout.sty — Aesthetic Computer paper layout master template +% Usage: \usepackage{ac-paper-layout} in each arxiv-* paper +% +% This package provides the shared visual identity for all AC working drafts: +% - AC color palette +% - YWFT Processing font commands (\acbold, \aclight) +% - Draft watermark in Processing Light font +% - Pals logo watermark (top-left, rotated, semi-opaque) +% - Section/subsection formatting +% - Header/footer (draft notice + page numbers) +% - List and paragraph settings +% - \ac command for "Aesthetic Computer" small caps +% +% Papers still define their own: +% - \hypersetup{pdftitle=...} +% - \graphicspath{...} +% - Additional \newcommand macros (e.g. \wg, \acos) +% - Extra packages (listings, multicol, etc.) +% - Extra font families (ComicRelief for KidLisp, etc.) + +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{ac-paper-layout}[2026/03/16 Aesthetic Computer paper layout] + +% === FONTS === +% Requires fontspec (loaded by the document before this package) +\newfontfamily\acbold{ywft-processing-bold}[ + Path=../../system/public/type/webfonts/, + Extension=.ttf +] +\newfontfamily\aclight{ywft-processing-light}[ + Path=../../system/public/type/webfonts/, + Extension=.ttf +] + +% === COLORS (AC palette) === +\definecolor{acpink}{RGB}{180,72,135} +\definecolor{acpurple}{RGB}{120,80,180} +\definecolor{acdark}{RGB}{64,56,74} +\definecolor{acgray}{RGB}{119,119,119} +\definecolor{draftcolor}{RGB}{180,72,135} + +% === DRAFT WATERMARK (Processing font + pals logo) === +\RequirePackage{eso-pic} +\RequirePackage{tikz} +\usetikzlibrary{shadows.blur} +\AddToShipoutPictureBG{% + \begin{tikzpicture}[remember picture, overlay] + % --- Pals logo stamps: mirroring bios.mjs side-stamp pattern + % (drawTextAtPosition(0, 90) on the left, (canvasWidth, -90) on the right). + % Both pals stamps anchored to their respective page edges, one-third of + % the way down from the top. Rotated +90 / -90 so they run vertically up + % each side. Sinusoidally wiggling per page, semi-opaque, drop-shadowed, + % bleeding off the edge. --- + \pgfmathsetmacro{\palswiggle}{7*sin(\thepage * 67)}% + \node[opacity=0.10, anchor=center, rotate={90 + \palswiggle}, + blur shadow={shadow blur steps=5, shadow blur extra rounding=1pt, + shadow xshift=4pt, shadow yshift=-4pt, shadow opacity=0.18}] + at ([xshift=-1.0cm, yshift=-0.33\paperheight]current page.north west) + {\includegraphics[width=14cm]{pals}}; + \node[opacity=0.10, anchor=center, rotate={-90 + \palswiggle}, + blur shadow={shadow blur steps=5, shadow blur extra rounding=1pt, + shadow xshift=-4pt, shadow yshift=-4pt, shadow opacity=0.18}] + at ([xshift=1.0cm, yshift=-0.33\paperheight]current page.north east) + {\includegraphics[width=14cm]{pals}}; + % --- "WORKING DRAFT" text in Processing Light font --- + \node[opacity=0.12, rotate=45, anchor=center] + at (current page.center) + {{\aclight\fontsize{2.5cm}{3cm}\selectfont\color{acpink} WORKING DRAFT}}; + \end{tikzpicture}% +} + +% === SECTION FORMATTING === +\RequirePackage{titlesec} +\titleformat{\section} + {\normalfont\bfseries\normalsize\uppercase} + {\thesection.} + {0.5em} + {} +\titlespacing{\section}{0pt}{1.2em}{0.3em} + +\titleformat{\subsection} + {\normalfont\bfseries\small} + {\thesubsection} + {0.5em} + {} +\titlespacing{\subsection}{0pt}{0.8em}{0.2em} + +% === HEADER/FOOTER === +\RequirePackage{fancyhdr} +\pagestyle{fancy} +\fancyhf{} +\renewcommand{\headrulewidth}{0pt} +\fancyhead[C]{\footnotesize\color{acpink}\textit{Working Draft --- not for citation}} +\fancyfoot[C]{\footnotesize\thepage} + +% === LIST SETTINGS === +\RequirePackage{enumitem} +\setlist[itemize]{nosep, leftmargin=1.2em, itemsep=0.1em} +\setlist[enumerate]{nosep, leftmargin=1.2em} + +% === PARAGRAPH SETTINGS === +\setlength{\columnsep}{1.8em} +\setlength{\parindent}{1em} +\setlength{\parskip}{0.3em} + +% === HYPERREF COLORS === +\RequirePackage{hyperref} +\RequirePackage{xurl} +\hypersetup{ + colorlinks=true, + linkcolor=acpurple, + urlcolor=acpurple, + citecolor=acpurple, +} + +% === COMMON COMMANDS === +\newcommand{\ac}{\textsc{Aesthetic Computer}} +\newcommand{\acdot}{{\color{acpink}.}} + +\endinput diff --git a/papers/arxiv-creator-system/creator-system.tex b/papers/arxiv-creator-system/creator-system.tex new file mode 100644 index 0000000000..ba75b13ccd --- /dev/null +++ b/papers/arxiv-creator-system/creator-system.tex @@ -0,0 +1,298 @@ +% !TEX program = xelatex +\documentclass[10pt,letterpaper,twocolumn]{article} + +% === GEOMETRY === +\usepackage[top=0.75in, bottom=0.75in, left=0.75in, right=0.75in]{geometry} + +% === FONTS === +\usepackage{fontspec} +\usepackage{unicode-math} + +\setmainfont{Latin Modern Roman}[ + Extension=.otf, + UprightFont=lmroman10-regular, + BoldFont=lmroman10-bold, + ItalicFont=lmroman10-italic, + BoldItalicFont=lmroman10-bolditalic, +] +\setsansfont{Latin Modern Sans}[ + Extension=.otf, + UprightFont=lmsans10-regular, + BoldFont=lmsans10-bold, + ItalicFont=lmsans10-oblique, + BoldItalicFont=lmsans10-boldoblique, +] + +\newfontfamily\acbold{ywft-processing-bold}[ + Path=../../system/public/type/webfonts/, + Extension=.ttf +] +\newfontfamily\aclight{ywft-processing-light}[ + Path=../../system/public/type/webfonts/, + Extension=.ttf +] +\setmonofont{Latin Modern Mono}[ + Extension=.otf, + UprightFont=lmmono10-regular, + ItalicFont=lmmono10-italic, + Scale=0.85, +] + +% === PACKAGES === +\usepackage{xcolor} +\usepackage{titlesec} +\usepackage{enumitem} +\usepackage{booktabs} +\usepackage{tabularx} +\usepackage{multicol} +\usepackage{fancyhdr} +\usepackage{hyperref} +\usepackage{xurl} +\usepackage{graphicx} +\usepackage{ragged2e} +\usepackage{microtype} +\usepackage[colorspec=0.92]{draftwatermark} + +% === COLORS (AC palette) === +\definecolor{acpink}{RGB}{180,72,135} +\definecolor{acpurple}{RGB}{120,80,180} +\definecolor{acdark}{RGB}{64,56,74} +\definecolor{acgray}{RGB}{119,119,119} +\definecolor{draftcolor}{RGB}{180,72,135} +\definecolor{rowtint}{RGB}{244,238,246} + +% === DRAFT WATERMARK === +\DraftwatermarkOptions{ + text=WORKING DRAFT, + fontsize=3cm, + color=draftcolor!18, + angle=45, + pos={0.5\paperwidth, 0.5\paperheight} +} + +% === HYPERREF === +\hypersetup{ + colorlinks=true, + linkcolor=acpurple, + urlcolor=acpurple, + citecolor=acpurple, + pdfauthor={@jeffrey}, + pdftitle={Two Personal Intelligence Systems: The Aesthetic.Computer Research Platter and the Creator System}, +} + +% === SECTION FORMATTING === +\titleformat{\section} + {\normalfont\bfseries\normalsize\uppercase} + {\thesection.} + {0.5em} + {} +\titlespacing{\section}{0pt}{1.2em}{0.3em} + +\titleformat{\subsection} + {\normalfont\bfseries\small} + {\thesubsection} + {0.5em} + {} +\titlespacing{\subsection}{0pt}{0.8em}{0.2em} + +% === HEADER/FOOTER === +\pagestyle{fancy} +\fancyhf{} +\renewcommand{\headrulewidth}{0pt} +\fancyhead[C]{\footnotesize\color{acpink}\textit{Working Draft --- a letter to Matty}} +\fancyfoot[C]{\footnotesize\thepage} + +% === CUSTOM COMMANDS === +\newcommand{\acdot}{{\color{acpink}.}} +\newcommand{\ac}{\textsc{Aesthetic.Computer}} +\newcommand{\cs}{\textsc{Creator System}} +\newcommand{\yes}{{\color{acpurple}\textbf{$\bullet$}}} +\newcommand{\no}{{\color{acgray}$\circ$}} + +\setlist[itemize]{leftmargin=1.1em, itemsep=1pt, topsep=2pt} +\setlist[enumerate]{leftmargin=1.4em, itemsep=1pt, topsep=2pt} + +\begin{document} + +% ============ TITLE BLOCK ============ + +\twocolumn[{% +\begin{center} +\includegraphics[height=4em]{pals}\par\vspace{0.5em} +{\acbold\fontsize{21pt}{25pt}\selectfont\color{acdark} Two Personal Intelligence Systems}\par +\vspace{0.2em} +{\aclight\fontsize{11pt}{13pt}\selectfont\color{acpink} The Aesthetic.Computer Research Platter and the Creator System, compared}\par +\vspace{0.3em} +{\aclight\fontsize{9pt}{11pt}\selectfont\color{acgray} A letter to Matty, on what our two second-brains share, where they diverge, and what each could steal from the other}\par +\vspace{0.6em} +{\normalsize\href{https://prompt.ac/@jeffrey}{@jeffrey}}\par +{\small\color{acgray} Jeffrey Alan Scudder --- Aesthetic.Computer}\par +{\small\color{acgray} ORCID: \href{https://orcid.org/0009-0007-4460-4913}{0009-0007-4460-4913}}\par +\vspace{0.3em} +{\small\color{acpurple} \url{https://aesthetic.computer}}\par +\vspace{0.6em} +\rule{\textwidth}{1.5pt} +\vspace{0.5em} +\end{center} + +\begin{center} +{\small\color{acpink}\textbf{[ working draft --- not for citation ]}} +\end{center} +\vspace{0.3em} + +\begin{quote} +\small\noindent\textbf{Abstract.} +The \cs{} by Matty Mo Studio and the \ac{} research platter are, independently, the same wager: that an LLM agent pointed at a structured, trust-aware archive of one person's real material produces output that sounds like that person because it \emph{is} that person, not the average of the internet. This letter compares the two systems feature-by-feature. They converge on a layered vault (raw sources $\rightarrow$ processed notes $\rightarrow$ synthesized canon $\rightarrow$ outputs), a source-trust hierarchy, and a library of Claude Code skills. They diverge on what each was built \emph{around}: the \cs{} is a productized, teachable kit --- onboarding flow, page templates, ingestion pipelines, advisory skills, an opt-in cohort network --- engineered to be handed to a stranger and personalized in ten minutes. The \ac{} platter is a code-native publishing mill: it lives inside a running monorepo, presses the project's internal thinking into \textbf{49} \mbox{arXiv-format} papers and \textbf{21} institutional dossiers, enforces machine-auditable provenance (a first-hand-only lexicon that filters AI-written text), and maintains \emph{callable identity assets} --- a POI-annotated photographic self, a voice clone, a 421\,GB media archive --- that the \cs{} has no analogue for. The honest finding: as a daily knowledge base the \cs{} is largely redundant for someone already running the platter; as a \emph{packaging pattern} it is the more instructive artifact, a working proof that a personal vault can be productized into a kit and a cohort. I close with what each system could borrow from the other, and why the interesting market move is not adopting the \cs{} but mirroring its packaging move with the platter's richer substrate. +\end{quote} +\vspace{0.5em} +}] + +% ============ 1. INTRODUCTION ============ + +\section{Introduction} + +Matty, we built the same thing without comparing notes, and the differences are the interesting part. + +Both systems start from a rejection. Most ``AI tools'' wrap a chat box around a prompt and let the model reach for whatever is statistically nearby. Both of ours instead wrap the model around a \emph{specific archive} --- a structured store of one person's journals, documents, code, press, and conversations --- on the premise that synthesis grounded in real material is worth more than fluent generality. Your \cs{} states this outright (``most AI tools wrap a chatbot around a prompt; this wraps a chatbot around your archive''). The \ac{} platter never said it that cleanly, but it has been the operating assumption the whole time. + +This letter is a structured comparison. \S\ref{sec:arch} lines up the two architectures. \S\ref{sec:features} is the feature matrix. \S\ref{sec:cs-only} catalogs what your system has that mine does not; \S\ref{sec:ac-only} the reverse. \S\ref{sec:discussion} answers the question that prompted the letter --- is one redundant given the other? --- and argues that the more useful reading is about packaging, not redundancy. + +A note on method: every count in this paper was read directly off the two repositories on \mbox{2026-06-03}, not estimated. Where I describe your system I am reading the public \cs{} v2026-05-01 release; where I describe mine I am reading the \texttt{aesthetic-computer} monorepo and its \texttt{papers/} tree. + +% ============ 2. ARCHITECTURE ============ + +\section{Two Architectures, One Shape} +\label{sec:arch} + +Both systems are layered vaults that move material upward in trust and synthesis. The folder names differ; the gradient is identical. + +\begin{center} +\footnotesize +\begin{tabularx}{\columnwidth}{@{}lX@{}} +\toprule +\textbf{Layer} & \textbf{Role (shared across both)} \\ +\midrule +Sources & Immutable raw material. Never edited. \\ +Source notes & Processed extracts; \emph{conditional} --- not every source earns one. \\ +Canon & Durable synthesized knowledge: people, places, works, themes, timelines. \\ +Outputs & Reusable deliverables: bios, memos, decks, papers, copy. \\ +\bottomrule +\end{tabularx} +\end{center} + +\noindent The \cs{} encodes this as ten numbered folders (\texttt{00\_HOME} through \texttt{10\_META}) with an explicit constitution (\texttt{AGENTS.md}) defining page types, frontmatter schemas, and a four-level source hierarchy: L1 journals own emotional truth, L2 business documents own dates and amounts, L3 memoir is reflective, press is treated as possibly myth-shaped. The platter encodes the same gradient less formally but enforces it more aggressively in one place --- the lexicon (\S\ref{sec:ac-only}) --- where provenance is not a label but a filter. + +The deeper structural difference is \emph{where the vault lives}. The \cs{} is a free-standing Obsidian vault: a folder you can put anywhere, married to Claude Code, married to nothing else. The platter lives \emph{inside} a running software project. Its sources are commits, code comments, piece source files, lecture transcripts, and recap audio; its outputs deploy to \texttt{papers.aesthetic.computer}. The vault is not adjacent to the work --- it is the work, indexed. + +% ============ 3. FEATURE MATRIX ============ + +\section{The Feature Matrix} +\label{sec:features} + +{\centering +\footnotesize +\renewcommand{\arraystretch}{1.15} +\begin{tabularx}{\columnwidth}{@{}Xcc@{}} +\toprule +\textbf{Capability} & \textbf{\cs} & \textbf{Platter} \\ +\midrule +Layered source$\rightarrow$canon vault & \yes & \yes \\ +Source-trust hierarchy & \yes & \yes \\ +Agent ingestion pipeline & \yes & \yes \\ +Canon-page synthesis skill & \yes & \yes \\ +Structured onboarding (\texttt{let's go}) & \yes & \no \\ +Page-type templates + schemas & \yes & partial \\ +Ingestion tools for consumer exports & \yes & \no \\ +Advisory / decision skills & \yes & \no \\ +Opt-in cohort network & \yes & \no \\ +Documented SOP + recipes & \yes & partial \\ +Lives inside a code monorepo & \no & \yes \\ +First-hand-only provenance filter & \no & \yes \\ +Publishing mill (papers / cards) & \no & \yes \\ +Institutional dossier lane & \no & \yes \\ +Callable identity assets & \no & \yes \\ +Voice clone / audio self & \no & \yes \\ +Bulk media-archive hooks & \no & \yes \\ +Live email / calendar / drive & partial & \yes \\ +\bottomrule +\end{tabularx} +\par} + +\vspace{0.4em} +\noindent The matrix splits cleanly. The \cs{}'s column is dense at the top --- the \emph{onboarding, teaching, and reflection} band. The platter's is dense at the bottom --- the \emph{integration, provenance, and production} band. Neither is a superset of the other. That is the whole finding, compressed. + +% ============ 4. WHAT THE CREATOR SYSTEM HAS ============ + +\section{What the Creator System Has That the Platter Lacks} +\label{sec:cs-only} + +\subsection{A front door} +The platter has no \texttt{let's go}. Your system personalizes itself in one conversation --- name, practice, first source ingested, first canon page built, git initialized --- because it was designed to be handed to someone who has never seen it. Mine assumes its author is already inside it. For a system of one, that is fine; for anything else it is a wall. + +\subsection{Advisory skills --- a mode the platter has none of} +This is the gap I felt most. The platter's skills are all \emph{centripetal}: pull material in, compress it, press it out as a paper. Your skill library adds a \emph{centrifugal} mode --- the system talking back: +\begin{itemize} + \item \texttt{/council} --- twelve advisory personas evaluate a project, idea, or deal from every angle. + \item \texttt{/challenge} --- pressure-test a stated belief against the vault's own evidence. + \item \texttt{/drift} --- compare stated priorities against what the activity log actually shows. + \item \texttt{/connect}, \texttt{/emerge}, \texttt{/ideas} --- surface bridges and implications the archive contains but never stated. +\end{itemize} +The platter can write what I think. It cannot yet argue with me. These skills are portable in principle --- they are prompts over a vault --- and they are the first thing I would lift. + +\subsection{Ingestion tools for the messy real world} +Six pipelines (\texttt{photo-processor}, \texttt{takeout-processor}, \texttt{chatgpt-ingest}, \texttt{facebook-ingest}, \texttt{press-ingest}, \texttt{granola-ingest}) turn platform exports into clean Markdown. The platter ingests \emph{code and commits} natively but has no equivalent for a Facebook archive or a Google Takeout dump. For a non-programmer creator, your tools \emph{are} the on-ramp. + +\subsection{It is a product} +A README front door, 23 recipes, an SOP with manual fallbacks, an MIT license, a versioned changelog, and a cohort of real users filing friction logs. The platter is a workshop with the lights on and the door unlocked but no sign out front. Yours is a kit. + +% ============ 5. WHAT THE PLATTER HAS ============ + +\section{What the Platter Has That the Creator System Lacks} +\label{sec:ac-only} + +\subsection{It lives where the work happens} +The platter is not pointed at the work from outside; it is grown from inside the \texttt{aesthetic-computer} monorepo. Sources are first-party and continuous: commit messages, code comments, piece source, lecture transcripts, recap audio. There is no ``export then import'' step because the archive and the practice are the same files. + +\subsection{Machine-auditable provenance} +The \cs{} \emph{labels} trust (canonical / working / speculative). The platter, in the \texttt{jeffrey-lexicon}, \emph{enforces} it: a frequency-attributed dictionary of every word I have used first-hand, with AI-written text --- co-authored commits, machine-translated paragraphs, image-gen prompts --- excluded by source filter. A poisoned dictionary is no dictionary. This is a discipline the \cs{} gestures at and the platter operationalizes. + +\subsection{A publishing mill} +The reason the platter exists: to make the project's thinking \emph{leave the building}. It presses raw material into \textbf{49} arXiv-format papers and a \textbf{21}-dossier lane of institutional fact-surfacing (Rhizome, Eyebeam, Creative Capital, the Mellon Foundation --- each with an EIN-anchored IRS~990 financial spine). The same \LaTeX{} source renders as a full paper or a single-sheet card. The \cs{} produces outputs; the platter produces \emph{publications}, with formats, targets, and a deploy step. + +\subsection{Callable identity assets} +The strangest divergence. The platter maintains a canonical \emph{self} that pipelines can call by reference: a POI-annotated photographic index (headshots, candids, POV screenshots, face-matched archive selfies), a \texttt{jeffrey-pvc} voice clone wired into \texttt{/api/say}, and the lexicon as a textual self. ``A photograph of Jeffrey'' or ``Jeffrey's voice'' resolves to a concrete, annotated asset instead of a guess. The \cs{} models what you \emph{know}; the platter also models what you \emph{look and sound like}. + +\subsection{Hooks into the bulk archive and the live silos} +The platter indexes a 421\,GB Dropbox archive (exhibitions, masters, lecture footage), a multi-gigabyte Instagram pull, and --- this session --- live Gmail, Calendar, and Drive through MCP. It reaches past the text vault into the heavy media and the live accounts. + +% ============ 6. DISCUSSION ============ + +\section{Discussion: Redundant, or a Pattern?} +\label{sec:discussion} + +\subsection{The redundancy question, answered} +As a \emph{daily knowledge base}, the \cs{} is largely redundant for an operator already running the platter. Its core loop --- layered vault, agent ingestion, canon synthesis, source hierarchy --- is a subset of what the platter already does, minus the code-integration and the provenance enforcement. Adopting it as a second brain would trade rigor for nothing. That is the unsentimental read, and I think it is correct. + +\subsection{Why that is the wrong question} +The redundancy framing misses what your system actually demonstrates. You took a personal vault --- the bespoke, undocumented kind every serious practitioner accretes --- and \emph{productized} it: onboarding, templates, recipes, license, cohort. The artifact's real lesson is not its feature set. It is the proof that the move is doable: a second brain can become a kit other people run, and a cohort other people pay for. + +The platter has a \emph{richer substrate} for exactly that move. \ac{} already carries the teaching posture, a programmable medium (KidLisp), the mill, and the identity layer. ``The AC Research Platter, as a kit'' --- or even as an AC piece --- is a more defensible product than anything that subordinates one practice to another's vault. In that reading your \cs{} is not a tool to adopt; it is the \emph{reference implementation} of the packaging pattern, and the most useful thing I can take from it is the shape of the box, not the contents. + +\subsection{What each should steal} +\textbf{Platter $\leftarrow$ \cs{}:} the advisory skills (\texttt{/council}, \texttt{/challenge}, \texttt{/drift}) --- a reflection mode the mill lacks; the \texttt{let's go} onboarding shape, as the seed of a kit; the consumer-export ingestion tools, for the non-code material. + +\noindent\textbf{\cs{} $\leftarrow$ Platter:} provenance-as-filter, not just as label; a publishing/deploy lane so outputs can \emph{leave}; the notion of callable identity assets, so a vault can model not just what a creator knows but how they sound. + +% ============ 7. CONCLUSION ============ + +\section{Conclusion} + +We built the same wager from opposite ends. You built outward --- from a vault toward a product a stranger can run. I built downward --- from a vault into the code, the provenance, and the published record. The systems are not competitors and neither is redundant in the way the question implied: one is a \emph{teachable kit with a reflection mode}, the other a \emph{code-native mill with a callable self}. The trade worth making is not adoption in either direction. It is the cross-pollination in \S\ref{sec:discussion}, and the recognition --- which your system is the clearest available proof of --- that a personal intelligence system, once it is good enough to live in, is also a thing you can hand to other people. + +\vspace{0.5em} +\noindent\rule{\columnwidth}{0.4pt} +\vspace{0.3em} + +{\footnotesize\color{acgray}\noindent Counts (papers, dossiers, archive size) read from \texttt{aesthetic-computer} and \cs{} v2026-05-01 on 2026-06-03. Drafted with Claude Code inside the vault it describes --- itself a small instance of the thesis.} + +\end{document} diff --git a/pop/big-pictures/bin/gen-sections.mjs b/pop/big-pictures/bin/gen-sections.mjs index e84a456d37..8338ee9692 100644 --- a/pop/big-pictures/bin/gen-sections.mjs +++ b/pop/big-pictures/bin/gen-sections.mjs @@ -8,7 +8,7 @@ // MARK ZUCKERBERG onboards him into a metaverse game; the office gets // WARPED + REPLICATED inside it (the one woman-in-grey becomes an army // of grey selfie-girl avatars, the surfboard on the office wall becomes -// his wakeboard). Out on the grey lagoon DRONE-WARS erupt on the +// his wakeboard). Out on the Meta-blue lagoon DRONE-WARS erupt on the // horizon; Mark's avatar tries to drag him back (a tug-of-war); jeffrey // plows the oblivious girls over (none react), then finds the warm // GROUP PIXSIES round-table — the human antidote to the hollow crowd — @@ -60,6 +60,11 @@ const TAG = LANDSCAPE ? "-yt" : "-p"; const SLUG = "amaythingra"; mkdirSync(`${LANE}/out`, { recursive: true }); +// The whole metaverse keys to ONE colour — Meta's brand blue. Threaded +// into every block so the virtual world reads as corporate Meta blue +// (the water, the glitch, everything), never polychrome / rainbow. +const META_BLUE = "#0668E1"; // Meta brand blue (RGB 6,104,225) + // ── identity refs (mirrors marimba/bin/gen-sections.mjs) ───────────── const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot-2k`; const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`; @@ -92,13 +97,13 @@ function loadOpenAIKey() { // ── PORTRAIT / LANDSCAPE recomposition ─────────────────────────────── const PORTRAIT_NOTE = -`FRAME — compose for a TALL vertical 9:16 frame. HONOR THE SHOT DIRECTIVE in this beat exactly: a close-up / extreme close-up FILLS the frame with the face or detail (crop in tight — do NOT pull back); a first-person POV shows the scene through jeffrey's eyes; a wide shot lets the whole setting breathe. Keep a small calmer margin at the very top and very bottom for a wordmark + player UI. keep the glossy desaturated-grey metaverse render and the crisp NO-blur look.`; +`FRAME — compose for a TALL vertical 9:16 frame. HONOR THE SHOT DIRECTIVE in this beat exactly: a close-up / extreme close-up FILLS the frame with the face or detail (crop in tight — do NOT pull back); a first-person POV shows the scene through jeffrey's eyes; a wide shot lets the whole setting breathe. Keep a small calmer margin at the very top and very bottom for a wordmark + player UI. keep the established per-mode render style and the crisp NO-blur look.`; const LANDSCAPE_NOTE = -`FRAME — compose for a WIDE 3:2 / 16:9 frame. HONOR THE SHOT DIRECTIVE in this beat exactly: a close-up / extreme close-up FILLS the frame (crop in tight — do NOT pull back); a first-person POV shows the scene through jeffrey's eyes; a wide shot lets the whole setting stretch horizontally. Leave the upper third a little calmer for chrome. keep the glossy desaturated-grey metaverse render and the crisp NO-blur look.`; +`FRAME — compose for a WIDE 3:2 / 16:9 frame. HONOR THE SHOT DIRECTIVE in this beat exactly: a close-up / extreme close-up FILLS the frame (crop in tight — do NOT pull back); a first-person POV shows the scene through jeffrey's eyes; a wide shot lets the whole setting stretch horizontally. Leave the upper third a little calmer for chrome. keep the established per-mode render style and the crisp NO-blur look.`; // ── shared scene law — STYLE + MOOD from cover-prompt.v2.txt ───────── const MEDIUM = -`A COMPUTER-ANIMATED METAVERSE SCENE — a glossy real-time 3D game-engine render in the "metaverse avatar / Horizon Worlds" look: clean flat shading, smooth plasticky surfaces, simplified low-poly-ish geometry, slightly uncanny videogame-avatar characters with the soft rounded proportions and glossy plastic skin of a virtual-reality social-world avatar. This is NOT a photograph, NOT photoreal — it is a stylized glossy 3D-rendered CGI image, the way a corporate metaverse social app renders its avatars. Despite the cartoonish avatar render, the whole frame is deliberately DESATURATED and GREY-toned — an overcast, washed-out, almost colorless world, flat grey daylight, cold and joyless. NOT bright, NOT vivid, NOT a cheerful saturated children's cartoon, NOT Pixar-colorful — muted, low-contrast, cold, ironic. Deadpan, satirical. CRITICAL: CRISP and SHARP throughout — NO motion blur, NO depth-of-field blur, NO soft-focus, NO "blurred to suggest movement"; movement is conveyed only through pose and body lean. jeffrey's EXPRESSION + MOOD genuinely SHIFT per beat — read the EMOTION called out and let his face and posture show it clearly.`; +`A COMPUTER-ANIMATED METAVERSE SCENE — a glossy real-time 3D game-engine render in the "metaverse avatar / Horizon Worlds" look: clean flat shading, smooth plasticky surfaces, simplified low-poly-ish geometry, slightly uncanny videogame-avatar characters with the soft rounded proportions and glossy plastic skin of a virtual-reality social-world avatar. This is NOT a photograph, NOT photoreal — it is a stylized glossy 3D-rendered CGI image, the way a corporate metaverse social app renders its avatars. Despite the cartoonish avatar render, the WHOLE frame is washed in META BLUE (#0668E1, Meta's corporate brand blue) — a cold, flat, monotone Meta-blue colour grade over everything (sky, water, avatars, light), like a corporate metaverse promo. NOT polychrome, NOT rainbow, NOT a cheerful saturated cartoon, NOT Pixar-colorful, NOT grey — monotone Meta blue, low-contrast, cold, ironic. Deadpan, satirical. CRITICAL: CRISP and SHARP throughout — NO motion blur, NO depth-of-field blur, NO soft-focus, NO "blurred to suggest movement"; movement is conveyed only through pose and body lean. jeffrey's EXPRESSION + MOOD genuinely SHIFT per beat — read the EMOTION called out and let his face and posture show it clearly.`; const JEFFREY = `JEFFREY — about 30, recognizable from the jeffrey reference photographs (medium-length brown hair, light beard, his real face) but RESTYLED as a glossy plasticky metaverse-avatar version of himself with smooth low-poly shading. He wears an open burgundy hoodie over a plain soft tee and casual board shorts (or chinos in the office), barefoot on the water / sneakers in the office. His burgundy hoodie is the ONLY faint warm colour in an otherwise colourless frame. He is the one avatar that feels alive; NOT centered as a lone hero — equal eye-line with others. NO laptop, NO device, NO phone in his hands ever.`; @@ -116,19 +121,19 @@ const OFFICE = `THE OFFICE — a drab open-plan corporate TECH OFFICE rendered in the same glossy desaturated 3D style: rows of low grey cubicles, dark dead monitors, flat fluorescent ceiling light, grey carpet, a couple of sad potted plants, a glass-walled meeting room behind. Mounted on one wall as trendy startup decor is a single real SURFBOARD (the motif that later becomes jeffrey's wakeboard). Mundane, soul-less, washed-out corporate grey.`; const WORLD = -`THE METAVERSE WORLD — a wide virtual tropical ocean off a low sand-dune coastline rendered in a game engine but drained of colour: flat grey overcast sky, pale silver-grey simple-shaded water with stylized low-poly waves, bleached dune grass on a far sandbar, a calm shallow lagoon-like plane, hazy distant low dunes. Cool, grey, overcast, empty and lonely despite the crowd — the office dream warped into a weird joyless beach.`; +`THE METAVERSE WORLD — a wide virtual ocean off a low sand-dune coastline rendered in a game engine, the WHOLE scene in META BLUE (#0668E1): a flat Meta-blue sky and bright corporate META-BLUE WATER (#0668E1) with stylized low-poly waves, pale blue-tinted dune grass on a far sandbar, a calm shallow lagoon-like plane, hazy distant low dunes. Cold, monotone Meta blue, corporate and a little eerie — the office dream warped into a Meta-blue virtual beach. The water especially is unmistakably Meta blue (#0668E1).`; const DRONE = -`THE DRONE WARS ON THE HORIZON — across the distant back horizon of the grey sea, a FEW SPLASHY EXPLOSIONS of water erupt from SEA-MINES / drone-strikes detonating far out: several tall white-grey PLUMES of water blasting straight up off the far waterline, crisp columns and fans of flung spray spaced across the horizon — a distant metaverse SEA-BATTLE playing out behind everything. ATMOSPHERIC distant water-plume blasts ONLY — clean crisp geometry, NO fire, NO gore, NO debris, NO visible boats or weapons — kept small on the far horizon in the faintly-colorized grey grade, never crowding jeffrey.`; +`THE DRONE WARS ON THE HORIZON — across the distant back horizon of the Meta-blue sea, a FEW SPLASHY EXPLOSIONS of water erupt from SEA-MINES / drone-strikes detonating far out: several tall white-grey PLUMES of water blasting straight up off the far waterline, crisp columns and fans of flung spray spaced across the horizon — a distant metaverse SEA-BATTLE playing out behind everything. ATMOSPHERIC distant water-plume blasts ONLY — clean crisp geometry, NO fire, NO gore, NO debris, NO visible boats or weapons — kept small on the far horizon in the faintly-colorized grey grade, never crowding jeffrey.`; const PIXSIES = `THE GROUP PIXSIES — a single warm round (or oval) TABLE of "AC freaks": a small ETHNICALLY-DIVERSE band of joyful creative people aged 20s–60s gathered close, trading hand-made papers and odd hacker gear and little glowing handmade gadgets, leaning in, laughing, genuinely connected; a couple of small KIDS sit on the ground beside the table, and an ELDER plays a battered upright piano just behind. They are the human antidote to the hollow selfie crowd — rendered with a touch MORE warmth and life than the grey avatars (still inside the same metaverse render, just a small warm island of real connection amid the grey). jeffrey is a PEER among them, never centered, never a leader. ONE focal table only — NOT multiple tables, NOT a cafe full of tables.`; const PALETTE = -`PALETTE — heavily DESATURATED, grey-toned, overcast: flat grey sky / ceiling, bleached silver-grey water / carpet, washed-out cool avatar skin, all the women + Mark in muted grey; jeffrey's burgundy hoodie the only faint warmth (and a slightly warmer pool of life around the pixsies table when present). Soft flat overcast light, no harsh sun, no neon, no vivid saturation. Cool, grey, ironic.`; +`PALETTE — a monotone META BLUE (#0668E1) grade across the whole metaverse: Meta-blue sky, Meta-blue water, Meta-blue-tinted avatars; jeffrey's burgundy hoodie the only warm note (and a slightly warmer pool of life around the pixsies table when present). Flat corporate light, NO rainbow, NO polychrome, NO vivid multi-colour — just cold Meta blue. Ironic, corporate.`; const AVOID = -`AVOID — any photographic / photoreal look; any illustration / colored-pencil / gouache / painterly hand look; BRIGHT, vivid, saturated, cheerful cartoon colour, Pixar-glossy palettes, neon, sunset glow, teal-and-orange grade, tropical aqua or vivid blue water (the whole frame stays a washed-out cold GREY); motion blur of ANY kind, soft-focus, "blurred to suggest movement"; ANY laptop, computer, green MacBook, phone-in-jeffrey's-hand, screen/monitor showing content, tablet held by jeffrey; ANY matrix code, binary digits, falling 1s and 0s, glowing code; the drone-war blasts rendered as fire/flame/gore/debris/boats/weapons (clean distant WATER PLUMES only) or a giant tsunami wall of water; multiple pixsies tables / a cafe of tables (ONE focal table only); jeffrey centered as a hero or lone-leader; the selfie girls reacting, noticing, or looking at jeffrey/camera; any selfie girl in a costume / lewd / runway outfit; jeffrey in a tank top, costume, or formalwear; any readable real-world brand wordmark or logo anywhere; app / phone / Story UI chrome, HUD, menus, health bars, or text overlaid; any readable text or wordmark in the image; any second wakeboard rider; phones showing the surrounding scene recursively.`; +`AVOID — any photographic / photoreal look; any illustration / colored-pencil / gouache / painterly hand look; polychrome / RAINBOW / multicolour, cheerful Pixar palettes, neon, sunset glow, teal-and-orange grade, tropical aqua, or GREY water (the whole frame is monotone META BLUE #0668E1 — the water especially); motion blur of ANY kind, soft-focus, "blurred to suggest movement"; ANY laptop, computer, green MacBook, phone-in-jeffrey's-hand, screen/monitor showing content, tablet held by jeffrey; ANY matrix code, binary digits, falling 1s and 0s, glowing code; the drone-war blasts rendered as fire/flame/gore/debris/boats/weapons (clean distant WATER PLUMES only) or a giant tsunami wall of water; multiple pixsies tables / a cafe of tables (ONE focal table only); jeffrey centered as a hero or lone-leader; the selfie girls reacting, noticing, or looking at jeffrey/camera; any selfie girl in a costume / lewd / runway outfit; jeffrey in a tank top, costume, or formalwear; any readable real-world brand wordmark or logo anywhere; app / phone / Story UI chrome, HUD, menus, health bars, or text overlaid; any readable text or wordmark in the image; any second wakeboard rider; phones showing the surrounding scene recursively.`; // ── REAL-WORLD render mode (office beats — before/after the game) ──── // Photoreal + textured, the tangible real world vs the glossy game. @@ -141,7 +146,7 @@ const R_MARK = const R_GRAY_WOMAN = `THE WOMAN IN GREY — a real young woman jeffrey is quietly drawn to, PHOTOREAL: an all-grey office outfit (real grey knit / blouse + grey slacks or skirt), neat hair, calm, going about her work, NOT noticing him.`; const R_OFFICE = -`THE OFFICE — a real, drab open-plan corporate TECH OFFICE, photoreal + textured: real grey fabric cubicle partitions, real monitors + keyboards, fluorescent ceiling panels, worn grey carpet, real potted plants, a glass-walled meeting room with real smudges + reflections. Mounted on one wall as startup decor: a real SURFBOARD (real fibreglass sheen). Mundane, washed-out corporate.`; +`THE OFFICE — a real, drab open-plan corporate TECH OFFICE, photoreal + textured: real grey fabric cubicle partitions, real monitors + keyboards, fluorescent ceiling panels, worn grey carpet, real potted plants, a glass-walled meeting room with real smudges + reflections. Mounted on one wall as startup decor: a real SURFBOARD (real fibreglass sheen). The monitors + a glass-walled meeting-room screen glow a faint META BLUE (#0668E1); subtle Meta-blue corporate accents. Mundane, washed-out corporate.`; const R_PALETTE = `PALETTE — cool, desaturated, grey-graded but PHOTOGRAPHIC: real muted fluorescent-lit colour, jeffrey's burgundy hoodie the one warm note. No vivid saturation.`; const R_AVOID = @@ -152,11 +157,11 @@ const REALIZE = new Map([[OFFICE, R_OFFICE], [GRAY_WOMAN, R_GRAY_WOMAN], [MARK, // When the metaverse tears or assembles: NOT grey wireframe — FULL // super-psychedelic aesthetic.computer glitch. const PSYCH_MEDIUM = -`A SUPER-PSYCHEDELIC AESTHETIC-COMPUTER GLITCH RENDER — the metaverse tearing apart / assembling: an explosive KALEIDOSCOPIC riot of SATURATED RAINBOW colour, vivid generative-art glitch, glowing neon polygon shards, datamosh smears, scan-lines, prismatic chromatic-split fragments, luminous trails — the AC (aesthetic.computer / kidlisp) visual language: bold, trippy, maximalist, luminous. NOT grey, NOT monochrome, NOT a plain boring wireframe — FULL psychedelic colour. Crisp glowing geometry, no motion blur. Deadpan-cosmic.`; +`A META-BLUE DIGITAL GLITCH RENDER — the metaverse tearing apart / assembling, rendered ENTIRELY in META BLUE (#0668E1): glowing Meta-blue polygon shards, Meta-blue wireframe, datamosh smears, scan-lines and luminous Meta-blue trails over a deep blue-black void. MONOCHROME META BLUE — NOT polychrome, NOT rainbow, NOT multicolour, NOT psychedelic-colour; cold corporate Facebook/Meta blue only. Crisp glowing geometry, no motion blur. Deadpan, eerie, corporate-cosmic.`; const PSYCH_JEFFREY = `JEFFREY — recognizable from the references (brown hair, beard, his real face), his avatar caught mid-transition, breaking apart into / assembling out of glowing rainbow-coloured polygon shards.`; const PSYCH_AVOID = -`AVOID — any grey / monochrome / desaturated treatment here (this beat is FULL psychedelic colour); photoreal look; a plain dull wireframe; readable text / logos; UI / HUD overlays; motion blur.`; +`AVOID — ANY polychrome / rainbow / multicolour / psychedelic-colour (this beat is MONOCHROME META BLUE #0668E1 only); grey; photoreal look; readable text / logos; UI / HUD overlays; motion blur.`; const PSYCH_SET = new Set(["build-c", "build-d", "drop1-a", "outro-b", "outro-c", "outro-d"]); // ── per-beat story — office → metaverse → office, 8 sections × 2 ────── @@ -168,7 +173,7 @@ const SECTION_ORDER = [ ]; const COVER_VARIANT = -`COVER COMPOSITION — the locked amaythingra album-cover crop: jeffrey WAKEBOARDING across the flat grey metaverse water, framed UP CLOSE, full body large and near, knees bent, one arm out for balance, leaning into a confident carve that throws a sharp crisp fan of grey spray off the board's edge — a big open-mouthed delighted grin — carving THROUGH the scattered rows of stiff oblivious grey selfie-girl avatars (replicas of the office woman in grey), one or two caught mid-topple but still filming themselves. He rides as one element among the figures, equal eye-line, NOT a hero-center pose.`; +`COVER COMPOSITION — the locked amaythingra album-cover crop: jeffrey WAKEBOARDING across the flat Meta-blue metaverse water, framed UP CLOSE, full body large and near, knees bent, one arm out for balance, leaning into a confident carve that throws a sharp crisp fan of grey spray off the board's edge — a big open-mouthed delighted grin — carving THROUGH the scattered rows of stiff oblivious grey selfie-girl avatars (replicas of the office woman in grey), one or two caught mid-topple but still filming themselves. He rides as one element among the figures, equal eye-line, NOT a hero-center pose.`; const BEATS = { intro: { @@ -193,23 +198,23 @@ const BEATS = { }, drop1: { a: { blocks: [WORLD], body: -`SHOT — LOW ANGLE looking UP at jeffrey's avatar SPAWNING into being in a blank flat-grey loading void: glossy low-poly polygon shards + wireframe panels snapping together up his body, burgundy hoodie resolving, a faint wireframe horizon grid drawing in beneath. EMOTION — uncanny arrival, blinking wonder.` }, +`SHOT — LOW ANGLE looking UP at jeffrey's avatar SPAWNING into being in a blank flat-Meta-blue void: glossy low-poly polygon shards + wireframe panels snapping together up his body, burgundy hoodie resolving, a faint wireframe horizon grid drawing in beneath. EMOTION — uncanny arrival, blinking wonder.` }, b: { blocks: [WORLD], body: -`SHOT — FIRST-PERSON POV (jeffrey's FACE NOT SHOWN): looking down at his own newly-formed glossy plastic avatar hands turning over in front of the camera-eyes, then the grey beach + flat silver water rendering in around them. EMOTION — disoriented curiosity, "where am I".` }, +`SHOT — FIRST-PERSON POV (jeffrey's FACE NOT SHOWN): looking down at his own newly-formed glossy plastic avatar hands turning over in front of the camera-eyes, then the Meta-blue beach + flat silver water rendering in around them. EMOTION — disoriented curiosity, "where am I".` }, c: { blocks: [WORLD, GIRLS, DRONE], body: -`SHOT — WIDE ESTABLISHING, the full grey lagoon revealed: rows of the REPLICATED woman-in-grey selfie-girl avatars stretching back toward the horizon, every one filming herself; far out, the DRONE-WARS water-plumes erupting; a wakeboard materializing on the water near jeffrey (small in frame). EMOTION — dawning unease at the scale of it.` }, +`SHOT — WIDE ESTABLISHING, the full Meta-blue lagoon revealed: rows of the REPLICATED woman-in-grey selfie-girl avatars stretching back toward the horizon, every one filming herself; far out, the DRONE-WARS water-plumes erupting; a wakeboard materializing on the water near jeffrey (small in frame). EMOTION — dawning unease at the scale of it.` }, d: { blocks: [WORLD, GIRLS], body: `SHOT — CLOSE-UP, two of the identical grey selfie-girl replicas side by side filling the frame, same face, same pose, both filming themselves, utterly oblivious — the uncanny clone reveal. jeffrey's shoulder/edge just in frame, recoiling. EMOTION — creeping dread, recognition.` }, }, break: { a: { blocks: [WORLD], body: -`SHOT — CLOSE-UP, low over the water, on the WAKEBOARD (the office wall-surfboard, transformed) materializing on the flat grey surface with a faint spawn-shimmer, jeffrey's bare avatar feet stepping onto it, ripples spreading. EMOTION — tentative, figuring it out. tight on board + feet + water.` }, +`SHOT — CLOSE-UP, low over the water, on the WAKEBOARD (the office wall-surfboard, transformed) materializing on the flat Meta-blue surface with a faint spawn-shimmer, jeffrey's bare avatar feet stepping onto it, ripples spreading. EMOTION — tentative, figuring it out. tight on board + feet + water.` }, b: { blocks: [WORLD, GIRLS], body: `SHOT — MEDIUM on jeffrey rising to standing on the wakeboard, arms out, wobbling to find balance among the frozen grey selfie-girls. EMOTION — focused, a determined little grin.` }, c: { blocks: [WORLD, PIXSIES], body: -`SHOT — FIRST-PERSON POV (jeffrey's FACE NOT SHOWN): skimming forward low over the grey water on the board, the front tip of the wakeboard + his own hands in foreground, and far ahead a small WARM GLOW — the GROUP PIXSIES round table — drawing his eye across the cold lagoon. EMOTION — a flicker of hope, drawn toward warmth.` }, +`SHOT — FIRST-PERSON POV (jeffrey's FACE NOT SHOWN): skimming forward low over the Meta-blue water on the board, the front tip of the wakeboard + his own hands in foreground, and far ahead a small WARM GLOW — the GROUP PIXSIES round table — drawing his eye across the cold lagoon. EMOTION — a flicker of hope, drawn toward warmth.` }, d: { blocks: [WORLD, GIRLS, PIXSIES], body: -`SHOT — WIDE HIGH ANGLE (bird's-eye-ish) looking down: jeffrey small on his board amid the scattered ranks of frozen grey selfie-girls, the warm little pixsies island glowing far across the grey lagoon, his wake cutting a line between them. EMOTION — the choice laid out, the maze ahead.` }, +`SHOT — WIDE HIGH ANGLE (bird's-eye-ish) looking down: jeffrey small on his board amid the scattered ranks of frozen grey selfie-girls, the warm little pixsies island glowing far across the Meta-blue lagoon, his wake cutting a line between them. EMOTION — the choice laid out, the maze ahead.` }, }, vortex: { a: { blocks: [WORLD, GIRLS, DRONE], body: @@ -227,19 +232,19 @@ const BEATS = { b: { blocks: [WORLD, GIRLS], body: `SHOT — CLOSE-UP on ONE blonde grey replica caught mid-topple, water sloshing up past her shoulder, her face locked in the same blissed-out duck-lipped selfie expression, phone still raised, oblivious. EMOTION — deadpan absurdity. tight on her non-reacting face.` }, c: { blocks: [WORLD, PIXSIES], body: -`SHOT — MEDIUM, warm: jeffrey carves up to the GROUP PIXSIES round table and steps off the board, the diverse band turning to welcome him in as a peer, the cold grey lagoon behind, a warm candle-lit pocket here. EMOTION — relief, arrival, belonging.` }, +`SHOT — MEDIUM, warm: jeffrey carves up to the GROUP PIXSIES round table and steps off the board, the diverse band turning to welcome him in as a peer, the cold Meta-blue lagoon behind, a warm candle-lit pocket here. EMOTION — relief, arrival, belonging.` }, d: { blocks: [PIXSIES], body: `SHOT — CLOSE-UP, warm, candlelit: jeffrey and two of the pixsies leaning in together over a small glowing handmade gadget on the table, hands and faces close, real delight, the elder's piano + a kid just behind in soft focus. EMOTION — genuine human connection, the heart of the piece.` }, }, outro: { a: { blocks: [WORLD, PIXSIES, DRONE], body: -`SHOT — WIDE: jeffrey rides AWAY from the pixsies table back out onto the open grey lagoon, glancing back to wave; the pixsies wave warmly after him, the DRONE-WARS plumes receding small on the horizon. EMOTION — contented, a soft satisfied smile, leaving the warmth.` }, +`SHOT — WIDE: jeffrey rides AWAY from the pixsies table back out onto the open Meta-blue lagoon, glancing back to wave; the pixsies wave warmly after him, the DRONE-WARS plumes receding small on the horizon. EMOTION — contented, a soft satisfied smile, leaving the warmth.` }, b: { blocks: [WORLD], body: `SHOT — FIRST-PERSON POV (jeffrey's FACE NOT SHOWN): his own hands + the front of the wakeboard in foreground beginning to BREAK APART into floating low-poly polygon shards as the grey world glitches and dissolves around his viewpoint. EMOTION — the ride ending, reality tugging back.` }, c: { blocks: [], body: `SHOT — EXTREME CLOSE-UP on jeffrey's face, half of it already DISSOLVING into drifting polygon shards, eyes wide, the grey world gone to flat loading-void behind. EMOTION — bittersweet, being pulled out, a flicker of loss. face fills frame.` }, d: { blocks: [], body: -`SHOT — ABSTRACT WIDE: the entire grey metaverse SHATTERS into a rushing storm of low-poly polygon shards + pale portal light streaming/reversing past the camera toward a white-out. EMOTION — the violent yank back through, disorientation. no figures, pure transition.` }, +`SHOT — ABSTRACT WIDE: the entire Meta-blue metaverse SHATTERS into a rushing storm of low-poly polygon shards + pale portal light streaming/reversing past the camera toward a white-out. EMOTION — the violent yank back through, disorientation. no figures, pure transition.` }, }, resolve: { a: { blocks: [OFFICE, MARK], body: @@ -256,19 +261,22 @@ const BEATS = { function safeName(n) { return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } +const POV_NOTE = +`POV INTEGRITY — this is jeffrey's OWN first-person viewpoint (his eyes ARE the camera). The only part of jeffrey in frame is his own hands / forearms / body in the near foreground. ABSOLUTELY NO second jeffrey anywhere — no other man resembling jeffrey, no mirror / reflection / screen showing jeffrey's face. Any other people are clearly DIFFERENT individuals.`; function build(beat, tightCrop, psych) { const orient = LANDSCAPE ? `\n\n${LANDSCAPE_NOTE}` : (tightCrop ? "" : `\n\n${PORTRAIT_NOTE}`); - if (psych) { // degradation: super-psychedelic AC glitch - return [PSYCH_MEDIUM, PSYCH_JEFFREY, beat.body, PSYCH_AVOID].join("\n\n") + orient + "\n"; + const pov = beat.body.includes("FIRST-PERSON POV") ? `\n\n${POV_NOTE}` : ""; + if (psych) { // degradation: Meta-blue glitch + return [PSYCH_MEDIUM, PSYCH_JEFFREY, beat.body, PSYCH_AVOID].join("\n\n") + pov + orient + "\n"; } if (beat.blocks.includes(OFFICE)) { // real-world office: photoreal + textured const rb = beat.blocks.map((b) => REALIZE.get(b) || b); - return [R_MEDIUM, R_JEFFREY, ...rb, beat.body, R_PALETTE, R_AVOID].join("\n\n") + orient + "\n"; + return [R_MEDIUM, R_JEFFREY, ...rb, beat.body, R_PALETTE, R_AVOID].join("\n\n") + pov + orient + "\n"; } - return [MEDIUM, JEFFREY, ...beat.blocks, beat.body, PALETTE, AVOID].join("\n\n") // glossy grey metaverse - + orient + "\n"; + return [MEDIUM, JEFFREY, ...beat.blocks, beat.body, PALETTE, AVOID].join("\n\n") // glossy Meta-blue metaverse + + pov + orient + "\n"; } const apiKey = loadOpenAIKey(); diff --git a/pop/big-pictures/bin/preview-score-yt.mjs b/pop/big-pictures/bin/preview-score-yt.mjs index 939111ebc3..f02fc42fad 100644 --- a/pop/big-pictures/bin/preview-score-yt.mjs +++ b/pop/big-pictures/bin/preview-score-yt.mjs @@ -949,20 +949,25 @@ function _ewChannel(src, scale, tint) { // scaled single-channel copy about ce _ewChanC.globalCompositeOperation = "source-over"; return _ewChan; } -function drawEdgeWarp(c) { +// First-person POV beats — warp HARDER (it's jeffrey's own eyes). +const POV_SET = new Set(["intro-c", "build-c", "drop1-b", "break-c", "outro-b", "resolve-b"]); +function drawEdgeWarp(c, boost = 1) { // boost>1 = stronger warp (POV beats) const cx = W / 2, cy = H / 2, minDim = Math.min(W, H); + const div = 0.02 * boost; // chromatic divergence + const blurPx = Math.round(6 * boost); // edge blur + const innerR = minDim * Math.max(0.18, 0.46 - 0.16 * (boost - 1)); // ring pulls inward _ewEdgeC.setTransform(1, 0, 0, 1, 0, 0); _ewEdgeC.globalCompositeOperation = "source-over"; _ewEdgeC.globalAlpha = 1; _ewEdgeC.clearRect(0, 0, W, H); - _ewEdgeC.filter = "blur(6px)"; + _ewEdgeC.filter = `blur(${blurPx}px)`; _ewEdgeC.drawImage(_ewChannel(c.canvas, 1.0, "#00ff00"), 0, 0); // green base _ewEdgeC.globalCompositeOperation = "lighter"; - _ewEdgeC.drawImage(_ewChannel(c.canvas, 1.02, "#ff0000"), 0, 0); // red pushed OUT (subtle) - _ewEdgeC.drawImage(_ewChannel(c.canvas, 0.988, "#0000ff"), 0, 0); // blue pulled IN (subtle) + _ewEdgeC.drawImage(_ewChannel(c.canvas, 1 + div, "#ff0000"), 0, 0); // red pushed OUT + _ewEdgeC.drawImage(_ewChannel(c.canvas, 1 - div * 0.6, "#0000ff"), 0, 0); // blue pulled IN _ewEdgeC.filter = "none"; _ewEdgeC.globalCompositeOperation = "destination-out"; // keep only OUTER edge ring - const g = _ewEdgeC.createRadialGradient(cx, cy, minDim * 0.46, cx, cy, minDim * 0.66); + const g = _ewEdgeC.createRadialGradient(cx, cy, innerR, cx, cy, minDim * 0.66); g.addColorStop(0, "rgba(0,0,0,1)"); g.addColorStop(1, "rgba(0,0,0,0)"); _ewEdgeC.fillStyle = g; _ewEdgeC.fillRect(0, 0, W, H); _ewEdgeC.globalCompositeOperation = "source-over"; @@ -1008,7 +1013,7 @@ function renderPanel(c, idx, audioT, env, punch) { // 3: LEADED contrast — the inverse-mask multiply pass that keeps the // dark linework PUNCHY, like the lead between glass. drawLeadedContrast(c, PANEL_IMGS[idx], xform, faces, l_i, audioT); - drawEdgeWarp(c); // radial chromatic + geometric + blur edge warp + drawEdgeWarp(c, POV_SET.has(`${PANELS[idx].name}-${PANELS[idx].half}`) ? 2.4 : 1); // POV beats warp harder return xform; } diff --git a/pop/big-pictures/bin/preview-score.mjs b/pop/big-pictures/bin/preview-score.mjs index 8e44911676..23a27ab760 100644 --- a/pop/big-pictures/bin/preview-score.mjs +++ b/pop/big-pictures/bin/preview-score.mjs @@ -956,20 +956,24 @@ function _ewChannel(src, scale, tint) { _ewChanC.globalCompositeOperation = "source-over"; return _ewChan; } -function drawEdgeWarp(c) { +const POV_SET = new Set(["intro-c", "build-c", "drop1-b", "break-c", "outro-b", "resolve-b"]); +function drawEdgeWarp(c, boost = 1) { // boost>1 = stronger warp (POV beats) const cx = W / 2, cy = H / 2, minDim = Math.min(W, H); + const div = 0.02 * boost; + const blurPx = Math.round(6 * boost); + const innerR = minDim * Math.max(0.18, 0.46 - 0.16 * (boost - 1)); _ewEdgeC.setTransform(1, 0, 0, 1, 0, 0); _ewEdgeC.globalCompositeOperation = "source-over"; _ewEdgeC.globalAlpha = 1; _ewEdgeC.clearRect(0, 0, W, H); - _ewEdgeC.filter = "blur(6px)"; + _ewEdgeC.filter = `blur(${blurPx}px)`; _ewEdgeC.drawImage(_ewChannel(c.canvas, 1.0, "#00ff00"), 0, 0); _ewEdgeC.globalCompositeOperation = "lighter"; - _ewEdgeC.drawImage(_ewChannel(c.canvas, 1.02, "#ff0000"), 0, 0); - _ewEdgeC.drawImage(_ewChannel(c.canvas, 0.988, "#0000ff"), 0, 0); + _ewEdgeC.drawImage(_ewChannel(c.canvas, 1 + div, "#ff0000"), 0, 0); + _ewEdgeC.drawImage(_ewChannel(c.canvas, 1 - div * 0.6, "#0000ff"), 0, 0); _ewEdgeC.filter = "none"; _ewEdgeC.globalCompositeOperation = "destination-out"; - const g = _ewEdgeC.createRadialGradient(cx, cy, minDim * 0.46, cx, cy, minDim * 0.66); + const g = _ewEdgeC.createRadialGradient(cx, cy, innerR, cx, cy, minDim * 0.66); g.addColorStop(0, "rgba(0,0,0,1)"); g.addColorStop(1, "rgba(0,0,0,0)"); _ewEdgeC.fillStyle = g; _ewEdgeC.fillRect(0, 0, W, H); _ewEdgeC.globalCompositeOperation = "source-over"; @@ -1009,7 +1013,7 @@ function renderPanel(c, idx, audioT, env, punch) { // 3: LEADED contrast — the inverse-mask multiply pass that keeps the // dark linework PUNCHY, like the lead between glass. drawLeadedContrast(c, PANEL_IMGS[idx], xform, faces, l_i, audioT); - drawEdgeWarp(c); // radial chromatic + geometric + blur edge warp + drawEdgeWarp(c, POV_SET.has(`${PANELS[idx].name}-${PANELS[idx].half}`) ? 2.4 : 1); // POV beats warp harder return xform; } diff --git a/recap/audience/serpentine.mjs b/recap/audience/serpentine.mjs index 42f14cdd10..61b7682866 100644 --- a/recap/audience/serpentine.mjs +++ b/recap/audience/serpentine.mjs @@ -26,14 +26,14 @@ export const PALETTE = { export const audience = { name: "serpentine", handle: "@aesthetic.computer", - voice: { provider: "jeffrey", voice: "neutral:0" }, + voice: { provider: "jeffrey", voice: "neutral:0", speed: 1.08 }, // This is a spoken proposal, NOT a sung recap — keep the plain ~2:50 // narration. Disable the autotune/pitchsnap "sing" pass (which otherwise // stretches the timeline ~3× and rewrites words.json/segments.json). sing: false, - narration: `hey, i'm artist jeffrey alan scudder, and here's what i can't stop thinking about. as everyone races to build ai, computing is getting pulled into the data center. your laptop is becoming a thin client, renting its intelligence by the month. the personal computer you actually own is quietly disappearing. i think home computing needs a revolution at the operating system layer. so i built one. aesthetic dot computer boots a fifty dollar surplus laptop into a creative instrument in seven seconds — no desktop, no account, no cloud. it has its own little language, kidlisp, and it is a commons: everything is open, and it all boots from a single usb stick. twenty years ago the laptop orchestra proved a room of laptops could be a real instrument, but it stayed locked inside rich universities. on surplus hardware, that can be planetary, and held in common. language models are the force doing the enclosing, but they are also what lets one artist build a whole operating system. so here is my question: can a creative O.S. take ai in as a local, owned faculty, on your own terms, instead of a tenant of the cloud? and that's the question of aesthetic dot computer, version twenty twenty-six. thanks for listening.`, + narration: `Hey, I'm artist Jeffrey Alan Scudder, and here's what I can't stop thinking about. As everyone races to build higher-level intelligence services, computing is getting pulled into the data center. Your laptop is becoming a thin client, renting its intelligence by the month — and the personal computer you actually own is quietly disappearing. I think home computing needs a revolution at the operating system layer. Not another app — a different ground to stand on. So I built one. Aesthetic dot Computer boots a fifty-dollar surplus laptop straight into the notepat new media instrument. Notepat makes tunes you can type — so a song is something you write down and pass along, the way folk music has always moved. And here the picture, the code, and the music are one medium: you slide between drawing, programming, and playing without ever leaving the instrument. It's not a tool you use — you play it — and it all runs on an operating system built for nothing else. It is a commons. A tiny language, KidLisp, where a whole generative piece is six legible lines that live at their own web address. Twenty years ago the Princeton Laptop Orchestra proved a room of laptops could be a real instrument — but at fifteen hundred dollars a seat it stayed locked inside rich universities. Last year, when Windows ten ended, two hundred and forty million working computers became obsolete overnight. That's the raw material for a planetary orchestra — held in common, not thrown away. Three people with surplus laptops in a park can form a laptop orchestra. Language models are the force doing the enclosing — but they are also what lets one artist build and tend a whole operating system that used to take a company. So here is my question: can a creative o.s. take LLMs in as a local, owned faculty, on your own terms, instead of a tenant of the cloud? And that's the question of Aesthetic dot Computer, version twenty twenty-six. Thanks for listening.`, // Whisper renders these dictionary-style; rewrite the displayed subtitles. // Longer/multi-word fixes first so they win before single-word fixes. @@ -66,6 +66,7 @@ export const audience = { { name: "03_os_layer", marker: "operating system layer" }, { name: "04_creative_os", marker: "surplus laptop" }, { name: "05_commons", marker: "it is a commons" }, + { name: "05b_quote", marker: "three people" }, { name: "06_llms", marker: "language models are the force" }, { name: "07_poised", marker: "can a creative" }, { name: "09_outro", marker: "the question of" }, @@ -105,8 +106,8 @@ export const audience = {
01 · the creative os
-
a $50 laptop
→ an instrument
in 7 seconds
-
no desktop · no account · no cloud
+
notepat —
tunes you
can type
+
picture · code · music — one medium
`, @@ -119,6 +120,18 @@ export const audience = { `, + // 05b_quote is rendered in the widescreen cut as a paper-extract scene + // (the PLOrk PDF with a live highlight); this slide is only the portrait + // fallback. + "05b_quote": ` +
+
from the paper
+
+
three people
in a park
+
plork'ing the planet — a verbatim extract
+
+
`, + "06_llms": `
03 · what llms change
diff --git a/recap/bin/transcribe.mjs b/recap/bin/transcribe.mjs index 5b9985f71c..9b9cd3bc55 100755 --- a/recap/bin/transcribe.mjs +++ b/recap/bin/transcribe.mjs @@ -52,9 +52,14 @@ if (!force && existsSync(wordsPath) && existsSync(hashFile)) { } console.log(`→ whisper-cli · ${MP3}`); +// -dtw computes token-level timestamps via cross-attention dynamic time warping +// — far more accurate word start/stop boundaries than the default heuristic, +// which matters for the per-word karaoke captions. DTW is incompatible with +// flash-attention (which is on by default), so -nfa disables it. Must match the +// model preset (base.en). execFileSync( "whisper-cli", - ["-m", MODEL, "-f", MP3, "-ojf", "-of", `${ROOT}/out/recap`, "--max-len", "1", "-ml", "1", "-sow"], + ["-m", MODEL, "-f", MP3, "-ojf", "-of", `${ROOT}/out/recap`, "--max-len", "1", "-ml", "1", "-sow", "-nfa", "-dtw", "base.en"], { stdio: ["ignore", "ignore", "inherit"] }, ); diff --git a/recap/bin/tts.mjs b/recap/bin/tts.mjs index b02736d10b..ed64358b31 100755 --- a/recap/bin/tts.mjs +++ b/recap/bin/tts.mjs @@ -34,6 +34,9 @@ const body = { from: audience.narration, provider: audience.voice.provider, voice: audience.voice.voice, + // ElevenLabs jeffrey voice_settings.speed (0.7-1.2; say.js clamps). Default + // 1.0 reads slow for a spoken proposal; audience configs can override. + ...(typeof audience.voice.speed === "number" ? { speed: audience.voice.speed } : {}), }; const inputHash = createHash("sha256") diff --git a/slab/bin/slab-audio-setup.swift b/slab/bin/slab-audio-setup.swift new file mode 100755 index 0000000000..fdc9076b72 --- /dev/null +++ b/slab/bin/slab-audio-setup.swift @@ -0,0 +1,166 @@ +#!/usr/bin/env swift +// slab-audio-setup.swift — build the Core Audio plumbing slab-call-record needs +// to capture BOTH sides of a FaceTime / Zoom / etc. call. +// +// Creates (idempotently — destroys + recreates by UID): +// • Aggregate device "Meeting" = + BlackHole 2ch (the recorder reads this) +// • Multi-Output device "Meeting Out" = + BlackHole 2ch (so you still HEAR the call) +// +// During a call: set System Output → "Meeting Out", then hit Start call in the +// slab menubar. ffmpeg captures "Meeting": your mic on the low channel(s), the +// far end (looped back through BlackHole) on the high channel(s). +// +// Run: swift slab/bin/slab-audio-setup.swift (no sudo needed) + +import CoreAudio +import Foundation + +let MEETING_UID = "computer.aesthetic.slab.meeting" +let MEETING_OUT_UID = "computer.aesthetic.slab.meeting-out" +let BLACKHOLE_MATCH = "BlackHole" + +// MARK: - Core Audio property helpers + +func addr(_ selector: AudioObjectPropertySelector, + _ scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, + _ element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain) +-> AudioObjectPropertyAddress { + AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element) +} + +func allDeviceIDs() -> [AudioObjectID] { + var a = addr(kAudioHardwarePropertyDevices) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &a, 0, nil, &size) == noErr else { return [] } + let count = Int(size) / MemoryLayout.size + var ids = [AudioObjectID](repeating: 0, count: count) + guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &a, 0, nil, &size, &ids) == noErr else { return [] } + return ids +} + +func stringProp(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String? { + var a = addr(selector) + var size = UInt32(MemoryLayout.size) + var cf: CFString? = nil + let st = withUnsafeMutablePointer(to: &cf) { + AudioObjectGetPropertyData(id, &a, 0, nil, &size, $0) + } + guard st == noErr, let s = cf else { return nil } + return s as String +} + +func deviceName(_ id: AudioObjectID) -> String { stringProp(id, kAudioObjectPropertyName) ?? "?" } +func deviceUID(_ id: AudioObjectID) -> String? { stringProp(id, kAudioDevicePropertyDeviceUID) } + +// Channel count for a scope (input or output). +func channelCount(_ id: AudioObjectID, scope: AudioObjectPropertyScope) -> Int { + var a = addr(kAudioDevicePropertyStreamConfiguration, scope) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(id, &a, 0, nil, &size) == noErr, size > 0 else { return 0 } + let bl = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout.alignment) + defer { bl.deallocate() } + guard AudioObjectGetPropertyData(id, &a, 0, nil, &size, bl) == noErr else { return 0 } + let abl = UnsafeMutableAudioBufferListPointer(bl.assumingMemoryBound(to: AudioBufferList.self)) + return abl.reduce(0) { $0 + Int($1.mNumberChannels) } +} + +func hasInput(_ id: AudioObjectID) -> Bool { channelCount(id, scope: kAudioObjectPropertyScopeInput) > 0 } +func hasOutput(_ id: AudioObjectID) -> Bool { channelCount(id, scope: kAudioObjectPropertyScopeOutput) > 0 } + +func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID? { + var a = addr(selector) + var id = AudioObjectID(0) + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &a, 0, nil, &size, &id) == noErr, id != 0 else { return nil } + return id +} + +// MARK: - Create / destroy aggregates + +func destroyByUID(_ uid: String) { + for id in allDeviceIDs() where deviceUID(id) == uid { + let st = AudioHardwareDestroyAggregateDevice(id) + if st == noErr { print(" · removed existing \(uid)") } + } +} + +func createAggregate(name: String, uid: String, subUIDs: [String], masterUID: String, stacked: Bool) -> Bool { + let subList = subUIDs.map { [kAudioSubDeviceUIDKey: $0] } + var desc: [String: Any] = [ + kAudioAggregateDeviceNameKey as String: name, + kAudioAggregateDeviceUIDKey as String: uid, + kAudioAggregateDeviceSubDeviceListKey as String: subList, + kAudioAggregateDeviceMasterSubDeviceKey as String: masterUID, + kAudioAggregateDeviceIsPrivateKey as String: 0, // visible in Audio MIDI Setup + ] + if stacked { desc[kAudioAggregateDeviceIsStackedKey as String] = 1 } + var newID = AudioObjectID(0) + let st = AudioHardwareCreateAggregateDevice(desc as CFDictionary, &newID) + if st != noErr { + print(" ✗ failed to create \(name) (OSStatus \(st))") + return false + } + print(" ✓ created \(name) [\(uid)] from \(subUIDs.joined(separator: " + "))") + return true +} + +// MARK: - Main + +print("slab-audio-setup — wiring call-recording devices\n") + +let ids = allDeviceIDs() + +// Find BlackHole (must be installed first). +guard let blackhole = ids.first(where: { deviceName($0).localizedCaseInsensitiveContains(BLACKHOLE_MATCH) }), + let blackholeUID = deviceUID(blackhole) else { + print("✗ BlackHole not found. Install it first:") + print(" brew install --cask blackhole-2ch") + exit(1) +} +print("found BlackHole: \(deviceName(blackhole)) [\(blackholeUID)]") + +// Pick the mic: prefer the system default input, but never BlackHole itself. +let micCandidate = defaultDevice(kAudioHardwarePropertyDefaultInputDevice) +let mic: AudioObjectID? = { + if let m = micCandidate, m != blackhole, hasInput(m) { return m } + return ids.first(where: { $0 != blackhole && hasInput($0) }) +}() +guard let micID = mic, let micUID = deviceUID(micID) else { + print("✗ no usable microphone found"); exit(1) +} +print("found mic: \(deviceName(micID)) [\(micUID)]") + +// Pick speakers: system default output, never BlackHole. +let spkCandidate = defaultDevice(kAudioHardwarePropertyDefaultOutputDevice) +let spk: AudioObjectID? = { + if let s = spkCandidate, s != blackhole, hasOutput(s) { return s } + return ids.first(where: { $0 != blackhole && hasOutput($0) }) +}() +guard let spkID = spk, let spkUID = deviceUID(spkID) else { + print("✗ no usable speakers found"); exit(1) +} +print("found speakers: \(deviceName(spkID)) [\(spkUID)]\n") + +// Aggregate "Meeting" — mic is master (clock source), BlackHole second. +// Channel order follows sub-device order: mic channels first, BlackHole after. +print("Aggregate \"Meeting\" (recorder input):") +destroyByUID(MEETING_UID) +let agg = createAggregate(name: "Meeting", uid: MEETING_UID, + subUIDs: [micUID, blackholeUID], masterUID: micUID, stacked: false) + +// Multi-Output "Meeting Out" — speakers master so you still hear, BlackHole taps the audio. +print("\nMulti-Output \"Meeting Out\" (call playback + tap):") +destroyByUID(MEETING_OUT_UID) +let mo = createAggregate(name: "Meeting Out", uid: MEETING_OUT_UID, + subUIDs: [spkUID, blackholeUID], masterUID: spkUID, stacked: true) + +print("") +if agg && mo { + print("✓ done. To record a call:") + print(" 1. System Settings → Sound → Output → \"Meeting Out\"") + print(" 2. Start the FaceTime call, hit Start call in the slab menubar") + print(" 3. Stop call when done — far end is on the high channel(s)") + exit(0) +} else { + exit(1) +} diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index b46c8136d9..a4e0ea3798 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -357,6 +357,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// the space sensitivity so the two horizontal modes feel the /// same under the finger — only the modifier changes the target. private static let echoSensitivityPerPoint: Float = 1.0 / 200.0 + /// [temp] Echo half of the horizontal fx axis (swipe RIGHT) is + /// disabled for now. The X axis stays bipolar in code but the + /// right side is clamped out, so only the left/space (reverb) + /// half responds. Flip back to `true` to restore echo. + private static let fxEchoEnabled = false func applicationDidFinishLaunching(_ notification: Notification) { debugLog("applicationDidFinishLaunching pid=\(ProcessInfo.processInfo.processIdentifier)") @@ -3092,8 +3097,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let xSens: Float = event.modifierFlags.contains(.option) ? Self.echoSensitivityPerPoint * Float(0.3) : Self.echoSensitivityPerPoint - fxX = max(Float(-1), min(Float(1), fxX + dx * xSens)) - echoAmount = max(Float(0), fxX) + // [temp] When echo is disabled, cap the axis at 0 so a RIGHT + // swipe can't wind up any echo (and the puck won't drift into a + // dead right half); the LEFT/space half still runs normally. + let fxMax: Float = Self.fxEchoEnabled ? 1 : 0 + fxX = max(Float(-1), min(fxMax, fxX + dx * xSens)) + echoAmount = Self.fxEchoEnabled ? max(Float(0), fxX) : 0 spaceAmount = max(Float(0), -fxX) cancelFxRelease() // Clamp the accumulator to ±bendRange so it can't wind up past the diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 4c5aabebf8..83d4543217 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -864,6 +864,9 @@ final class MenuBandController { // range determines audible cap). let result = amount.finiteMagnitudeMultiply(8192) let value = Int16(result.safeClamped(Float(Int16.min), Float(Int16.max))) + // Remember the current bend so a freshly-allocated round-robin + // channel can be set to it at noteOn time (see currentBendValue). + currentBendValue = value // Channels currently sounding via either input path. Use // sets to dedupe — same channel can host both a tap and a // keyboard note when the user is using both at once. @@ -1527,6 +1530,20 @@ final class MenuBandController { // play and ~7 doppler retriggers with their growing intervals. private var melodicVoiceCursor: UInt8 = 0 // round-robin 0..3 for live private var dopplerVoiceCursor: UInt8 = 4 // round-robin 4..7 for doppler retriggers + /// Last pitch-bend value pushed via `setBend` (centered = 0). Each + /// freshly-allocated round-robin channel is set to this at noteOn so + /// a new note never inherits a STALE bend left on that channel by an + /// earlier gesture — otherwise repeatedly pressing one key cycles + /// through 0…3 and audibly "oscillates" in pitch. When a bend is + /// active the new note correctly joins it; when idle it's centered. + private var currentBendValue: Int16 = 0 + /// Center the pitch bend on a channel about to host a fresh note, + /// applying the current bend (0 when idle). No-op in MIDI mode (the + /// external DAW owns channel state there). + private func primeChannelBend(_ channel: UInt8) { + guard !midiMode else { return } + synth.sendPitchBend(value: currentBendValue, channel: channel) + } /// Linger tail length — how long after key release we hold the /// note before sending the cleanup noteOff. Long enough that the @@ -1599,6 +1616,9 @@ final class MenuBandController { // their pan into subsequent taps on the same channel. if !midiMode { synth.setPan(pan, channel: synthCh) + // Prime the channel's bend (current bend, or centered when idle) + // so this tap can't inherit a stale bend on this round-robin slot. + primeChannelBend(synthCh) synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } midiNoteOn(midiNote, velocity: velocity, channel: midiCh) @@ -1885,6 +1905,7 @@ final class MenuBandController { DispatchQueue.main.asyncAfter(deadline: .now() + triggerAt) { [weak self] in guard let self = self else { return } let ch = self.nextDopplerChannel() + self.primeChannelBend(ch) if !self.midiMode { self.synth.noteOn(midiNote, velocity: v, channel: ch) } self.midiNoteOn(midiNote, velocity: v, channel: midiChannel) DispatchQueue.main.asyncAfter(deadline: .now() + Self.dopplerNoteOffWindow) { [weak self] in @@ -2434,6 +2455,10 @@ final class MenuBandController { // new note attacks at full volume (see startTapNote for // the parallel call site / rationale). cancelLingerFade(channel: synthCh) + // Center (or current-bend) this channel BEFORE the noteOn so it + // can't inherit a stale bend left by an earlier gesture on the + // same round-robin slot. + primeChannelBend(synthCh) midi.sendCC(10, value: pan, channel: 0) if !midiMode { synth.noteOn(note, velocity: 100, channel: synthCh) } midiNoteOn(note, velocity: 100, channel: 0) diff --git a/system/public/papers.aesthetic.computer/deadlines/index.html b/system/public/papers.aesthetic.computer/deadlines/index.html index 40157d51ee..e9f31b0aa1 100644 --- a/system/public/papers.aesthetic.computer/deadlines/index.html +++ b/system/public/papers.aesthetic.computer/deadlines/index.html @@ -212,6 +212,7 @@ .row .notes .status.accepted { color: var(--cyan); border-color: var(--cyan); } .row .notes .status.submitted { color: var(--green); border-color: var(--green); } .row .notes .status.new { color: var(--dim); } + .row .notes .status.declined { color: var(--dim); border-color: var(--dim); } .row .notes a { color: var(--purple); } .row .notes .draft-link { @@ -499,10 +500,10 @@ deadline: '2026-06-07', deadlineExtra: 'midnight BST', conference: 'Sep 2026 – Mar 2027 · low-residency, 3 London weekends', - status: 'candidate', - statusLabel: 'Candidate', - draftPath: 'serpentine-fae-2026/DRAFT.md', - notes: 'DRAFT v1 ready — spine is the voice thread ("A Voice of My Own"). £10,000 award + travel/accommodation for the London intensives. Inaugural cohort of four practitioners working at the intersection of art and advanced technologies; early-stage creative research grounded in process and dialogue. Submitted via an Airtable form; two selection rounds (Serpentine Arts Tech shortlist, then external jury). International applicants welcome if they can attend the three London weekends.', + status: 'go', + statusLabel: 'Active', + draftPath: 'serpentine-fae-2026/FORM-ANSWERS.md', + notes: 'Form-ready — spine settled on re-founding personal computing as a commons / creative OS + laptop orchestra (FORM-ANSWERS.md is the final field-mapped paste sheet; the older voice-thread DRAFT.md is superseded). £10,000 award + travel/accommodation for the London intensives. Inaugural cohort of four practitioners working at the intersection of art and advanced technologies; early-stage creative research grounded in process and dialogue. Submitted via an Airtable form; two selection rounds (Serpentine Arts Tech shortlist, then external jury). International applicants welcome if they can attend the three London weekends. Blocking: pick 2 references, assemble portfolio PDF (≤10pp), optional ≤3-min video, open the live form to confirm field order/caps.', }, { section: 'open', @@ -547,16 +548,16 @@ }, { - section: 'open', + section: 'decided', venue: 'CAC Individual Artist Fellowship — Legacy', venueUrl: 'https://performancepractice.la/cac-iaf/', track: 'Statewide CAC, via Los Angeles Performance Practice (Fia, sourced 2026-05-18, ID-confirmed 2026-05-19)', deadline: '2026-06-06', deadlineExtra: '23:59 PT (LA County)', conference: '1-year unrestricted fellowship · CA residents (LA Co. via LAPP)', - status: 'candidate', - statusLabel: 'Candidate', - notes: 'DRAFT v0 ready. $50,000 unrestricted (Legacy tier; same call also has Emerging $5k and Established $10k). Eligibility: 18+, California resident ≥ 2 years, primarily reside + work in LA County (via LAPP), not previously awarded Legacy statewide. Unrestricted — for sustained practice, not a project pitch. Five narrative answers, work-sample plan, and letter-of-support plan in vault cac-iaf-2026/DRAFT.md; canonical 2026 guideline PDF cached locally. Letter of support (1 page, colleague, uploaded directly by writer) is blocking submit.', + status: 'declined', + statusLabel: 'Declined', + notes: 'Not pursuing — declined 2026-06-04. DRAFT v0 stays in vault cac-iaf-2026/DRAFT.md for a future call. $50,000 unrestricted (Legacy tier; same call also has Emerging $5k and Established $10k). Eligibility: 18+, California resident ≥ 2 years, primarily reside + work in LA County (via LAPP), not previously awarded Legacy statewide. Unrestricted — for sustained practice, not a project pitch. Letter of support (1 page, colleague, uploaded directly by writer) was the blocking item.', }, { section: 'open',