Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

oven+recap: remote recap-build pipeline driven by audience configs

Oven side:
- recap-builder.mjs builds a recap mp4 from a single audience config (e.g. jeffrey-73h-2026-05-02). Same job/log/SSE shape as native-builder.mjs / papers-builder.mjs so the dashboard can render it without special-casing.
- recap-git-poller.mjs auto-triggers a build when recap/audience/*.mjs changes land on origin/main.
- server.mjs adds /recap-build endpoints (list, get, SSE stream, mp4 download, manual POST trigger, cancel) gated by OS_BUILD_ADMIN_KEY, and starts the recap git poller alongside the native + papers ones.
- deploy.fish scps the two new builder files to /opt/oven/.

Recap pipeline:
- New audience configs: jeffrey-24h-2026-05-01.mjs, jeffrey-73h-2026-05-02.mjs.
- New tooling: chat-fetch.mjs, gen-photos.mjs, test-slide.mjs.
- scout.mjs touch-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+2372 -3
+2
oven/deploy.fish
··· 219 219 scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/server.mjs root@$DROPLET_IP:/opt/oven/ 220 220 scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/baker.mjs root@$DROPLET_IP:/opt/oven/ 221 221 scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/grabber.mjs root@$DROPLET_IP:/opt/oven/ 222 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/recap-builder.mjs root@$DROPLET_IP:/opt/oven/ 223 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/recap-git-poller.mjs root@$DROPLET_IP:/opt/oven/ 222 224 scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SERVICE_ENV root@$DROPLET_IP:/opt/oven/.env 223 225 224 226 # Update .env for production
+288
oven/recap-builder.mjs
··· 1 + // recap-builder.mjs — Auto-build a recap mp4 from an audience config. 2 + // 3 + // Triggered via POST /recap-build (with `audience` in body) or by 4 + // recap-git-poller.mjs when recap/audience/*.mjs changes on main. 5 + // Runs `fish recap/pipeline.fish <audience>` inside the git clone at 6 + // GIT_REPO_DIR (shared with papers-builder + native-builder). 7 + // 8 + // Mirrors papers-builder.mjs structure: queue + active job + log capture 9 + // + SSE streaming + cancel. The pipeline emits "▸ N/8 <stage>" markers 10 + // which we parse into percent. 11 + // 12 + // On success, the resulting mp4 is copied to RECAP_OUT_DIR/<audience>.mp4 13 + // (default /opt/oven/recap-out/) and exposed via GET /recap-build/<id>/mp4. 14 + // We do NOT commit mp4s back to git (~30 MB each — would bloat history). 15 + // Future: upload to DO Spaces and commit a small manifest entry. 16 + 17 + import { promises as fs } from "fs"; 18 + import path from "path"; 19 + import { randomUUID } from "crypto"; 20 + import { spawn } from "child_process"; 21 + 22 + const MAX_RECENT_JOBS = 10; 23 + const MAX_LOG_LINES = 4000; 24 + 25 + const GIT_REPO_DIR = 26 + process.env.NATIVE_GIT_DIR || "/opt/oven/native-git"; 27 + const RECAP_OUT_DIR = 28 + process.env.RECAP_OUT_DIR || "/opt/oven/recap-out"; 29 + 30 + // Pipeline stages, in order. Each "▸ N/8 <stage>" line bumps to the next 31 + // stage. Percent is interpolated within each stage. 32 + // Keys must match (as prefix in either direction) the first word after the 33 + // "▸ N/8 " marker emitted by recap/pipeline.fish. So for "▸ 2/8 transcribe 34 + // + align" the key must be "transcribe" (or a shorter prefix of it). For 35 + // "▸ 3/8 jeffrey-photos ..." the key is "jeffrey" (since 36 + // "jeffrey-photos".startsWith("jeffrey")). 37 + const STAGES = [ 38 + { key: "tts", label: "tts", weight: 5 }, 39 + { key: "transcribe", label: "transcribe", weight: 5 }, 40 + { key: "jeffrey", label: "photos", weight: 55 }, // dominant — ~3min × 13 41 + { key: "chat", label: "chat-fetch", weight: 1 }, 42 + { key: "screen", label: "screenshots", weight: 1 }, 43 + { key: "scout", label: "scout", weight: 2 }, 44 + { key: "slides", label: "slides", weight: 12 }, 45 + { key: "subtitles", label: "subtitles", weight: 4 }, 46 + { key: "waltz", label: "waltz", weight: 5 }, 47 + { key: "compose", label: "compose", weight: 10 }, 48 + ]; 49 + const TOTAL_WEIGHT = STAGES.reduce((s, x) => s + x.weight, 0); 50 + 51 + const jobs = new Map(); 52 + const jobOrder = []; 53 + let activeJobId = null; 54 + 55 + function nowISO() { return new Date().toISOString(); } 56 + function stripAnsi(s) { return String(s || "").replace(/\[[0-9;]*m/g, ""); } 57 + 58 + function addLogLine(job, stream, line) { 59 + const clean = stripAnsi(line).replace(/\r/g, "").trimEnd(); 60 + if (!clean) return; 61 + job.logs.push({ ts: nowISO(), stream, line: clean }); 62 + if (job.logs.length > MAX_LOG_LINES) 63 + job.logs.splice(0, job.logs.length - MAX_LOG_LINES); 64 + job.updatedAt = nowISO(); 65 + 66 + // Stage transitions: pipeline.fish prints "▸ <step>/8 <name>" or 67 + // "▸ <step>.<sub>/8 <name>" between phases. Match the leading number. 68 + const stageMatch = clean.match(/▸\s+\d+(?:\.\d+)?\/\d+\s+([a-z][\w-]*)/i); 69 + if (stageMatch) { 70 + const name = stageMatch[1].toLowerCase(); 71 + const idx = STAGES.findIndex((s) => name.startsWith(s.key) || s.key.startsWith(name)); 72 + if (idx >= 0) { 73 + job.stageIdx = idx; 74 + job.stage = STAGES[idx].label; 75 + // Percent up to the start of this stage 76 + const cumWeight = STAGES.slice(0, idx).reduce((s, x) => s + x.weight, 0); 77 + job.percent = Math.min(99, Math.round((cumWeight / TOTAL_WEIGHT) * 100)); 78 + } 79 + } 80 + 81 + // Per-stage progress hints (best-effort) 82 + if (job.stage === "photos" && /^\s*✓\s+\S+\.png/.test(clean)) { 83 + // Each photo done bumps a small amount within the photos band 84 + job.photosDone = (job.photosDone || 0) + 1; 85 + } else if (job.stage === "slides" && /^\s*✓\s+\d{2}_/.test(clean)) { 86 + job.slidesDone = (job.slidesDone || 0) + 1; 87 + } 88 + 89 + // Final success marker from pipeline.fish 90 + if (clean.includes("━━━ done ·")) { 91 + job.stage = "done"; 92 + job.percent = 100; 93 + } 94 + } 95 + 96 + function makeSnapshot(job, opts = {}) { 97 + const { includeLogs = false, tail = 200 } = opts; 98 + const snap = { 99 + id: job.id, 100 + audience: job.audience, 101 + ref: job.ref, 102 + status: job.status, 103 + stage: job.stage, 104 + percent: job.percent, 105 + createdAt: job.createdAt, 106 + startedAt: job.startedAt, 107 + updatedAt: job.updatedAt, 108 + finishedAt: job.finishedAt, 109 + exitCode: job.exitCode, 110 + error: job.error, 111 + mp4Path: job.mp4Path, 112 + mp4Bytes: job.mp4Bytes, 113 + logCount: job.logs.length, 114 + elapsedMs: job.startedAt 115 + ? (job.finishedAt ? Date.parse(job.finishedAt) : Date.now()) - 116 + Date.parse(job.startedAt) 117 + : 0, 118 + }; 119 + if (includeLogs) { 120 + const start = Math.max(0, job.logs.length - Math.max(0, tail)); 121 + snap.logs = job.logs.slice(start); 122 + } 123 + return snap; 124 + } 125 + 126 + function wireStream(job, proc, streamName) { 127 + let pending = ""; 128 + const s = streamName === "stdout" ? proc.stdout : proc.stderr; 129 + s.on("data", (chunk) => { 130 + pending += chunk.toString(); 131 + let idx; 132 + while ((idx = pending.indexOf("\n")) >= 0) { 133 + addLogLine(job, streamName, pending.slice(0, idx)); 134 + pending = pending.slice(idx + 1); 135 + } 136 + }); 137 + s.on("end", () => { if (pending) addLogLine(job, streamName, pending); }); 138 + } 139 + 140 + async function copyOutputMp4(job) { 141 + const src = path.join(GIT_REPO_DIR, "recap", "out", "recap.mp4"); 142 + await fs.mkdir(RECAP_OUT_DIR, { recursive: true }); 143 + const dst = path.join(RECAP_OUT_DIR, `${job.audience}.mp4`); 144 + await fs.copyFile(src, dst); 145 + const stat = await fs.stat(dst); 146 + job.mp4Path = dst; 147 + job.mp4Bytes = stat.size; 148 + addLogLine(job, "stdout", ` OUT: copied recap.mp4 → ${dst} (${(stat.size / 1024 / 1024).toFixed(1)} MB)`); 149 + } 150 + 151 + async function runRecapJob(job) { 152 + try { 153 + job.status = "running"; 154 + job.startedAt = nowISO(); 155 + job.percent = 0; 156 + job.stage = "starting"; 157 + job.stageIdx = -1; 158 + 159 + const cwd = path.join(GIT_REPO_DIR, "recap"); 160 + addLogLine(job, "stdout", `▸ recap pipeline · audience=${job.audience}`); 161 + 162 + await new Promise((resolve, reject) => { 163 + const proc = spawn("fish", ["./pipeline.fish", job.audience], { 164 + cwd, 165 + env: { 166 + ...process.env, 167 + TERM: "dumb", 168 + CLICOLOR: "0", 169 + FORCE_COLOR: "0", 170 + }, 171 + stdio: ["ignore", "pipe", "pipe"], 172 + }); 173 + job.process = proc; 174 + job.pid = proc.pid; 175 + wireStream(job, proc, "stdout"); 176 + wireStream(job, proc, "stderr"); 177 + proc.on("error", reject); 178 + proc.on("close", (code) => { 179 + job.process = null; 180 + job.exitCode = code; 181 + if (code !== 0) reject(new Error(`recap pipeline failed (exit ${code})`)); 182 + else resolve(); 183 + }); 184 + }); 185 + 186 + // Pipeline succeeded — copy the mp4 out of the git clone 187 + try { 188 + await copyOutputMp4(job); 189 + } catch (copyErr) { 190 + addLogLine(job, "stderr", ` OUT: copy failed: ${copyErr.message}`); 191 + } 192 + 193 + job.status = "success"; 194 + job.stage = "done"; 195 + job.percent = 100; 196 + job.finishedAt = nowISO(); 197 + } catch (err) { 198 + job.finishedAt = nowISO(); 199 + job.status = job.status === "cancelled" ? "cancelled" : "failed"; 200 + job.stage = job.status; 201 + job.error = err.message || String(err); 202 + } finally { 203 + if (activeJobId === job.id) activeJobId = null; 204 + } 205 + } 206 + 207 + export async function startRecapBuild(options = {}) { 208 + if (!options.audience || !/^[\w.-]+$/.test(options.audience)) { 209 + const err = new Error(`recap-build: missing or invalid audience name '${options.audience}'`); 210 + err.code = "RECAP_BUILD_BAD_AUDIENCE"; 211 + throw err; 212 + } 213 + if (activeJobId) { 214 + const err = new Error(`Recap build already running: ${activeJobId}`); 215 + err.code = "RECAP_BUILD_BUSY"; 216 + err.activeJobId = activeJobId; 217 + throw err; 218 + } 219 + 220 + const id = randomUUID().slice(0, 10); 221 + const job = { 222 + id, 223 + audience: options.audience, 224 + ref: options.ref || "unknown", 225 + status: "queued", 226 + stage: "queued", 227 + stageIdx: -1, 228 + percent: 0, 229 + createdAt: nowISO(), 230 + startedAt: null, 231 + updatedAt: nowISO(), 232 + finishedAt: null, 233 + pid: null, 234 + process: null, 235 + exitCode: null, 236 + error: null, 237 + mp4Path: null, 238 + mp4Bytes: null, 239 + logs: [], 240 + }; 241 + 242 + jobs.set(id, job); 243 + jobOrder.unshift(id); 244 + while (jobOrder.length > MAX_RECENT_JOBS) { 245 + const old = jobOrder.pop(); 246 + if (old !== activeJobId) jobs.delete(old); 247 + } 248 + activeJobId = id; 249 + runRecapJob(job).catch(() => {}); 250 + return makeSnapshot(job); 251 + } 252 + 253 + export function getRecapBuild(jobId, opts = {}) { 254 + const job = jobs.get(jobId); 255 + return job ? makeSnapshot(job, opts) : null; 256 + } 257 + 258 + export function getRecapBuildsSummary() { 259 + return { 260 + activeJobId, 261 + active: activeJobId ? makeSnapshot(jobs.get(activeJobId)) : null, 262 + recent: jobOrder 263 + .map((id) => jobs.get(id)) 264 + .filter(Boolean) 265 + .map((j) => makeSnapshot(j)), 266 + }; 267 + } 268 + 269 + export function cancelRecapBuild(jobId) { 270 + const job = jobs.get(jobId); 271 + if (!job) return { ok: false, error: "not found" }; 272 + if (job.status !== "running" || !job.process) 273 + return { ok: false, error: "not running" }; 274 + try { 275 + job.process.kill("SIGTERM"); 276 + job.status = "cancelled"; 277 + return { ok: true }; 278 + } catch (err) { 279 + return { ok: false, error: err.message }; 280 + } 281 + } 282 + 283 + // Returns the on-disk mp4 path for a successful job, or null. 284 + export function getRecapMp4Path(jobId) { 285 + const job = jobs.get(jobId); 286 + if (!job || job.status !== "success" || !job.mp4Path) return null; 287 + return job.mp4Path; 288 + }
+170
oven/recap-git-poller.mjs
··· 1 + // recap-git-poller.mjs — polls git for recap/audience/*.mjs changes, 2 + // auto-triggers recap mp4 builds via startRecapBuild. 3 + // 4 + // Runs inside the oven server. Every POLL_INTERVAL_MS (default 90s), 5 + // fetches origin/main and checks if any audience config changed since 6 + // the last successful build. If so, pulls and triggers startRecapBuild 7 + // for each changed audience (one at a time — recap-builder serializes). 8 + // 9 + // Shares the git clone at GIT_REPO_DIR with native-git-poller and 10 + // papers-git-poller. Uses .last-recap-built-hash to track state. 11 + 12 + import { execFile } from "child_process"; 13 + import { promises as fs } from "fs"; 14 + import path from "path"; 15 + 16 + const POLL_INTERVAL_MS = parseInt(process.env.RECAP_POLL_INTERVAL_MS || "90000", 10); 17 + const GIT_REPO_DIR = process.env.NATIVE_GIT_DIR || "/opt/oven/native-git"; 18 + const BRANCH = process.env.NATIVE_GIT_BRANCH || "main"; 19 + const HASH_FILE = path.join(GIT_REPO_DIR, ".last-recap-built-hash"); 20 + 21 + // Paths whose changes trigger a recap rebuild. We watch only audience 22 + // configs — changes to bin/* or pipeline.fish would force rebuilds of 23 + // every audience and aren't worth the cost (~$5 per cut). 24 + const TRIGGER_PREFIX = "recap/audience/"; 25 + const SOURCE_EXTS = [".mjs"]; 26 + 27 + let polling = false; 28 + let timer = null; 29 + let startBuildFn = null; 30 + let logFn = (level, icon, msg) => console.log(`[recap-git-poller] ${msg}`); 31 + 32 + function git(args, cwd = GIT_REPO_DIR) { 33 + return new Promise((resolve, reject) => { 34 + execFile("git", args, { cwd, timeout: 30_000 }, (err, stdout, stderr) => { 35 + if (err) { err.stderr = stderr; return reject(err); } 36 + resolve(stdout.trim()); 37 + }); 38 + }); 39 + } 40 + 41 + async function readLastBuiltHash() { 42 + try { return (await fs.readFile(HASH_FILE, "utf8")).trim(); } catch { return null; } 43 + } 44 + async function writeLastBuiltHash(hash) { 45 + await fs.writeFile(HASH_FILE, hash + "\n", "utf8"); 46 + } 47 + 48 + function isTriggerPath(filePath) { 49 + if (!filePath.startsWith(TRIGGER_PREFIX)) return false; 50 + const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); 51 + return SOURCE_EXTS.includes(ext); 52 + } 53 + 54 + // "recap/audience/jeffrey-73h-2026-05-02.mjs" → "jeffrey-73h-2026-05-02" 55 + function audienceNameFromPath(filePath) { 56 + const base = path.basename(filePath); 57 + return base.replace(/\.mjs$/, ""); 58 + } 59 + 60 + async function poll() { 61 + if (polling) return; 62 + polling = true; 63 + 64 + try { 65 + await git(["fetch", "origin", BRANCH, "--quiet"]); 66 + const remoteHead = await git(["rev-parse", `origin/${BRANCH}`]); 67 + const lastBuilt = await readLastBuiltHash(); 68 + if (remoteHead === lastBuilt) { polling = false; return; } 69 + 70 + let changedPaths = ""; 71 + if (lastBuilt) { 72 + try { 73 + changedPaths = await git(["diff", "--name-only", lastBuilt, remoteHead]); 74 + } catch { 75 + // lastBuilt missing — only build if there are explicit recap audience 76 + // changes in the remote head's tree (avoid a full force-build sweep 77 + // through every audience, which would cost real money). 78 + changedPaths = ""; 79 + } 80 + } 81 + 82 + const audienceChanges = changedPaths 83 + .split("\n") 84 + .filter(isTriggerPath) 85 + .map(audienceNameFromPath); 86 + 87 + if (audienceChanges.length === 0) { 88 + logFn("info", "⏭️", `New commits (${remoteHead.slice(0, 8)}) but no recap/audience/ changes — skipping`); 89 + await writeLastBuiltHash(remoteHead); 90 + polling = false; 91 + return; 92 + } 93 + 94 + // Pull so the build runs against the latest tree 95 + await git(["checkout", BRANCH, "--quiet"]); 96 + await git(["merge", `origin/${BRANCH}`, "--ff-only", "--quiet"]); 97 + 98 + // Trigger one build per changed audience, serialized by recap-builder 99 + // (the start function rejects with RECAP_BUILD_BUSY if one is running). 100 + const unique = [...new Set(audienceChanges)]; 101 + logFn("info", "🎬", `Recap changes detected (${remoteHead.slice(0, 8)}): ${unique.join(", ")}`); 102 + 103 + let queued = 0; 104 + for (const audience of unique) { 105 + try { 106 + const job = await startBuildFn({ audience, ref: remoteHead }); 107 + logFn("info", "🚀", `Recap build ${job.id} started for ${audience}`); 108 + queued++; 109 + // If the builder is busy with the previous, retry on next poll 110 + break; 111 + } catch (err) { 112 + if (err?.code === "RECAP_BUILD_BUSY") { 113 + logFn("info", "⏳", `Recap builder busy — will retry next poll for ${audience}`); 114 + break; 115 + } 116 + logFn("error", "❌", `Failed to start recap build for ${audience}: ${err.message}`); 117 + } 118 + } 119 + 120 + // Only mark hash as built once nothing remains queued (so the next poll 121 + // continues serializing through any backlog). 122 + if (queued === unique.length) { 123 + await writeLastBuiltHash(remoteHead); 124 + } 125 + } catch (err) { 126 + if (err?.code === "RECAP_BUILD_BUSY") { 127 + logFn("info", "⏳", "Recap build already running — will retry next poll"); 128 + } else { 129 + logFn("error", "❌", `Recap git poll error: ${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}`); 130 + } 131 + } finally { 132 + polling = false; 133 + } 134 + } 135 + 136 + // ── Public API ───────────────────────────────────────────────────────── 137 + 138 + export function startPoller({ startRecapBuild, addServerLog }) { 139 + startBuildFn = startRecapBuild; 140 + if (addServerLog) logFn = addServerLog; 141 + 142 + fs.access(GIT_REPO_DIR) 143 + .then(() => { 144 + logFn("info", "🎬", `Recap git poller started (every ${POLL_INTERVAL_MS / 1000}s, repo: ${GIT_REPO_DIR})`); 145 + // Stagger: native uses 5s, papers uses 15s, recap uses 25s 146 + setTimeout(poll, 25000); 147 + timer = setInterval(poll, POLL_INTERVAL_MS); 148 + }) 149 + .catch(() => { 150 + logFn("error", "⚠️", `Recap git poller disabled — repo dir not found: ${GIT_REPO_DIR}`); 151 + }); 152 + } 153 + 154 + export function stopPoller() { 155 + if (timer) { 156 + clearInterval(timer); 157 + timer = null; 158 + logFn("info", "🛑", "Recap git poller stopped"); 159 + } 160 + } 161 + 162 + export function getPollerStatus() { 163 + return { 164 + running: timer !== null, 165 + intervalMs: POLL_INTERVAL_MS, 166 + repoDir: GIT_REPO_DIR, 167 + branch: BRANCH, 168 + triggerPrefix: TRIGGER_PREFIX, 169 + }; 170 + }
+111 -1
oven/server.mjs
··· 22 22 import { startPoller as startNativeGitPoller, getPollerStatus as getNativePollerStatus } from './native-git-poller.mjs'; 23 23 import { startPapersBuild, getPapersBuild, getPapersBuildsSummary, cancelPapersBuild } from './papers-builder.mjs'; 24 24 import { startPoller as startPapersGitPoller, getPollerStatus as getPapersPollerStatus } from './papers-git-poller.mjs'; 25 - import { join, dirname } from 'path'; 25 + import { startRecapBuild, getRecapBuild, getRecapBuildsSummary, cancelRecapBuild, getRecapMp4Path } from './recap-builder.mjs'; 26 + import { startPoller as startRecapGitPoller, getPollerStatus as getRecapPollerStatus } from './recap-git-poller.mjs'; 27 + import { join, dirname, basename } from 'path'; 26 28 import { fileURLToPath } from 'url'; 27 29 import { MongoClient } from 'mongodb'; 28 30 ··· 3840 3842 return res.json(result); 3841 3843 }); 3842 3844 3845 + // ── Recap Builds ─────────────────────────────────────────────────────────── 3846 + // Builds a recap mp4 from a single audience config (e.g. jeffrey-73h-2026-05-02). 3847 + // Auth: same OS_BUILD_ADMIN_KEY used for /native-build / /papers-build. 3848 + // Auto-triggered by recap-git-poller.mjs when recap/audience/*.mjs changes 3849 + // land on origin/main. Manual trigger: POST /recap-build with { audience }. 3850 + 3851 + app.get('/recap-build', (req, res) => { 3852 + res.json({ ...getRecapBuildsSummary(), poller: getRecapPollerStatus() }); 3853 + }); 3854 + 3855 + app.get('/recap-build/:jobId', (req, res) => { 3856 + const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200)); 3857 + const includeLogs = req.query.logs === '1' || req.query.logs === 'true'; 3858 + const job = getRecapBuild(req.params.jobId, { includeLogs, tail }); 3859 + if (!job) return res.status(404).json({ error: 'Job not found' }); 3860 + return res.json(job); 3861 + }); 3862 + 3863 + app.get('/recap-build/:jobId/stream', (req, res) => { 3864 + const jobId = req.params.jobId; 3865 + const initial = getRecapBuild(jobId, { includeLogs: true, tail: 500 }); 3866 + if (!initial) return res.status(404).json({ error: 'Job not found' }); 3867 + 3868 + res.set({ 3869 + 'Content-Type': 'text/event-stream', 3870 + 'Cache-Control': 'no-cache', 3871 + 'Connection': 'keep-alive', 3872 + 'X-Accel-Buffering': 'no', 3873 + }); 3874 + res.flushHeaders(); 3875 + 3876 + let sentLogs = 0; 3877 + const sendEvent = (type, data) => { 3878 + res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 3879 + if (typeof res.flush === 'function') res.flush(); 3880 + }; 3881 + 3882 + if (Array.isArray(initial.logs) && initial.logs.length > 0) { 3883 + sendEvent('logs', { logs: initial.logs }); 3884 + sentLogs = initial.logs.length; 3885 + } 3886 + sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent }); 3887 + 3888 + const timer = setInterval(() => { 3889 + const job = getRecapBuild(jobId, { includeLogs: true, tail: 2000 }); 3890 + if (!job) { clearInterval(timer); res.end(); return; } 3891 + const logs = Array.isArray(job.logs) ? job.logs : []; 3892 + if (logs.length > sentLogs) { 3893 + sendEvent('logs', { logs: logs.slice(sentLogs) }); 3894 + sentLogs = logs.length; 3895 + } 3896 + sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error }); 3897 + if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') { 3898 + sendEvent('complete', { status: job.status, error: job.error, mp4Path: job.mp4Path, mp4Bytes: job.mp4Bytes }); 3899 + clearInterval(timer); 3900 + res.end(); 3901 + } 3902 + }, 1000); 3903 + 3904 + req.on('close', () => clearInterval(timer)); 3905 + }); 3906 + 3907 + // Stream the resulting mp4 for a successful job. Public — anyone with 3908 + // the (random) jobId can download. Recap mp4s aren't committed to git. 3909 + app.get('/recap-build/:jobId/mp4', async (req, res) => { 3910 + const mp4Path = getRecapMp4Path(req.params.jobId); 3911 + if (!mp4Path) return res.status(404).json({ error: 'mp4 not available (job not finished or not found)' }); 3912 + res.setHeader('Content-Type', 'video/mp4'); 3913 + res.setHeader('Content-Disposition', `inline; filename="${basename(mp4Path)}"`); 3914 + const { createReadStream, statSync } = await import('fs'); 3915 + try { 3916 + const stat = statSync(mp4Path); 3917 + res.setHeader('Content-Length', stat.size); 3918 + createReadStream(mp4Path).pipe(res); 3919 + } catch (err) { 3920 + res.status(500).json({ error: err.message }); 3921 + } 3922 + }); 3923 + 3924 + app.post('/recap-build', requireOSBuildAdmin, async (req, res) => { 3925 + try { 3926 + const audience = req.body?.audience; 3927 + if (!audience) return res.status(400).json({ error: 'Missing `audience` in body' }); 3928 + const job = await startRecapBuild({ 3929 + audience, 3930 + ref: req.body?.ref || 'unknown', 3931 + }); 3932 + addServerLog('info', '🎬', `Recap build started: ${job.id} (audience=${audience})`); 3933 + return res.status(202).json(job); 3934 + } catch (err) { 3935 + if (err.code === 'RECAP_BUILD_BUSY') { 3936 + return res.status(409).json({ error: err.message, activeJobId: err.activeJobId }); 3937 + } 3938 + if (err.code === 'RECAP_BUILD_BAD_AUDIENCE') { 3939 + return res.status(400).json({ error: err.message }); 3940 + } 3941 + return res.status(500).json({ error: err.message }); 3942 + } 3943 + }); 3944 + 3945 + app.post('/recap-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { 3946 + const result = cancelRecapBuild(req.params.jobId); 3947 + if (!result.ok) return res.status(400).json(result); 3948 + addServerLog('info', '🛑', `Recap build cancel requested: ${req.params.jobId}`); 3949 + return res.json(result); 3950 + }); 3951 + 3843 3952 // ── OS Release Upload ────────────────────────────────────────────────────── 3844 3953 // Accepts a vmlinuz binary + metadata, uploads to DO Spaces as OTA release. 3845 3954 // Auth: AC token (Bearer) verified against Auth0 userinfo. ··· 4087 4196 4088 4197 // Start papers PDF git poller — watches for papers/ changes 4089 4198 startPapersGitPoller({ startPapersBuild, addServerLog }); 4199 + startRecapGitPoller({ startRecapBuild, addServerLog }); 4090 4200 }); 4091 4201 } 4092 4202
+463
recap/audience/jeffrey-24h-2026-05-01.mjs
··· 1 + // Audience config: jeffrey-24h, 2026-05-01 cut (covering work since the 2 + // 04-30 cut up through end of day 05-01, into the morning of 05-02). 3 + // Voice: jeffrey-pvc. Style: lowercase, calm, descriptive. 4 + // 5 + // New this cut: 6 + // 1. The chapter color now stains the portrait itself — each photo runs 7 + // through a soft duotone so jeffrey reads as recognizably him while 8 + // the slide's color address bleeds through the whole frame instead 9 + // of just the chapter chrome. The CSS color chip in the corner is 10 + // the same color you're seeing tinted across the photo above it. 11 + // 2. The recap pipeline now bakes a per-cut piano waltz under the 12 + // narration, generated by `recap/bin/waltz.mjs` from the AC Native 13 + // OS grand-piano sample bank (Salamander Grand v3 anchors, the same 14 + // .raw files notepat plays through `fedac/native/audio.c`). Each 15 + // cut's waltz is seeded by the audience name so the tune shifts 16 + // across days but the instrument stays consistent — a slow grand 17 + // piano in 3/4 sitting under the narration, never on top of it. 18 + // 19 + // Content slides remain full-bleed gpt-image-2 photos (jeffrey at a 20 + // real home desk, using the imagined feature on his laptop) with a 21 + // top gradient overlay. Photos pre-baked by `bin/jeffrey-photos.mjs` 22 + // into recap/out/jeffrey-photos/<segment>.png and loaded via the 23 + // standard scout `glob:` query. 24 + 25 + import { cssColors } from "../../system/public/aesthetic.computer/lib/num.mjs"; 26 + 27 + export const PALETTE = { 28 + bg: "#0c1430", 29 + off: "#ffffffcc", 30 + dim: "#7886b0", 31 + cream: "#fcf7c5", 32 + cyan: "#70f0e0", 33 + }; 34 + 35 + // Helper: pull an [r,g,b] from cssColors and turn it into the strings 36 + // the slide needs (hex for css, "rgb(r, g, b)" caption, name). 37 + function colorAddress(name) { 38 + const rgb = cssColors[name]; 39 + if (!rgb) throw new Error(`colorAddress: unknown css color '${name}'`); 40 + const [r, g, b] = rgb; 41 + const hex = "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); 42 + return { name, rgb, hex, caption: `rgb(${r}, ${g}, ${b})` }; 43 + } 44 + 45 + // Shared identity + tone preamble. Real iphone-snapshot energy, NOT cinematic. 46 + const REAL = `\ 47 + Photographic candid lifestyle photo of the man in the reference photos. Real \ 48 + photograph, photo-realistic, the kind of casual iPhone snapshot a friend would \ 49 + take of him at home — NOT illustrated, NOT painted, NOT cinematic, NOT \ 50 + magazine-glossy, NOT AI-poster-glossy. Slight film grain, slightly off-center \ 51 + framing, real natural room light. Identity: same face, same medium-length brown \ 52 + hair, same actual features as the references — recognizably him across the \ 53 + various refs. Keep real skin texture; do NOT smooth or prettify. He is at a \ 54 + real home desk in normal everyday clothes (t-shirt or hoodie, slightly bedheaded). \ 55 + Real, deadpan, very "him". Vertical 1024x1536 portrait orientation.`; 56 + 57 + export const audience = { 58 + name: "jeffrey-24h", 59 + handle: "@jeffrey", 60 + voice: { provider: "jeffrey", voice: "neutral:0" }, 61 + 62 + // Per-cut waltz bed. waltz.mjs reads this and `audience.name` to seed 63 + // the tune; the result is mixed under the narration in compose.fish. 64 + // `voice: "sinebells"` is the soft-chime synth (fundamental + lightly 65 + // inharmonic partials, cosine attack). `voice: "piano"` switches to 66 + // the AC Native OS Salamander grand-piano sample bank. 67 + waltz: { 68 + voice: "sinebells", // softer than piano for narration beds 69 + seed: "jeffrey-24h-2026-05-01", 70 + bpm: 78, // calm; "soft viennese" pace 71 + scale: "major", 72 + progression: [0, 5, 3, 4, 0, 3, 4, 0], // I vi IV V I IV V I — patient 73 + bars: 32, 74 + voiceGain: 0.18, // quiet bed under voice 75 + }, 76 + 77 + narration: `hey everybody, here's the next twenty-four hours at aesthetic computer. the headline this round is the menubar audio stack growing up. menuband now has a real waveform strip — it slides out from under the piano popover when notes start hitting and tucks back when they stop, with shared waveform state across the menubar, the popover, and the new floating palette so all three surfaces draw the same trace. the floating palette itself got proper popover controls — instrument picker, transport, mute — a strip footer that names the current voice and the held notes, and dragging that's pinned to the title bar instead of grabbing the whole window. underneath, the metal shader for the waveform moved into its own dot-metal file and now compiles at runtime, so a hot-reload doesn't need a full xcode round-trip, and the metal uniform upload size got fixed alongside it. the multi-channel synth path stopped clobbering itself when polyphony stacked up, and shift and capslock now hold a note in linger mode instead of dropping it — you can lean on a key while you reach for the next one. today's commit lands the integrated chord finder right inside the popover — it watches the piano voicing and names what you're playing — plus chromatic notepat coloring so every key carries a hue that matches its semitone, and a tighter mini-meter that fits next to the chord readout without crowding it. zooming out, most of yesterday went into a paper. arxiv-keymaps now has a tikz figure comparing qwerty, dvorak, and colemak, a daw chromatic-staircase mapping figure, a wicki-hayden hex layout, vim modes, singmaster's cube notation, and a real asymmetric-instruments figure with hand-cut trumpet and ocarina silhouettes. the prose grew too — a steelman against isomorphic layouts, a notation-surface section, an orality-and-literacy parallel pulling from walter ong with the wasd hand-shape pattern as the worked example, and a community-authored framing for the lgbtq-plus initialism. the papers index itself got load-bearing infrastructure — a deadlines page wired into prompt autocomplete, a question-mark-review mode that surfaces vault draft links right next to deadlines, a submissions ledger, the keymaps draft formally registered, the public cv hidden, and fia's opportunities pulled in from the honeydo inbox so the page works for both of us. on the hardware bench, a new toughbook directory landed at the top level to scaffold the ac native os bring-up on the panasonic. and ac-electron pushed zero-one-point-four-four — turns out the xbox controller bridge file was orphaned in the build, so the controller wasn't actually wired through to the webview; that's loaded for real now, with a-button and space respawn and the ac-desktop cli alongside it. that's the day. thanks for watching, and i'll be back tomorrow with the next twenty-four.`, 78 + 79 + // Whisper renders these dictionary-style; rewrite the displayed subtitles. 80 + // Order matters: longer/multi-word fixes first. 81 + transcriptFixes: { 82 + "a static computer": "aesthetic computer", 83 + "static computer": "aesthetic computer", 84 + "Menu Band": "menuband", 85 + "menu band": "menuband", 86 + "Menuband": "menuband", 87 + "Menu Bar": "menubar", 88 + "menu bar": "menubar", 89 + "Note Pat": "notepat", 90 + "Notepat": "notepat", 91 + "Arxiv": "arxiv", 92 + "ArXiv": "arxiv", 93 + "TikZ": "tikz", 94 + "Tik Z": "tikz", 95 + "DAW": "daw", 96 + "QWERTY": "qwerty", 97 + "Qwerty": "qwerty", 98 + "Dvorak": "dvorak", 99 + "Colemak": "colemak", 100 + "Wicki-Hayden": "wicki-hayden", 101 + "Wicki Hayden": "wicki-hayden", 102 + "Singmaster": "singmaster", 103 + "Vim": "vim", 104 + "VIM": "vim", 105 + "Walter Ong": "walter ong", 106 + "WASD": "wasd", 107 + "LGBTQ": "lgbtq", 108 + "LGBT": "lgbt", 109 + "PVC": "pvc", 110 + "CV": "cv", 111 + "CLI": "cli", 112 + "Mac OS": "macos", 113 + "macOS": "macos", 114 + "MIDI": "midi", 115 + "Metal": "metal", 116 + "Xcode": "xcode", 117 + "XCode": "xcode", 118 + "Toughbook": "toughbook", 119 + "Panasonic": "panasonic", 120 + "Xbox": "xbox", 121 + "X-box": "xbox", 122 + "Electron": "electron", 123 + "Casey": "casey", 124 + "Claude": "claude", 125 + "Mac": "mac", 126 + "MacBook": "macbook", 127 + "Jeffrey": "jeffrey", 128 + "Fia": "fia", 129 + "Honeydo": "honeydo", 130 + }, 131 + 132 + segments: [ 133 + { name: "01_title", marker: "hey everybody" }, 134 + { name: "02_waveform_strip", marker: "menuband now has" }, 135 + { name: "03_floating_palette", marker: "the floating palette" }, 136 + { name: "04_metal_shader", marker: "underneath, the metal shader" }, 137 + { name: "05_synth_linger", marker: "the multi-channel synth" }, 138 + { name: "06_chord_finder", marker: "today's commit lands" }, 139 + { name: "07_arxiv_keymaps", marker: "zooming out" }, 140 + { name: "08_keymaps_prose", marker: "the prose grew" }, 141 + { name: "09_deadlines_review", marker: "the papers index itself" }, 142 + { name: "10_toughbook", marker: "on the hardware bench" }, 143 + { name: "11_electron_xbox", marker: "and ac-electron" }, 144 + { name: "12_outro", marker: "thanks for watching" }, 145 + { name: "13_end", marker: "__END__", trailingSilenceSec: 3 }, 146 + ], 147 + 148 + slides: { 149 + "01_title": { 150 + colorAddress: colorAddress("mintcream"), 151 + metaphor: `${REAL} 152 + 153 + Scene: he is sitting at a wooden home desk in a wrinkled t-shirt, holding up \ 154 + a single sheet of plain white printer paper next to his face. The paper has \ 155 + "DAY 2 / 24 HRS" hand-drawn on it in thick black sharpie, slightly crooked \ 156 + letters, with a tiny "+1" in the corner. He is giving a deadpan grin, \ 157 + mid-laugh, eye-contact with the camera. An open Citrus-green MacBook Neo (Apple's bright yellow-green color) on the desk \ 158 + in the background shows a calendar app with two highlighted days in a row. \ 159 + A pale mintcream (#f5fffa) ceramic mug sits on the desk — the only pop of \ 160 + accent color, very gentle. Soft afternoon light from a window on the right, \ 161 + warm white balance. Wide shot, his whole upper body and the desk visible.`, 162 + queries: { photo: { glob: "recap/out/jeffrey-photos/01_title.png" } }, 163 + body: ({ photo }) => photoSlide({ 164 + photo, 165 + chapter: "00 / 11 · the next 24 hours", 166 + title: "aesthetic computer", 167 + cap: "2026·05·01", 168 + color: colorAddress("mintcream"), 169 + }), 170 + }, 171 + 172 + "02_waveform_strip": { 173 + colorAddress: colorAddress("deeppink"), 174 + metaphor: `${REAL} 175 + 176 + Scene: he is hunched over a Citrus-green MacBook Neo (Apple's bright yellow-green color) on a kitchen table, eyes locked \ 177 + on a small popover in the upper-right of the screen. The popover clearly \ 178 + shows a piano keyboard on top, and BELOW it a thin horizontal waveform \ 179 + strip rendered as a single deeppink (#ff1493) trace on a dark substrate, \ 180 + mid-slide-out, like a drawer being pulled open. He is gently drumming three \ 181 + fingers of his left hand on the table to set the rhythm. A deeppink \ 182 + highlighter pen lies on the desk. He is in a hoodie, slightly bedheaded, \ 183 + deadpan focus. Evening lamp light. Real candid energy.`, 184 + queries: { photo: { glob: "recap/out/jeffrey-photos/02_waveform_strip.png" } }, 185 + body: ({ photo }) => photoSlide({ 186 + photo, 187 + chapter: "01 / 11 · menuband · waveform strip", 188 + title: "slides under\nthe piano,\non note activity", 189 + cap: "shared trace · menubar · popover · palette", 190 + color: colorAddress("deeppink"), 191 + }), 192 + }, 193 + 194 + "03_floating_palette": { 195 + colorAddress: colorAddress("mediumspringgreen"), 196 + metaphor: `${REAL} 197 + 198 + Scene: he is at a wooden home desk in front of a Citrus-green MacBook Neo (Apple's bright yellow-green color). A small \ 199 + floating window — the menuband palette, torn off from the menubar — is \ 200 + hovering mid-air over the desk surface, clearly draggable. He is reaching \ 201 + out with one finger pinched on its title bar, deadpan-amused, as if \ 202 + catching a ball. The palette shows: an instrument picker tab, a transport \ 203 + row with play/mute, and a strip footer at the bottom listing held notes in \ 204 + mediumspringgreen (#00fa9a) text — "C4 E4 G4". A mediumspringgreen \ 205 + sticky note is stuck to the laptop bezel. Bright morning window light. \ 206 + Real candid, slightly playful, very "him".`, 207 + queries: { photo: { glob: "recap/out/jeffrey-photos/03_floating_palette.png" } }, 208 + body: ({ photo }) => photoSlide({ 209 + photo, 210 + chapter: "02 / 11 · floating palette", 211 + title: "popover\ncontrols,\nstrip footer", 212 + cap: "drag from title bar · names voice + held notes", 213 + color: colorAddress("mediumspringgreen"), 214 + }), 215 + }, 216 + 217 + "04_metal_shader": { 218 + colorAddress: colorAddress("royalblue"), 219 + metaphor: `${REAL} 220 + 221 + Scene: he is at a home desk, leaning back in a chair with a single sheet \ 222 + of paper held up: a hand-drawn diagram of a Metal shader pipeline — \ 223 + boxes labeled "vertex → fragment → uniform" connected by arrows in \ 224 + royalblue (#4169e1) ballpoint pen. Behind him, the Citrus-green MacBook Neo (Apple's bright yellow-green color) screen \ 225 + shows a code editor with a file titled "WaveformShaders.metal" open and \ 226 + no Xcode IDE chrome visible — a runtime compile log scrolls by. He is \ 227 + deadpan, slightly proud, like a kid showing off a finished worksheet. \ 228 + Soft afternoon light. A royalblue pen tucked behind his ear. Real candid.`, 229 + queries: { photo: { glob: "recap/out/jeffrey-photos/04_metal_shader.png" } }, 230 + body: ({ photo }) => photoSlide({ 231 + photo, 232 + chapter: "03 / 11 · waveform shader", 233 + title: "moved to\n.metal,\nruntime compile", 234 + cap: "no full xcode round-trip · uniform upload fix", 235 + color: colorAddress("royalblue"), 236 + }), 237 + }, 238 + 239 + "05_synth_linger": { 240 + colorAddress: colorAddress("tomato"), 241 + metaphor: `${REAL} 242 + 243 + Scene: close-ish shot of him at a home desk with both hands on a Citrus-green \ 244 + MacBook Neo (Apple's bright yellow-green color) keyboard. His left pinky is pressing the SHIFT key and his right \ 245 + pinky is pressing CAPSLOCK — both keys have small tomato-red (#ff6347) \ 246 + sticky-note flags taped to them reading "LINGER". On the laptop screen \ 247 + behind his hands, a notepat-style piano is visible with multiple keys \ 248 + glowing — clearly several voices playing at once without dropping. He is \ 249 + looking sideways at the camera with a small deadpan grin, like "watch \ 250 + this." A tomato-red coffee mug at the desk's edge. Bright window light. \ 251 + Real candid, slightly nerdy.`, 252 + queries: { photo: { glob: "recap/out/jeffrey-photos/05_synth_linger.png" } }, 253 + body: ({ photo }) => photoSlide({ 254 + photo, 255 + chapter: "04 / 11 · synth path · linger", 256 + title: "polyphony\nholds, shift\n+ capslock\nlinger", 257 + cap: "multi-channel fix · hold while you reach", 258 + color: colorAddress("tomato"), 259 + }), 260 + }, 261 + 262 + "06_chord_finder": { 263 + colorAddress: colorAddress("gold"), 264 + metaphor: `${REAL} 265 + 266 + Scene: he is at a home desk, fingers spread across a Citrus-green MacBook Neo (Apple's bright yellow-green color) \ 267 + keyboard like he's playing a chord shape. The menuband popover at the \ 268 + top of the screen is clearly visible, and inside it a chord readout \ 269 + panel reads "G MAJOR 7" in big gold (#ffd700) letters, with a tiny \ 270 + mini-meter strip on the right showing levels. Each piano key in the \ 271 + popover is tinted a different hue — chromatic coloring across the \ 272 + octave, gold prominent on the G. A small toy plastic upright piano \ 273 + sits on the desk as a visual gag. Soft window light, mug of tea. \ 274 + Deadpan focused. Real candid.`, 275 + queries: { photo: { glob: "recap/out/jeffrey-photos/06_chord_finder.png" } }, 276 + body: ({ photo }) => photoSlide({ 277 + photo, 278 + chapter: "05 / 11 · integrated chord finder", 279 + title: "names what\nyou're playing,\nchromatic\nkey hues", 280 + cap: "popover-side · tighter mini-meter", 281 + color: colorAddress("gold"), 282 + }), 283 + }, 284 + 285 + "07_arxiv_keymaps": { 286 + colorAddress: colorAddress("indigo"), 287 + metaphor: `${REAL} 288 + 289 + Scene: he is at a home desk completely buried in printed pages of an \ 290 + arxiv-style preprint. Several sheets are visibly the same paper: one \ 291 + shows a TikZ figure of QWERTY/Dvorak/Colemak keyboards in a row, another \ 292 + shows a Wicki-Hayden hex grid, another shows asymmetric-instrument \ 293 + silhouettes — trumpet, ocarina, recorder. A thick indigo (#4b0082) \ 294 + highlighter is in his hand, mid-mark over a paragraph. The Citrus-green \ 295 + MacBook Neo (Apple's bright yellow-green color) on the desk shows the paper's PDF preview. He is deadpan, \ 296 + glasses slightly low, very "him in writing mode." Bright overhead \ 297 + lamp light. Real candid.`, 298 + queries: { photo: { glob: "recap/out/jeffrey-photos/07_arxiv_keymaps.png" } }, 299 + body: ({ photo }) => photoSlide({ 300 + photo, 301 + chapter: "06 / 11 · arxiv-keymaps · figures", 302 + title: "qwerty / dvorak,\ndaw chromatic,\nwicki-hayden,\nvim, singmaster", 303 + cap: "asymmetric instruments · real silhouettes", 304 + color: colorAddress("indigo"), 305 + }), 306 + }, 307 + 308 + "08_keymaps_prose": { 309 + colorAddress: colorAddress("darksalmon"), 310 + metaphor: `${REAL} 311 + 312 + Scene: he is sitting cross-legged on the floor with a stack of books \ 313 + beside him. Walter Ong's "Orality and Literacy" is on top, the spine in \ 314 + darksalmon (#e9967a) cloth, clearly readable. He is holding up a printed \ 315 + draft page with a paragraph circled in darksalmon highlighter — visible \ 316 + fragments read "WASD" and "notation surface" and "steelman." His other \ 317 + hand rests on a laptop showing the paper's source. Lamp light, evening, \ 318 + warm tone. Deadpan, contemplative, slight half-smile. Real candid \ 319 + energy, very "him reading."`, 320 + queries: { photo: { glob: "recap/out/jeffrey-photos/08_keymaps_prose.png" } }, 321 + body: ({ photo }) => photoSlide({ 322 + photo, 323 + chapter: "07 / 11 · arxiv-keymaps · prose", 324 + title: "ong + wasd,\nnotation surface,\nsteelman,\nlgbtq+ framing", 325 + cap: "live-coding mini-notations · §6.2 sources verified", 326 + color: colorAddress("darksalmon"), 327 + }), 328 + }, 329 + 330 + "09_deadlines_review": { 331 + colorAddress: colorAddress("mediumaquamarine"), 332 + metaphor: `${REAL} 333 + 334 + Scene: he is at a home desk in front of a Citrus-green MacBook Neo (Apple's bright yellow-green color) showing a \ 335 + calendar-style "deadlines" page with rows of upcoming dates. A small \ 336 + column of mediumaquamarine (#66cdaa) sticky notes is lined up along \ 337 + the desk's left edge, each labeled with a different deadline: "SIGGRAPH \ 338 + ASIA," "fia-grant," "keymaps." A second laptop screen behind shows the \ 339 + prompt autocomplete dropdown matching the word "deadlines." He is \ 340 + holding up one mediumaquamarine sticky between two fingers like a \ 341 + ticket. Deadpan, "here you go" energy. Bright window light. Real \ 342 + candid.`, 343 + queries: { photo: { glob: "recap/out/jeffrey-photos/09_deadlines_review.png" } }, 344 + body: ({ photo }) => photoSlide({ 345 + photo, 346 + chapter: "08 / 11 · papers · deadlines", 347 + title: "deadlines page,\n?review mode,\nsubmissions\nledger", 348 + cap: "prompt autocomplete · fia opportunities · cv hidden", 349 + color: colorAddress("mediumaquamarine"), 350 + }), 351 + }, 352 + 353 + "10_toughbook": { 354 + colorAddress: colorAddress("dimgray"), 355 + metaphor: `${REAL} 356 + 357 + Scene: he is in a real garage / workshop space, holding up a Panasonic \ 358 + Toughbook — a chunky rugged laptop with bumpered black-and-dimgray \ 359 + (#696969) rubber armor on the corners, clearly industrial. The lid is \ 360 + open and the BIOS / boot screen is visible, mid-AC-Native-OS bring-up. \ 361 + A dimgray cable hangs from the back. The garage has tools, a workbench, \ 362 + maybe a plotter peeking in the background. He is deadpan-grinning, \ 363 + holding the laptop like a found treasure. Bright overhead garage light. \ 364 + Real candid, mildly proud, very "him at the bench."`, 365 + queries: { photo: { glob: "recap/out/jeffrey-photos/10_toughbook.png" } }, 366 + body: ({ photo }) => photoSlide({ 367 + photo, 368 + chapter: "09 / 11 · toughbook scaffold", 369 + title: "panasonic\ntoughbook,\nac native os\nbring-up", 370 + cap: "new top-level dir · next hardware target", 371 + color: colorAddress("dimgray"), 372 + }), 373 + }, 374 + 375 + "11_electron_xbox": { 376 + colorAddress: colorAddress("crimson"), 377 + metaphor: `${REAL} 378 + 379 + Scene: he is at a home desk, holding a black Xbox controller in both \ 380 + hands — the A button is clearly highlighted in crimson (#dc143c) \ 381 + glow, like it's been pressed. The Citrus-green MacBook Neo (Apple's bright yellow-green color) on the desk shows \ 382 + the AC Desktop app open with a piece running, and a small terminal \ 383 + window beside it shows the line "ac-desktop launch ..." in crimson \ 384 + text. He is leaning back slightly with a small deadpan grin, the \ 385 + controller resting on his lap, mock-gamer pose. Soft evening lamp \ 386 + light. Real candid, very "him goofing with hardware."`, 387 + queries: { photo: { glob: "recap/out/jeffrey-photos/11_electron_xbox.png" } }, 388 + body: ({ photo }) => photoSlide({ 389 + photo, 390 + chapter: "10 / 11 · ac-electron 0.1.44", 391 + title: "xbox bridge\nactually loads,\nA + space\nrespawn", 392 + cap: "controller in webview · ac-desktop cli", 393 + color: colorAddress("crimson"), 394 + }), 395 + }, 396 + 397 + "12_outro": { 398 + colorAddress: colorAddress("lavenderblush"), 399 + metaphor: `${REAL} 400 + 401 + Scene: he is leaning back in a desk chair holding a coffee mug, giving \ 402 + a small relaxed wave to the camera with his free hand. The Citrus-green \ 403 + MacBook Neo (Apple's bright yellow-green color) in front of him on the desk \ 404 + shows a screen reading "thanks for watching" in calm green-on-black terminal \ 405 + text with a thin lavenderblush \ 406 + (#fff0f5) underline. A lavenderblush curtain is visible behind him, \ 407 + softly catching evening light. He has a small real smile, eye contact \ 408 + with the camera. Comfortable home setting — desk lamp on, warm white \ 409 + balance, a houseplant or two visible. Real iPhone candid snapshot \ 410 + energy.`, 411 + queries: { photo: { glob: "recap/out/jeffrey-photos/12_outro.png" } }, 412 + body: ({ photo }) => photoSlide({ 413 + photo, 414 + chapter: "11 / 11 · outro", 415 + title: "thanks for\nwatching", 416 + cap: "aesthetic.computer · @jeffrey", 417 + color: colorAddress("lavenderblush"), 418 + }), 419 + }, 420 + 421 + "13_end": ` 422 + <div class="frame"> 423 + <div class="pals med"></div> 424 + <div class="endline" style="color:${PALETTE.cream}">aesthetic·computer</div> 425 + <div class="endsub" style="color:${PALETTE.dim}">narrated by jeffrey-pvc · @jeffrey</div> 426 + <div class="endsub" style="color:${PALETTE.dim}">2026·05·01</div> 427 + </div>`, 428 + }, 429 + }; 430 + 431 + // Renders a full-bleed photo slide. Two new layers vs. the 04-30 cut: 432 + // 1. The img runs through a soft duotone filter (slight desaturation + 433 + // contrast lift) so a colored multiply tint reads cleanly across it. 434 + // 2. A chapter-color tint div sits above the img with mix-blend-mode 435 + // multiply at moderate alpha — the CSS color address from the chip 436 + // now stains the actual portrait, turning the photo into the slide's 437 + // color statement instead of just a neutral backdrop. The chip in 438 + // the corner names what color you're seeing. 439 + function photoSlide({ photo, chapter, title, cap, color }) { 440 + const titleHtml = title.split("\n").map((l) => `<div>${l}</div>`).join(""); 441 + return ` 442 + <div style="position: fixed; inset: 0; padding: 0; overflow: hidden;"> 443 + ${photo 444 + ? `<img src="${photo}" style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; filter: saturate(0.55) contrast(1.08) brightness(0.96);" />` 445 + : `<div style="position: absolute; inset: 0; background: ${PALETTE.bg};"></div>`} 446 + <div style="position: absolute; inset: 0; background: ${color.hex}; mix-blend-mode: multiply; opacity: 0.42;"></div> 447 + <div style="position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 35%, transparent 0%, transparent 30%, rgba(0,0,0,0.35) 100%); mix-blend-mode: multiply;"></div> 448 + <div style="position: absolute; top: 0; left: 0; right: 0; padding: 180px 70px 110px; background: linear-gradient(to bottom, rgba(0,0,0,0.86) 0%, rgba(0,0,0,0.5) 60%, rgba(0,0,0,0) 100%);"> 449 + <div style="font-family: 'ProcessingR'; font-size: 30px; letter-spacing: 6px; text-transform: uppercase; color: ${color.hex};">${chapter}</div> 450 + <div style="font-family: 'ProcessingB'; font-size: 110px; line-height: 1.0; letter-spacing: -2px; color: ${color.hex}; margin-top: 18px;">${titleHtml}</div> 451 + <div style="font-family: 'ProcessingR'; font-size: 38px; color: ${PALETTE.off}; margin-top: 26px; letter-spacing: 1px;">${cap}</div> 452 + </div> 453 + <div style="position: absolute; bottom: 110px; right: 70px; display: flex; align-items: center; gap: 22px; padding: 18px 26px 18px 22px; background: rgba(0,0,0,0.72); border: 1px solid rgba(255,255,255,0.18); border-radius: 6px;"> 454 + <div style="width: 56px; height: 56px; background: ${color.hex}; border: 1px solid rgba(255,255,255,0.35); border-radius: 4px;"></div> 455 + <div style="display: flex; flex-direction: column; gap: 4px;"> 456 + <div style="font-family: 'ProcessingB'; font-size: 30px; letter-spacing: 1px; color: ${PALETTE.cream};">${color.name}</div> 457 + <div style="font-family: 'ProcessingR'; font-size: 22px; letter-spacing: 1px; color: ${PALETTE.dim};">${color.caption}</div> 458 + </div> 459 + </div> 460 + </div>`; 461 + } 462 + 463 + export default audience;
+836
recap/audience/jeffrey-73h-2026-05-02.mjs
··· 1 + // Audience config: jeffrey-73h, 2026-05-02 cut. 2 + // Window: ~2026-04-29 10:33 → 2026-05-02 11:33. Compressing three days of 3 + // commits into one recap because the menubar audio stack basically grew 4 + // up over the same three days and reads as a single arc, not three 5 + // separate ones. Voice: jeffrey-pvc. Style: lowercase, calm, descriptive. 6 + // 7 + // Inherits from the 04-30 / 05-01 cuts: 8 + // - per-slide CSS color address from `cssColors` in lib/num.mjs 9 + // - duotone tint over the photo (chapter color stains the portrait) 10 + // - sine-bells waltz bed under narration (pure synth, AC-native logic) 11 + // 12 + // Slides remain full-bleed gpt-image-2 photos (jeffrey at a real home 13 + // desk, using the imagined feature on his laptop) with a top gradient. 14 + // Photos pre-baked by `bin/jeffrey-photos.mjs` into 15 + // recap/out/jeffrey-photos/<segment>.png. 16 + 17 + import { cssColors } from "../../system/public/aesthetic.computer/lib/num.mjs"; 18 + 19 + export const PALETTE = { 20 + bg: "#0c1430", 21 + off: "#ffffffcc", 22 + dim: "#7886b0", 23 + cream: "#fcf7c5", 24 + cyan: "#70f0e0", 25 + }; 26 + 27 + function colorAddress(name) { 28 + const rgb = cssColors[name]; 29 + if (!rgb) throw new Error(`colorAddress: unknown css color '${name}'`); 30 + const [r, g, b] = rgb; 31 + const hex = "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); 32 + return { name, rgb, hex, caption: `rgb(${r}, ${g}, ${b})` }; 33 + } 34 + 35 + const REAL = `\ 36 + Photographic candid lifestyle photo of the man in the reference photos. Real \ 37 + photograph, photo-realistic, the kind of casual iPhone snapshot a friend would \ 38 + take of him at home — NOT illustrated, NOT painted, NOT cinematic, NOT \ 39 + magazine-glossy, NOT AI-poster-glossy. Slight film grain, slightly off-center \ 40 + framing, real natural room light. Identity: same face, same medium-length brown \ 41 + hair, same actual features as the references — recognizably him across the \ 42 + various refs. Keep real skin texture; do NOT smooth or prettify. He is in normal \ 43 + everyday clothes (t-shirt or hoodie, slightly bedheaded). Vertical 1024x1536 \ 44 + portrait orientation. Expression varies per scene — the per-scene description \ 45 + controls it; lean hyperbolic / cartoony when the scene says so. \ 46 + \ 47 + COMPOSITION RULES (the slide will overlay text and a small inset card on top \ 48 + of the photo, so leave room for them): \ 49 + 1) HEAD AND FACE: centered horizontally, vertically positioned in the MIDDLE \ 50 + band of the frame — head should sit roughly between y=500 and y=1100 of the \ 51 + 1024x1536 image (the middle 40%). His eyes should land near y=700–800. The \ 52 + face must be FULLY VISIBLE — not cropped at the top, not obscured by raised \ 53 + arms, not turned away. \ 54 + 2) TOP 25% RESERVED: the upper portion of the frame (y=0 to y=380) MUST be \ 55 + quiet background — ceiling, wall, sky, soft out-of-focus shelf, or quiet wall \ 56 + art. NO HEAD, NO RAISED HANDS, NO ARMS above shoulder level in this zone. \ 57 + This is where the chapter title text overlays. \ 58 + 3) BOTTOM-LEFT QUADRANT: keep the area roughly from (x=0, y=950) to (x=420, \ 59 + y=1400) calm — desk surface, body/torso, floor, or out-of-focus foreground. \ 60 + A small artifact inset card overlays here on some slides. \ 61 + 4) HYPERBOLIC ENERGY through FACE + POSTURE, not raised arms: convey big \ 62 + emotion via mouth (wide open, surprised laugh, oh-face), eyes (wide, scrunched, \ 63 + horrified), eyebrows (shot up, knit), head tilt, lean-in/lean-back, shoulders. \ 64 + Hands and gestures at chest level or below — never above the head, never \ 65 + covering the face. \ 66 + \ 67 + SHIRT: jeffrey's outfit always carries a single short lowercase word \ 68 + somewhere visible — a real-feeling screenprint or embroidery in bold \ 69 + custom typography, slightly worn-in. The exact word and placement are \ 70 + given per scene below. The word maps directly to the chapter's topic \ 71 + (it's a literal command). Placement varies to match the outfit: a \ 72 + centered chest screenprint for hyperbolic tee scenes; a small chest- \ 73 + pocket print on quieter tees; embroidered chest-pocket lettering on \ 74 + button-downs; embroidered cuff lettering on dress shirts; a sleeve \ 75 + patch on hoodies; stitched chest letters on cardigans. Don't always \ 76 + center the word — vary the placement so each chapter's wardrobe reads \ 77 + differently. \ 78 + \ 79 + RECURRING PROP: jeffrey often has a small **USB-stick-shaped vape pen** \ 80 + nearby — the size and silhouette of a USB-A thumb drive, slim rectangular \ 81 + profile, matte black or muted color, with a **small LED indicator light** \ 82 + (usually glowing white / blue / green) at the mouthpiece end. NOT a \ 83 + cigarette-style pen, NOT a chunky MOD, NOT a bong — the disposable-pod / \ 84 + Stiiizy-style portable. It varies per scene: sometimes in his hand \ 85 + mid-puff with a faint wisp of vapor, sometimes resting on the desk next \ 86 + to the laptop, sometimes tucked in a hoodie pocket. Casual background \ 87 + detail, not the visual focus. Skip on formal/scholarly scenes; lean in \ 88 + on relaxed couch / desk / garage / late-night scenes. \ 89 + \ 90 + WARDROBE — REAL OUTFITS ONLY: dress jeffrey only in clothes he actually \ 91 + wears: button-down shirts (flannel or solid casual), hoodies (open or \ 92 + zipped, muted gray/navy/olive/burgundy/cream), printed t-shirts bearing \ 93 + a single AC piece word per the per-scene description, cardigans (knit, \ 94 + earth tones), pullover sweaters (varied patterns including stripes), \ 95 + flannels (over a tee or buttoned, plaid). NEVER tank tops. NEVER suits / \ 96 + formalwear. NEVER costumes / cosplay / athletic gear / fashion-runway \ 97 + pieces. When in doubt, default to a hoodie or a printed tee; he's at \ 98 + home, not at a photoshoot.`; 99 + 100 + export const audience = { 101 + name: "jeffrey-73h", 102 + handle: "@jeffrey", 103 + voice: { provider: "jeffrey", voice: "neutral:0" }, 104 + 105 + // Sine-bells bed for this cut. A bit longer (40 bars) so it covers the 106 + // longer 73-hour narration without obvious looping. Same calm pace. 107 + waltz: { 108 + voice: "sinebells", 109 + seed: "jeffrey-73h-2026-05-02", 110 + bpm: 76, 111 + scale: "major", 112 + progression: [0, 5, 3, 4, 0, 3, 4, 0], 113 + bars: 40, 114 + voiceGain: 0.18, 115 + }, 116 + 117 + narration: `hey everybody, here's the last seventy-three hours at aesthetic computer. this is a triple-length cut because the menubar audio stack basically grew up over three days. menuband shipped six releases in a row — zero-four added a mute toggle and sandbox-safe key capture, zero-five took perf patches from esteban uribe, zero-six landed the voice palette lcd with bundled ywft processing and dark mode, zero-seven moved to a sharp risograph drop shadow on the title with a one-pixel family-color misregister, zero-eight thought it caught a bug where two ywft files shared a postscript name, and zero-nine actually fixed it by loading the dot-ttf descriptor from the bundle url and asserting the family before drawing. today's commit lands the integrated chord finder right in the popover — it watches the piano voicing and names what you're playing — plus chromatic notepat coloring so each key carries a hue at its semitone, and a tighter mini-meter that fits next to the chord readout. a real waveform strip now slides out from under the piano popover when notes hit and tucks back when they stop, with shared waveform state across the menubar, the popover, and the new floating palette so all three surfaces draw the same trace. the floating palette itself got proper popover controls — instrument picker, transport, mute — a strip footer that names the current voice and held notes, and dragging that's pinned to the title bar. the metal shader for the waveform moved into its own dot-metal file and now compiles at runtime, the multi-channel synth path stopped clobbering itself when polyphony stacked up, shift and capslock now hold a note in linger mode, and the whole app does less work when hidden. on the menubar side, the polygon status icon went from per-session colors to a single homogenized hue that tracks how many sessions are awaiting input, and the claude-stop hook flips active to awaiting instead of clearing — the lid-ambient mode detects all three shapes of claude-is-running. on the hardware bench, the hp seventy-five eighty-five b drafting plotter came up over rs-two-thirty-two with a hardware-flow serial sender, a live-limits probe that reads sheet geometry off the machine, and a native hp-gl composer that renders svg paths as faceted polylines, with the pals mark as the first real test. inside fedac native, the grand-piano sample amp went from one-point-eight to three so it sits level with the sine and square voices through notepat, and a new toughbook directory landed at the top level to scaffold the next ac native os bring-up on a panasonic. zooming out, most of the writing time went into a paper. arxiv-keymaps now has a tikz figure comparing qwerty, dvorak, and colemak, a daw chromatic-staircase mapping figure, a wicki-hayden hex layout, vim modes, singmaster's cube notation, and an asymmetric-instruments figure with guitar and violin string-tuning panels. the prose grew too — a steelman against isomorphic layouts, a notation-surface section, an orality-and-literacy parallel pulling from walter ong with wasd as the worked example, a community-authored framing for the lgbtq-plus initialism, and live-coding mini-notations with the section's sources verified. the papers index itself got load-bearing infrastructure — a deadlines page wired into prompt autocomplete, a question-mark-review mode that surfaces vault draft links, a submissions ledger with the siggraph asia scaffold, the keymaps draft formally registered, the public cv hidden, fia's opportunities pulled in from the honeydo inbox, and the original jeffrey platter restored with hq originals in a lightbox and an opinions list. ac-electron pushed zero-one-point-four-four — the xbox controller bridge file was orphaned in the build, so the controller wasn't actually wired through to the webview; that's loaded for real now, with a-button and space respawn and the new ac-desktop cli, and arena widened its reconcile dead zone so the multiplayer snap doesn't fight gentle motion. and the recap pipeline itself grew up — pre-baked jeffrey-photos cached per segment, and a per-cut waltz generator that pulls from the ac native os instrument stack so each cut sits under a custom soft sine-bell bed. from the chats — laer-klokken and the main thread both had voices running alongside all of this; the next slide pulls the recent messages so the people behind the keyboard are part of the cut, not just the commits. that's the seventy-three hours. thanks for watching, and i'll be back tomorrow.`, 118 + 119 + transcriptFixes: { 120 + "a static computer": "aesthetic computer", 121 + "static computer": "aesthetic computer", 122 + "Menu Band": "menuband", "menu band": "menuband", "Menuband": "menuband", 123 + "Menu Bar": "menubar", "menu bar": "menubar", 124 + "Note Pat": "notepat", "Notepat": "notepat", 125 + "YWFT Processing": "ywft processing", "YWFT": "ywft", "Y W F T": "ywft", 126 + "PostScript": "postscript", "Post Script": "postscript", 127 + "NS Font": "nsfont", "NSFont": "nsfont", 128 + "dot-TTF": "dot-ttf", "dot TTF": "dot-ttf", ".TTF": ".ttf", 129 + "Riso": "riso", "Risograph": "risograph", 130 + "LED": "led", "LCD": "lcd", 131 + "QWERTY": "qwerty", "Qwerty": "qwerty", 132 + "Dvorak": "dvorak", "Colemak": "colemak", 133 + "Wicki-Hayden": "wicki-hayden", "Wicki Hayden": "wicki-hayden", 134 + "Singmaster": "singmaster", 135 + "Vim": "vim", "VIM": "vim", 136 + "Walter Ong": "walter ong", 137 + "WASD": "wasd", 138 + "LGBTQ": "lgbtq", "LGBT": "lgbt", 139 + "Mac OS": "macos", "macOS": "macos", 140 + "MIDI": "midi", 141 + "GPT-Image-2": "gpt-image-2", "GPT Image 2": "gpt-image-2", 142 + "Fed AC": "fedac", "FedAC": "fedac", 143 + "HP-GL": "hp-gl", "HPGL": "hp-gl", 144 + "RS-232": "rs-232", "RS 232": "rs-232", 145 + "PALS": "pals", 146 + "Arxiv": "arxiv", "ArXiv": "arxiv", 147 + "TikZ": "tikz", "Tik Z": "tikz", 148 + "DAW": "daw", 149 + "Metal": "metal", 150 + "Xcode": "xcode", "XCode": "xcode", 151 + "Toughbook": "toughbook", "Panasonic": "panasonic", 152 + "Xbox": "xbox", "X-box": "xbox", 153 + "Electron": "electron", 154 + "SIGGRAPH Asia": "siggraph asia", "SIGGRAPH": "siggraph", 155 + "Casey": "casey", 156 + "Esteban Uribe": "esteban uribe", "Esteban": "esteban", 157 + "Claude": "claude", 158 + "Mac": "mac", "MacBook": "macbook", 159 + "Jeffrey": "jeffrey", "Fia": "fia", 160 + "Honeydo": "honeydo", 161 + "CV": "cv", "CLI": "cli", "PVC": "pvc", 162 + }, 163 + 164 + segments: [ 165 + { name: "01_title", marker: "hey everybody" }, 166 + { name: "02_menuband_arc", marker: "shipped six releases" }, 167 + { name: "03_waveform_strip", marker: "a real waveform strip" }, 168 + { name: "04_floating_palette", marker: "the floating palette itself" }, 169 + { name: "05_menuband_engineering", marker: "the metal shader" }, 170 + { name: "06_slab_polygon_lid", marker: "the polygon status" }, 171 + { name: "07_hp_plotter", marker: "on the hardware bench" }, 172 + { name: "08_fedac_native", marker: "the grand piano sample" }, 173 + { name: "09_arxiv_figures", marker: "zooming out" }, 174 + { name: "10_arxiv_prose", marker: "isomorphic layouts" }, 175 + { name: "11_papers_infra", marker: "a deadlines page" }, 176 + { name: "12_electron_arena", marker: "the xbox controller bridge" }, 177 + { name: "13_recap_pipeline", marker: "and the recap pipeline" }, 178 + { name: "14_chat", marker: "from the chats" }, 179 + { name: "15_outro", marker: "thanks for watching" }, 180 + { name: "16_end", marker: "__END__", trailingSilenceSec: 3 }, 181 + ], 182 + 183 + slides: { 184 + "01_title": { 185 + colorAddress: colorAddress("mintcream"), 186 + // The aesthetic-24 title motif: 24 jeffreys, 24 Citrus-green Neos, 187 + // vertical space, all with an update to share. Constant across 188 + // every episode — vary lighting / architectural conceit if needed 189 + // but keep the ensemble. 190 + metaphor: `Photographic real-estate / architectural-magazine style \ 191 + group photo, candid not staged. The man in the reference photos appears as \ 192 + TWENTY-FOUR INSTANCES of himself populating a tall, cool, vertically \ 193 + oriented MULTI-TIER SCAFFOLDED LOFT — open wooden platforms / metal \ 194 + scaffolding stacked vertically across the frame, exposed beams, open \ 195 + risers between tiers. About four to six tiers stacked top to bottom of \ 196 + the 1024x1536 portrait frame, with three to five jeffreys per tier at \ 197 + their own little stations. Airy, daylit, warm-cream walls behind, soft \ 198 + natural light from skylights or tall windows. \ 199 + \ 200 + ALL 24 instances are the SAME MAN — same face, same medium-length brown \ 201 + hair, same features as the references — clones of the same person. Each \ 202 + jeffrey is in DIFFERENT casual home clothing from his REAL wardrobe \ 203 + (NOT a uniform, NEVER tank tops, NEVER costumes): hoodies in varied \ 204 + muted colors (gray, navy, olive, burgundy, cream), button-down shirts \ 205 + (flannel patterns or plain solid colors), printed t-shirts (lowercase \ 206 + single-word screenprints — soft cotton casual), cardigans in earth \ 207 + tones, pullover sweaters with subtle patterns or stripes, flannels over \ 208 + a tee. Hair varies slightly in disarray. They're recognizable as the \ 209 + SAME PERSON in different moods / outfits, NOT a uniformed assembly. \ 210 + \ 211 + EACH JEFFREY IS BUSY WITH MULTIPLE DEVICES — he's a busy guy. Standard \ 212 + station setup: at LEAST one open Citrus-green Apple MacBook Neo (Apple's \ 213 + bright yellow-green color) — the green laptops are the unifying visual \ 214 + across all 24 stations. PLUS each station also has 1–2 of these EXTRAS \ 215 + varying across the ensemble: a second laptop (sometimes an older black \ 216 + ThinkPad next to the green Neo), a smartphone in hand, a fountain pen \ 217 + mid-write on a notebook, a clipboard of papers, an iPad on a stand, a \ 218 + small handheld game device, an extra external monitor, a coffee mug, a \ 219 + synth keyboard, headphones around the neck, a microphone arm. Some \ 220 + jeffreys have a laptop ON THEIR LAP and another on the desk. Some have \ 221 + the Neo open with a phone in their other hand. Variety — not every \ 222 + station identical. The vibe is "everyone has more than they can do." \ 223 + \ 224 + Each jeffrey is in a small "I have an update to share!" pose — variety \ 225 + of micro-expressions and gestures, all chest-level: one leaning back \ 226 + gesturing at his screen, one turning to a neighbor with a finger raised \ 227 + mid-explanation, one holding up a paper at his desk-mate, one mid-laugh \ 228 + pointing at his laptop, one typing intently and grinning, one writing \ 229 + in a notebook with the fountain pen, one talking on the phone, one in \ 230 + a small mock-shock oh-face, one nodding along with headphones. Most \ 231 + face toward the camera or toward a neighbor. None directly above \ 232 + another's head. \ 233 + \ 234 + EXACTLY ONE of the 24 jeffreys (just one — somewhere in the middle of \ 235 + the frame, not on the edges) is mid-puff on his vape — a small \ 236 + USB-stick-shaped pen visible in his hand with its tiny LED tip glowing. \ 237 + A single natural wisp of vape vapor curls up from his station, catching \ 238 + the warm light as it drifts toward the upper tier. NOT heavy fog, NOT \ 239 + multiple vapers — just one, a small atmospheric detail. Other jeffreys \ 240 + may have their vape pens visible on their desks (resting beside the \ 241 + laptop) but only the one is actively puffing. \ 242 + \ 243 + LIGHTING (richer for the cover): the loft has multiple practical light \ 244 + sources to give the frame depth — warm Edison-bulb pendant lamps hanging \ 245 + between tiers, a row of soft mintcream string lights running along one \ 246 + wall or the ceiling beams, a tall floor lamp throwing warm light from one \ 247 + corner, plus the bright daylight through skylights / tall windows. The \ 248 + mix creates pools of warm light and softer shadow, layered across the \ 249 + scaffolding, with the vape vapor catching glints. Cinematic-but-candid \ 250 + photographic depth, not flat magazine evenness. \ 251 + \ 252 + Real photograph, photo-realistic — slightly architectural-magazine but \ 253 + candid, like a friend dropped by during a busy work morning. Slight film \ 254 + grain, real shadows, real depth. Identity grounded by the references. NOT \ 255 + illustrated, NOT painted, NOT cinematic-glossy, NOT a uniform/team-photo. \ 256 + Vertical 1024x1536 portrait orientation. \ 257 + \ 258 + COMPOSITION (the title text overlays the top of the frame): \ 259 + The TOP ~35% (y=0..540 of the 1024x1536 photo) is QUIET BACKGROUND ONLY \ 260 + — empty wall, ceiling beams, sky through skylight, upper-tier rafters, \ 261 + or hanging plant. NO JEFFREYS, NO LAPTOPS in this top zone. The first \ 262 + / topmost row of jeffreys begins at y≈600 of the photo, not earlier. \ 263 + The 24-jeffrey ensemble lives in the central band (y≈600..1150). The \ 264 + BOTTOM 25% (y=1150..1536) shows the floor / foundation / open foreground \ 265 + — no jeffreys cropped at the bottom edge.`, 266 + queries: { photo: { glob: "recap/out/jeffrey-photos/01_title.png" } }, 267 + body: ({ photo }) => titleSlide({ 268 + photo, 269 + color: colorAddress("mintcream"), 270 + }), 271 + }, 272 + 273 + "02_menuband_arc": { 274 + colorAddress: colorAddress("orangered"), 275 + metaphor: `${REAL} 276 + 277 + Scene: he is at a home desk with a Citrus-green Apple MacBook Neo (Apple's \ 278 + bright yellow-green color) open in front of him. Anchored to the top-right \ 279 + corner of the screen, a small floating panel droops down from the menu bar — \ 280 + inside it, a tiny piano keyboard with each key glowing a different rainbow \ 281 + hue, a thin level meter, and a single bold word in chunky custom geometric \ 282 + letterforms across the top. A purple square app icon with two black piano \ 283 + keys peeks from the dock. Pinned along the top edge of the laptop's lid \ 284 + with painter's tape: a paper ladder of six index cards in ascending order, \ 285 + hand-numbered "0.4 / 0.5 / 0.6 / 0.7 / 0.8 / 0.9" in black sharpie, with \ 286 + the topmost card circled twice in fat orangered marker and a victory check \ 287 + beside it. He is leaning forward toward the laptop with both fists pumped at chest \ 288 + height in a mock-celebratory pose, FACE FULLY VISIBLE turned to the camera, \ 289 + mouth wide open in a HUGE proud laugh, eyes wide and bright, eyebrows up, \ 290 + head upright (not tilted back). Both hands stay below shoulder level. An \ 291 + orangered desk lamp directly to his right throws a hot orangered wash \ 292 + across the left side of his face, the laptop lid, and the index-card \ 293 + ladder; the right side of the room stays cool and unlit. Hoodie, slightly \ 294 + bedheaded. Shirt word (visible under the open hoodie): \`menuband\`.`, 295 + queries: { photo: { glob: "recap/out/jeffrey-photos/02_menuband_arc.png" } }, 296 + body: ({ photo }) => photoSlide({ 297 + photo, 298 + chapter: "01 / 14 · menuband · 0.4 → today", 299 + title: "six releases,\nvoice palette\n→ chord finder", 300 + cap: "chord finder · rainbow keys · slim meter · sharp title", 301 + color: colorAddress("orangered"), 302 + }), 303 + }, 304 + 305 + "03_waveform_strip": { 306 + colorAddress: colorAddress("deeppink"), 307 + metaphor: `${REAL} 308 + 309 + Scene: he is at a kitchen table in front of his Citrus-green MacBook Neo \ 310 + (Apple's bright yellow-green color), a black wired mechanical keyboard \ 311 + pulled forward into his lap, both hands mid-chord on the home row. A small \ 312 + popover is open in the upper-right of the screen with a tiny piano keyboard \ 313 + at the top and, just below it, a row of about sixteen short stacked-segment \ 314 + bars lit hot-pink, mid-bounce — clearly reacting to the chord he's holding. \ 315 + Wired over-ear headphones are around his neck, one cup off-ear so he can \ 316 + still hear the room. His mouth is open in a wide surprised laugh, eyebrows \ 317 + up, like the bars jumped higher than he expected. Diegetic deeppink: a \ 318 + hot-pink LED bias strip taped to the back of the laptop lid spills onto \ 319 + the wall behind him in a soft halo, and a small pink neon "OPEN" sign on \ 320 + the windowsill throws a second pink wash across his cheek and the keys. A \ 321 + half-empty water glass picks up the pink as a magenta highlight on its \ 322 + rim. Evening, kitchen, lived-in. Shirt word: \`menuband\`.`, 323 + queries: { photo: { glob: "recap/out/jeffrey-photos/03_waveform_strip.png" } }, 324 + body: ({ photo }) => photoSlide({ 325 + photo, 326 + chapter: "02 / 14 · menuband · audio strip", 327 + title: "slides under\nthe piano,\non note activity", 328 + cap: "shared meter · menubar · popover · palette", 329 + color: colorAddress("deeppink"), 330 + }), 331 + }, 332 + 333 + "04_floating_palette": { 334 + colorAddress: colorAddress("mediumspringgreen"), 335 + metaphor: `${REAL} 336 + 337 + Scene: bright morning at his wooden home desk, Citrus-green MacBook Neo \ 338 + (Apple's bright yellow-green color) open in front of him. A small \ 339 + translucent tool window has clearly been ripped free of the top menu bar \ 340 + and is hovering above the desktop area — you can see a faint \ 341 + mediumspringgreen tracer line in the air showing the path it just \ 342 + travelled down from the top of the screen. The window is alive: a dark \ 343 + inset visualizer at the top with bright pills along its edge spelling out \ 344 + three held notes, a chunky stylized piano in the middle, a smaller \ 345 + computer-keyboard map beneath it. He is mid-drag, one fingertip pressed \ 346 + precisely on the slim grip bar at the very top of the window — not the \ 347 + body — eyes wide and mouth open in a huge cartoon laugh, like he just \ 348 + caught a butterfly. His other hand thrown up in mock-celebration. A short \ 349 + string of mediumspringgreen LED fairy lights is taped along the wall \ 350 + behind the monitor, washing his face and the floating window in soft \ 351 + #00fa9a glow; that glow is the only colored light in the frame. Shirt word: \ 352 + \`menuband\`.`, 353 + queries: { photo: { glob: "recap/out/jeffrey-photos/04_floating_palette.png" } }, 354 + body: ({ photo }) => photoSlide({ 355 + photo, 356 + chapter: "03 / 14 · floating palette", 357 + title: "tear it off,\ntake it\nwith you", 358 + cap: "grab the top strip · floats over anything", 359 + color: colorAddress("mediumspringgreen"), 360 + }), 361 + }, 362 + 363 + "05_menuband_engineering": { 364 + colorAddress: colorAddress("royalblue"), 365 + metaphor: `${REAL} 366 + 367 + Scene: he is at a wooden home desk, both hands deliberately planted on \ 368 + the keyboard of an open Citrus-green MacBook Neo (Apple's bright \ 369 + yellow-green color) — left pinky pinning the shift key, ring finger \ 370 + latched on caps lock, right hand spread across four white keys at once. \ 371 + A small popover floats above the menubar showing a real two-octave piano \ 372 + keyboard with FOUR keys simultaneously lit royalblue (#4169e1), the \ 373 + labels rendered in UPPERCASE as the linger cue, and a tiny royalblue \ 374 + tilde flourish curling off the music note icon. A horizontal level strip \ 375 + below the piano glows the same royalblue, four overlapping lanes clearly \ 376 + stacked (polyphony, not one). The royalblue light from the screen rakes \ 377 + across his face and catches the underside of his chin and his glasses. A \ 378 + small royalblue desk lamp on the left edge of the desk adds a second \ 379 + source on his left cheek. He is leaning into the keyboard like a smug \ 380 + church organist mid-chord — chest puffed, eyebrows up, a knowing \ 381 + closed-mouth proud-dad smirk aimed at the camera, mock-conductor \ 382 + confidence. He is wearing a soft button-down with the word \`menuband\` \ 383 + embroidered in clean white stitch on the chest pocket — small, subtle, \ 384 + the kind of detail you'd only notice if you leaned in.`, 385 + queries: { photo: { glob: "recap/out/jeffrey-photos/05_menuband_engineering.png" } }, 386 + body: ({ photo }) => photoSlide({ 387 + photo, 388 + chapter: "04 / 14 · engine room", 389 + title: "shader compiles\nitself,\neight voices,\nhold to ring", 390 + cap: "quiet when hidden · shift / caps-lock linger", 391 + color: colorAddress("royalblue"), 392 + }), 393 + }, 394 + 395 + "06_slab_polygon_lid": { 396 + colorAddress: colorAddress("slateblue"), 397 + metaphor: `${REAL} 398 + 399 + Scene: he is sitting on a rug on the floor at night, a closed Citrus-green \ 400 + MacBook Neo (Apple's bright yellow-green color) on the rug in front of him, \ 401 + lid down. He is leaning his ear an inch from the closed laptop's hinge \ 402 + seam, listening — eyebrows shot up to his hairline, eyes wide, mouth \ 403 + slightly open in a "wait, really?" expression, mock-stunned that the \ 404 + closed laptop is humming a soft pad at him. A slateblue (#6a5acd) \ 405 + night-table lamp on the floor behind him is the only room light, washing \ 406 + one side of his face and the laptop lid in moody slateblue. On the wall \ 407 + behind him, a second small monitor is left awake — its screen tiny in the \ 408 + frame but legible: the macOS menubar with a small five-sided polygon icon, \ 409 + all five edges the same slateblue hue, sitting next to the clock. He has \ 410 + one hand cupped behind his ear, the other holding a mug. Late-night, very \ 411 + quiet, one houseplant casting a long shadow. Shirt word: \`chat\`.`, 412 + queries: { photo: { glob: "recap/out/jeffrey-photos/06_slab_polygon_lid.png" } }, 413 + body: ({ photo }) => photoSlide({ 414 + photo, 415 + chapter: "05 / 14 · slab · one shape, one hue", 416 + title: "n tabs,\nn-sided shape,\ngreen slides red\nas the queue grows", 417 + cap: "closed lid hums when claude is waiting", 418 + color: colorAddress("slateblue"), 419 + }), 420 + }, 421 + 422 + "07_hp_plotter": { 423 + colorAddress: colorAddress("olivedrab"), 424 + metaphor: `${REAL} 425 + 426 + Scene: he is kneeling on the concrete floor of a cluttered home garage \ 427 + next to a real, large 1980s-era HP 7585B drafting plotter — a wide \ 428 + refrigerator-sized beige-cream pen plotter with a black control strip on \ 429 + the right, eight technical pens visible in a circular carousel along the \ 430 + top edge, and a fresh sheet of white paper loaded across the bed. A \ 431 + thick chunky vintage serial cable with two big screws on its connector \ 432 + runs from the back of the plotter down to a Citrus-green MacBook Neo \ 433 + (Apple's bright yellow-green color) sitting open on an upturned milk \ 434 + crate next to him. The plotter's pen arm is mid-stroke, drawing the PALS \ 435 + mark — a small purple hand-shaped glyph, like a stylized wave with \ 436 + sprouting fingers — in glossy purple ink on the paper. Diegetic olivedrab \ 437 + (#6b8e23) light: a clamp-on metal-shop work lamp hooked to a shelf \ 438 + above, fitted with an olivedrab gel that throws an olivedrab wash across \ 439 + the plotter's case and the paper; a long olivedrab fluorescent tube \ 440 + buzzes overhead; olivedrab safety glasses pushed up on his forehead \ 441 + catching the light. He is throwing his head back in a giant belly-laugh, \ 442 + mouth wide open, eyes crinkled shut, both hands half-raised in a "it's \ 443 + actually drawing!" mock-celebration. Concrete floor, pegboard wall behind. \ 444 + Shirt word: \`line\`.`, 445 + queries: { photo: { glob: "recap/out/jeffrey-photos/07_hp_plotter.png" } }, 446 + body: ({ photo }) => photoSlide({ 447 + photo, 448 + chapter: "06 / 14 · hp 7585b plotter", 449 + title: "hp 7585b,\nover serial", 450 + cap: "first plot: the pals mark, in purple", 451 + color: colorAddress("olivedrab"), 452 + }), 453 + }, 454 + 455 + "08_fedac_native": { 456 + colorAddress: colorAddress("sienna"), 457 + metaphor: `${REAL} 458 + 459 + Scene: he is in a real home garage / workshop — concrete floor, exposed \ 460 + stud wall, a workbench with a vise, scattered cables, a clip-on shop \ 461 + lamp. He is standing in the middle of the shot holding up an open \ 462 + Panasonic Toughbook with both hands like a championship trophy: a \ 463 + chunky ruggedized laptop, black-and-tan industrial plastic with big \ 464 + bumpered corner armor, a thick keyboard, an integrated handle on the \ 465 + lid hinge — clearly a field machine, not a consumer laptop. The \ 466 + Toughbook screen is on and clearly shows a custom operating system \ 467 + booting: green-on-black terminal text scrolling a boot log, mono font, \ 468 + a small ASCII banner at the top, the screen actually emitting a soft \ 469 + phosphor green glow onto his face and chin. The room is lit \ 470 + diegetically by an old sienna-shaded enamel desk lamp clamped to the \ 471 + workbench, throwing warm sienna (#a0522d) brown-orange light across \ 472 + the back wall, plus late sienna sunset coming through a small dusty \ 473 + workshop window on the right — both real practical light sources in \ 474 + the scene, no overlay. His Citrus-green MacBook Neo (Apple's bright \ 475 + yellow-green color) sits closed on the workbench in the background, \ 476 + clearly the everyday laptop set aside while the Toughbook gets booted. \ 477 + His expression is HYPERBOLIC mock-celebratory: huge proud-dad \ 478 + eyebrow-raise, mouth open in a triumphant "ehhhh" grin, like he just \ 479 + won a small trophy at a county fair. He's in a worn flannel button-down \ 480 + with \`os\` embroidered in white stitch on the chest pocket — subtle, \ 481 + hand-tailored feeling, perfect for the workshop.`, 482 + queries: { photo: { glob: "recap/out/jeffrey-photos/08_fedac_native.png" } }, 483 + body: ({ photo }) => photoSlide({ 484 + photo, 485 + chapter: "07 / 14 · ac native os", 486 + title: "piano level,\ntoughbook\nbench open", 487 + cap: "piano sits with sine + square · toughbook is next", 488 + color: colorAddress("sienna"), 489 + }), 490 + }, 491 + 492 + "09_arxiv_figures": { 493 + colorAddress: colorAddress("indigo"), 494 + metaphor: `${REAL} 495 + 496 + Scene: a home desk completely buried in printed pages of the same \ 497 + academic preprint, fanned out and overlapping. Visible on specific \ 498 + sheets: one page shows three keyboard outlines drawn in clean \ 499 + black-on-white technical line art, stacked vertically with the letter \ 500 + caps labeled. Another shows a honeycomb of hexagonal buttons each \ 501 + labeled with a note name, with three of the hexes shaded to mark a \ 502 + triad. Another shows two horizontal strings with EADGBE and GDAE marked \ 503 + along them. A small staircase chart with labeled steps sits at one \ 504 + corner. He is holding an indigo highlighter mid-stroke, finger raised \ 505 + in super-smug professor-pointing mode — that "actually-" gesture, mouth \ 506 + open, eyes wide, deeply pleased with himself. The Citrus-green MacBook \ 507 + Neo (Apple's bright yellow-green color) on the desk shows the paper's \ 508 + PDF preview, screen tilted toward camera. Diegetic indigo light source: \ 509 + a deep-indigo glass-shaded incandescent desk lamp with the bulb glowing \ 510 + through the colored glass, throwing indigo cast across the pages and \ 511 + onto his face from the side. The room behind is dim; indigo is the \ 512 + only colored light. Shirt word: \`papers\`.`, 513 + queries: { photo: { glob: "recap/out/jeffrey-photos/09_arxiv_figures.png" } }, 514 + body: ({ photo }) => photoSlide({ 515 + photo, 516 + chapter: "08 / 14 · arxiv-keymaps · figures", 517 + title: "qwerty/dvorak,\ndaw chromatic,\nwicki-hayden,\nvim, singmaster", 518 + cap: "asymmetric instruments · guitar + violin tunings", 519 + color: colorAddress("indigo"), 520 + }), 521 + }, 522 + 523 + "10_arxiv_prose": { 524 + colorAddress: colorAddress("darksalmon"), 525 + metaphor: `${REAL} 526 + 527 + Scene: he is curled into a worn reading chair, knees up, the Walter Ong \ 528 + "Orality and Literacy" paperback open across one thigh — cream cover, \ 529 + red title block visible on the back as it folds, slim Methuen-era \ 530 + paperback creased at the spine. In his other hand he holds a printed \ 531 + manuscript page; two phrases are circled in darksalmon highlighter and \ 532 + clearly legible across the page: "WASD" and "notation surface." A \ 533 + darksalmon Tiffany-style stained-glass reading lamp is clamped to the \ 534 + chair arm and pours warm pink-orange light across his face, the book, \ 535 + and the printed page — every shadow is salmon-tinted because the lamp \ 536 + is the only light source. His Citrus-green MacBook Neo (Apple's bright \ 537 + yellow-green color) is on the rug at his feet, screen tilted up, \ 538 + showing the .tex draft in a plain editor. A small stack of paperbacks \ 539 + leans against the chair leg. His expression is hyperbolic \ 540 + scholar-amazed: head tipped back a quarter-turn, eyes wide and round \ 541 + behind his glasses, mouth open in a silent "oh!" — the look of \ 542 + realizing one paragraph just answered another. Evening, no overhead \ 543 + light, just the salmon glow. He's in a soft cream button-down with the \ 544 + word \`papers\` embroidered in muted thread on the chest pocket — \ 545 + quiet, scholarly, fits the reading-chair register.`, 546 + queries: { photo: { glob: "recap/out/jeffrey-photos/10_arxiv_prose.png" } }, 547 + body: ({ photo }) => photoSlide({ 548 + photo, 549 + chapter: "09 / 14 · arxiv-keymaps · prose", 550 + title: "ong + wasd,\nnotation surface,\nlgbtq+ framing", 551 + cap: "live-coding mini-notations · tidal, strudel, hydra, abc", 552 + color: colorAddress("darksalmon"), 553 + }), 554 + }, 555 + 556 + "11_papers_infra": { 557 + colorAddress: colorAddress("mediumaquamarine"), 558 + metaphor: `${REAL} 559 + 560 + Scene: he is at a wooden home desk in front of a Citrus-green MacBook \ 561 + Neo (Apple's bright yellow-green color). The laptop screen shows a \ 562 + calendar-style list of upcoming dates with venue names visibly readable \ 563 + in the rows — "SIGGRAPH ASIA," "fia-grant," "keymaps," "platter" — \ 564 + stacked like a month view. Running along the desk's left edge is a neat \ 565 + column of FOUR real mediumaquamarine (#66cdaa) sticky notes, each \ 566 + hand-labeled in black sharpie with one of those same names, arranged \ 567 + like a physical mirror of the on-screen list. Beside the laptop, a \ 568 + small iPad in a stand shows a prompt-style input with the word \ 569 + "deadlines" half-typed and a dropdown suggestion underneath it, glowing \ 570 + faintly. Next to a few rows on the laptop, smaller secondary lines \ 571 + visibly lead to draft links — described as little tab-stops of \ 572 + paper-clipped index cards taped to the bezel. On the wall behind him, \ 573 + a small real wooden picture frame holds a printed grid of jeffrey \ 574 + portraits — the restored platter, hanging like a family photo. \ 575 + Diegetic mediumaquamarine: a small mediumaquamarine-shaded gooseneck \ 576 + desk lamp clamped to the desk pours green-cyan light across his face \ 577 + and the stickies; a thin LED strip behind the laptop washes the wall \ 578 + the same hue. He is leaning forward toward the camera with both palms turned up at \ 579 + chest level — game-show host "BEHOLD" style, hands NEVER above shoulder \ 580 + level — face fully visible centered in the frame, mouth wide open in big \ 581 + proud laugh, eyes wide, eyebrows up. Hyperbolic, theatrical, like a \ 582 + game-show host revealing a board. Shirt word: \`papers\`.`, 583 + queries: { photo: { glob: "recap/out/jeffrey-photos/11_papers_infra.png" } }, 584 + body: ({ photo }) => photoSlide({ 585 + photo, 586 + chapter: "10 / 14 · papers · infrastructure", 587 + title: "deadlines page,\ndraft links,\nvenue list,\nplatter back", 588 + cap: "fia's opportunities · cv hidden · keymaps on the books", 589 + color: colorAddress("mediumaquamarine"), 590 + }), 591 + }, 592 + 593 + "12_electron_arena": { 594 + colorAddress: colorAddress("crimson"), 595 + metaphor: `${REAL} 596 + 597 + Scene: he is at a real home desk gripping a black Xbox Wireless \ 598 + Controller in both hands, thumbs on the sticks — the matte-black shell, \ 599 + the four colored face buttons in their classic ABXY diamond, the \ 600 + textured grips clearly visible. The green A button is glowing hot \ 601 + crimson from underneath, like it just got slammed. His mouth is wide \ 602 + open in a screaming oh-face — a hyperbolic "I'M IN" gamer howl, eyes \ 603 + huge, eyebrows up, head tilted slightly back. Behind the controller, \ 604 + his open Citrus-green MacBook Neo (Apple's bright yellow-green color) \ 605 + shows a 3D first-person arena scene: a tessellated grid floor, a low \ 606 + horizon, a small remote player avatar visible across the field, a \ 607 + speed meter HUD glowing in the corner. Beside the laptop, a small \ 608 + black command-line window with green-on-black text scrolls quietly. \ 609 + Diegetic crimson: a long crimson gaming LED strip taped to the back \ 610 + edge of the monitor, washing the wall behind in deep crimson, with a \ 611 + smaller crimson rope light under the desk catching the controller from \ 612 + below — the entire room half-bathed in red as if a "PLAYER 1 READY" \ 613 + sign somewhere off-frame just lit up. Empty energy-drink can on the \ 614 + desk. Slightly bedhead, hoodie. Slight motion blur on the thumbs. Shirt \ 615 + word (visible under the open hoodie): \`arena\`.`, 616 + queries: { photo: { glob: "recap/out/jeffrey-photos/12_electron_arena.png" } }, 617 + body: ({ photo }) => photoSlide({ 618 + photo, 619 + chapter: "11 / 14 · ac-electron + arena", 620 + title: "xbox bridge\nactually loads,\nA + space\nrespawn", 621 + cap: "ac-desktop cli · arena dead zone widened", 622 + color: colorAddress("crimson"), 623 + }), 624 + }, 625 + 626 + "13_recap_pipeline": { 627 + colorAddress: colorAddress("palevioletred"), 628 + metaphor: `${REAL} 629 + 630 + Scene: he is at a real wooden home desk, leaned slightly forward, \ 631 + fanning out a thick stack of fresh polaroid-style prints toward the \ 632 + camera like a poker hand. Each print in the fan is unmistakably a \ 633 + DIFFERENT staged candid of HIM — same face across all of them, but in \ 634 + clearly different rooms, outfits, poses (one at a kitchen table, one \ 635 + in a garage, one in evening lamplight, one mid-laugh, one deadpan). \ 636 + Two more of the same prints are pinned to a corkboard on the wall \ 637 + behind him. A small old-fashioned brass desk bell with a round \ 638 + push-button sits next to the laptop, catching a highlight. An open \ 639 + Citrus-green MacBook Neo (Apple's bright yellow-green color) on the \ 640 + desk shows a green-on-black terminal scrolling a build log — \ 641 + recognizable shell text, no readable filenames. A single sheet of \ 642 + hand-drawn waltz sheet music — three-four time signature, pencil \ 643 + quarter notes — is taped to the desk lamp arm. Diegetic palevioletred \ 644 + (#db7093) light is everywhere and clearly REAL: a small palevioletred \ 645 + Tiffany-style stained-glass desk lamp glows warm pink across his \ 646 + cheekbone and the polaroids; a thin palevioletred neon sign on the \ 647 + wall reads "RECAP" in a single cursive word; palevioletred fairy \ 648 + lights wrap the monitor bezel; a palevioletred-tinted curtain backlights \ 649 + the window behind him. Hyperbolic expression: head thrown back in a full \ 650 + belly laugh, eyes scrunched, both shoulders up — caught in the middle \ 651 + of cracking up at being inside the recap that's about the recap. Hoodie, \ 652 + slightly bedheaded. Shirt word (visible under the open hoodie): \`tapes\`.`, 653 + queries: { photo: { glob: "recap/out/jeffrey-photos/13_recap_pipeline.png" } }, 654 + body: ({ photo }) => photoSlide({ 655 + photo, 656 + chapter: "12 / 14 · recap pipeline", 657 + title: "portraits\npre-baked,\nsine-bells\nwaltz bed", 658 + cap: "each cut gets its own soft bell waltz", 659 + color: colorAddress("palevioletred"), 660 + }), 661 + }, 662 + 663 + "14_chat": { 664 + colorAddress: colorAddress("khaki"), 665 + // No metaphor — chat slide is text-only. Renders the latest messages 666 + // pulled by `bin/chat-fetch.mjs` from /api/chat-messages?instance=clock 667 + // (laer-klokken) and instance=system (main chat) into out/chat-snapshot.json. 668 + queries: { snapshot: { json: "recap/out/chat-snapshot.json" } }, 669 + body: ({ snapshot }) => chatSlide({ 670 + snapshot: snapshot || { clock: [], system: [] }, 671 + chapter: "13 / 14 · chat", 672 + title: "what people\nare saying", 673 + cap: "laer-klokken · chat · last 73 hours", 674 + color: colorAddress("khaki"), 675 + }), 676 + }, 677 + 678 + "15_outro": { 679 + colorAddress: colorAddress("lavenderblush"), 680 + metaphor: `${REAL} 681 + 682 + Scene: he is leaning back in a desk chair holding a coffee mug, giving a \ 683 + small relaxed wave to the camera with his free hand. The Citrus-green laptop \ 684 + in front of him on the desk shows a screen reading "thanks for watching" \ 685 + in calm cream-on-dark text with a thin lavenderblush (#fff0f5) underline. \ 686 + A lavenderblush curtain is visible behind him, softly catching evening \ 687 + light. He has a small real smile, eye contact with the camera. \ 688 + Comfortable home setting — desk lamp on, warm white balance, a houseplant \ 689 + or two visible. Real iPhone candid snapshot energy. He's in a relaxed button-down with \`prompt\` embroidered in tiny white stitch on the cuff of his sleeve — visible only because his wrist is raised in the wave.`, 690 + queries: { photo: { glob: "recap/out/jeffrey-photos/15_outro.png" } }, 691 + body: ({ photo }) => photoSlide({ 692 + photo, 693 + chapter: "14 / 14 · outro", 694 + title: "thanks for\nwatching", 695 + cap: "aesthetic.computer · @jeffrey", 696 + color: colorAddress("lavenderblush"), 697 + }), 698 + }, 699 + 700 + "16_end": ` 701 + <div class="frame"> 702 + <div class="pals med"></div> 703 + <div class="endline" style="color:${PALETTE.cream}">aesthetic·computer</div> 704 + <div class="endsub" style="color:${PALETTE.dim}">narrated by jeffrey-pvc · @jeffrey</div> 705 + <div class="endsub" style="color:${PALETTE.dim}">2026·04·29 → 2026·05·02</div> 706 + </div>`, 707 + }, 708 + }; 709 + 710 + // Slide chrome only — chapter color is DIEGETIC inside the photo (real 711 + // in-scene light source) and the only on-slide overlays are EDITORIAL: 712 + // chapter line + title + caption + color chip. No artifact insets — any 713 + // production-URL or app visuals that need to appear should appear as 714 + // content on screens within the rendered photo, not as overlay cards. 715 + // 716 + // Each title gets a luma-aware drop shadow so it stays readable against 717 + // any photo: a sharp reverse-color 2px offset plus a soft halo. Bright 718 + // chapter colors get black shadows; dark chapter colors get white ones. 719 + // Episodic series number — every recap cut is an "episode" in the 720 + // jeffrey-recap series ("aesthetic 24"). Bump for each new audience 721 + // config. (The 24h-2026-04-30 cut was episode 1; this 73h-2026-05-02 722 + // cut, which also folds in the 24h-2026-05-01 work, is episode 2.) 723 + const EPISODE = 2; 724 + const SHOW_NAME = "aesthetic 24"; 725 + const EPISODE_DATE = "2026·05·02"; 726 + const EPISODE_HOOK = "the last\n73 hours"; 727 + 728 + // Slide code: "e<N>-<color>". This is the ONLY editorial overlay on the 729 + // photo per frame — the narration carries the verbal content; the photo 730 + // carries the vibe. Title and cap are kept on each slide as data (for 731 + // the SEO description / future use) but no longer rendered in the chrome. 732 + function slideCode(color) { 733 + return `e${EPISODE}-${color.name}`; 734 + } 735 + 736 + function readabilityShadow(rgb) { 737 + const luma = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; 738 + const dark = "rgba(0,0,0,0.95)"; 739 + const light = "rgba(255,255,255,0.95)"; 740 + const blur = luma > 140 ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.55)"; 741 + const sharp = luma > 140 ? dark : light; 742 + return `2px 2px 0 ${sharp}, -1px -1px 0 ${sharp}, 0 0 18px ${blur}`; 743 + } 744 + 745 + // Title slide — the "newscast intro" frame. Shows the show wordmark 746 + // "aesthetic 24", the episode tag, and the episode hook (the line 747 + // jeffrey says first). Standard slide-code in the upper-left for 748 + // consistency with the rest of the deck. 749 + function titleSlide({ photo, color }) { 750 + const code = slideCode(color); 751 + const codeShadow = readabilityShadow(color.rgb); 752 + const creamShadow = "2px 2px 0 rgba(0,0,0,0.95), -1px -1px 0 rgba(0,0,0,0.7), 0 0 18px rgba(0,0,0,0.55)"; 753 + const subShadow = "1px 1px 0 rgba(0,0,0,0.92), 0 0 14px rgba(0,0,0,0.6)"; 754 + const hookHtml = EPISODE_HOOK.split("\n").map((l) => `<div>${l}</div>`).join(""); 755 + return ` 756 + <div style="position: fixed; inset: 0; padding: 0; overflow: hidden;"> 757 + ${photo 758 + ? `<img src="${photo}" style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;" />` 759 + : `<div style="position: absolute; inset: 0; background: ${PALETTE.bg};"></div>`} 760 + <div style="position: absolute; top: 0; left: 0; right: 0; padding: 130px 70px 80px; background: linear-gradient(to bottom, rgba(0,0,0,0.86) 0%, rgba(0,0,0,0.5) 60%, rgba(0,0,0,0) 100%);"> 761 + <div style="font-family: 'ProcessingB'; font-size: 38px; letter-spacing: 2px; color: ${color.hex}; text-shadow: ${codeShadow};">${code}</div> 762 + <div style="font-family: 'ProcessingB'; font-size: 64px; letter-spacing: 6px; color: ${PALETTE.cream}; margin-top: 22px; text-shadow: ${creamShadow};">${SHOW_NAME}</div> 763 + <div style="font-family: 'ProcessingR'; font-size: 30px; letter-spacing: 5px; color: ${PALETTE.off}; margin-top: 10px; text-shadow: ${subShadow};">ep ${String(EPISODE).padStart(2, "0")} · ${EPISODE_DATE}</div> 764 + <div style="font-family: 'ProcessingB'; font-size: 130px; line-height: 0.96; letter-spacing: -3px; color: ${PALETTE.cream}; margin-top: 36px; text-shadow: ${creamShadow};">${hookHtml}</div> 765 + </div> 766 + <div style="position: absolute; bottom: 0; left: 0; right: 0; height: 220px; background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.2) 60%, rgba(0,0,0,0) 100%);"></div> 767 + </div>`; 768 + } 769 + 770 + function photoSlide({ photo, chapter, title, cap, color }) { 771 + const titleHtml = (title || "").split("\n").map((l) => `<div>${l}</div>`).join(""); 772 + const code = slideCode(color); 773 + // Slide code in chapter color (luma-aware shadow). Title + cap in cream 774 + // with plain dark drop shadow so the photo's chapter color stays an 775 + // accent, not the dominant typographic statement. No bottom chip / 776 + // swatch / rgb — the slide code is the only chapter-color element. 777 + const codeShadow = readabilityShadow(color.rgb); 778 + const creamShadow = "2px 2px 0 rgba(0,0,0,0.95), -1px -1px 0 rgba(0,0,0,0.7), 0 0 18px rgba(0,0,0,0.55)"; 779 + const capShadow = "1px 1px 0 rgba(0,0,0,0.92), 0 0 14px rgba(0,0,0,0.6)"; 780 + return ` 781 + <div style="position: fixed; inset: 0; padding: 0; overflow: hidden;"> 782 + ${photo 783 + ? `<img src="${photo}" style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;" />` 784 + : `<div style="position: absolute; inset: 0; background: ${PALETTE.bg};"></div>`} 785 + <div style="position: absolute; top: 0; left: 0; right: 0; padding: 130px 70px 90px; background: linear-gradient(to bottom, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.4) 55%, rgba(0,0,0,0) 100%);"> 786 + <div style="font-family: 'ProcessingB'; font-size: 38px; letter-spacing: 2px; color: ${color.hex}; text-shadow: ${codeShadow};">${code}</div> 787 + <div style="font-family: 'ProcessingB'; font-size: 110px; line-height: 1.0; letter-spacing: -2px; color: ${PALETTE.cream}; margin-top: 22px; text-shadow: ${creamShadow};">${titleHtml}</div> 788 + <div style="font-family: 'ProcessingR'; font-size: 38px; color: ${PALETTE.off}; margin-top: 26px; letter-spacing: 1px; text-shadow: ${capShadow};">${cap || ""}</div> 789 + </div> 790 + </div>`; 791 + } 792 + 793 + // Chat slide — text-only, no photo. Renders the latest messages from the 794 + // 'system' and 'clock' chat instances pulled by `bin/chat-fetch.mjs` 795 + // into `recap/out/chat-snapshot.json`. Two columns side-by-side: laer- 796 + // klokken (clock) on the left, the main chat (system) on the right. 797 + function chatSlide({ snapshot, chapter, title, cap, color }) { 798 + const fmt = (m) => ` 799 + <div style="display:flex; gap: 14px; align-items: baseline; padding: 6px 0;"> 800 + <div style="font-family:'ProcessingB'; font-size: 24px; color: ${color.hex}; min-width: 160px; flex-shrink: 0;">${escape(m.handle || "anon")}</div> 801 + <div style="font-family:'ProcessingR'; font-size: 24px; color: ${PALETTE.cream}; line-height: 1.3; word-break: break-word;">${escape(m.text || "")}</div> 802 + </div>`; 803 + const leftHtml = (snapshot?.clock || []).slice(-14).map(fmt).join(""); 804 + const rightHtml = (snapshot?.system || []).slice(-14).map(fmt).join(""); 805 + return ` 806 + <div style="position: fixed; inset: 0; padding: 0; overflow: hidden; background: ${PALETTE.bg};"> 807 + <div style="position: absolute; top: 80px; left: 70px; right: 70px;"> 808 + <div style="font-family: 'ProcessingR'; font-size: 30px; letter-spacing: 6px; text-transform: uppercase; color: ${color.hex};">${chapter}</div> 809 + <div style="font-family: 'ProcessingB'; font-size: 110px; line-height: 1.0; letter-spacing: -2px; color: ${color.hex}; margin-top: 18px;">${title.split("\n").map((l) => `<div>${l}</div>`).join("")}</div> 810 + <div style="font-family: 'ProcessingR'; font-size: 32px; color: ${PALETTE.off}; margin-top: 22px; letter-spacing: 1px;">${cap}</div> 811 + </div> 812 + <div style="position: absolute; top: 540px; bottom: 220px; left: 70px; right: 70px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px;"> 813 + <div style="border-left: 4px solid ${color.hex}; padding-left: 22px; overflow: hidden;"> 814 + <div style="font-family:'ProcessingB'; font-size:28px; letter-spacing:4px; color:${color.hex}; text-transform:uppercase; margin-bottom: 18px;">laer-klokken</div> 815 + ${leftHtml || `<div style="color:${PALETTE.dim}; font-size:24px;">(no recent messages)</div>`} 816 + </div> 817 + <div style="border-left: 4px solid ${color.hex}; padding-left: 22px; overflow: hidden;"> 818 + <div style="font-family:'ProcessingB'; font-size:28px; letter-spacing:4px; color:${color.hex}; text-transform:uppercase; margin-bottom: 18px;">chat</div> 819 + ${rightHtml || `<div style="color:${PALETTE.dim}; font-size:24px;">(no recent messages)</div>`} 820 + </div> 821 + </div> 822 + <div style="position: absolute; bottom: 110px; right: 70px; display: flex; align-items: center; gap: 22px; padding: 18px 26px 18px 22px; background: rgba(0,0,0,0.72); border: 1px solid rgba(255,255,255,0.18); border-radius: 6px;"> 823 + <div style="width: 56px; height: 56px; background: ${color.hex}; border: 1px solid rgba(255,255,255,0.35); border-radius: 4px;"></div> 824 + <div style="display: flex; flex-direction: column; gap: 4px;"> 825 + <div style="font-family: 'ProcessingB'; font-size: 30px; letter-spacing: 1px; color: ${PALETTE.cream};">${color.name}</div> 826 + <div style="font-family: 'ProcessingR'; font-size: 22px; letter-spacing: 1px; color: ${PALETTE.dim};">${color.caption}</div> 827 + </div> 828 + </div> 829 + </div>`; 830 + } 831 + 832 + function escape(s) { 833 + return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 834 + } 835 + 836 + export default audience;
+52
recap/bin/chat-fetch.mjs
··· 1 + #!/usr/bin/env node 2 + // chat-fetch.mjs — pull recent messages from both AC chat instances 3 + // (clock = laer-klokken, system = main chat) and write a snapshot file 4 + // the chat slide reads at render time. 5 + // 6 + // API: GET https://aesthetic.computer/api/chat-messages 7 + // ?instance=<clock|system>&limit=<N>&before=<ISO> 8 + // 9 + // Output: recap/out/chat-snapshot.json 10 + // { fetchedAt, clock: [{handle, text, when}, ...], system: [...] } 11 + 12 + import { mkdirSync, writeFileSync } from "node:fs"; 13 + import { resolve, dirname } from "node:path"; 14 + import { fileURLToPath } from "node:url"; 15 + 16 + const HERE = dirname(fileURLToPath(import.meta.url)); 17 + const ROOT = resolve(HERE, ".."); 18 + const OUT = `${ROOT}/out/chat-snapshot.json`; 19 + const LIMIT = Number(process.argv[2] || 30); 20 + const API = "https://aesthetic.computer/api/chat-messages"; 21 + 22 + async function fetchInstance(instance) { 23 + const url = `${API}?instance=${instance}&limit=${LIMIT}`; 24 + const res = await fetch(url); 25 + if (!res.ok) { 26 + console.error(`✗ ${instance}: ${res.status} ${res.statusText}`); 27 + return []; 28 + } 29 + const data = await res.json(); 30 + // Response shape varies; coerce to {handle, text, when} for the slide. 31 + const arr = Array.isArray(data) ? data : (data.messages || []); 32 + return arr.map((m) => ({ 33 + handle: m.handle || m.user_handle || m.user || "anon", 34 + text: m.text || m.content || m.message || "", 35 + when: m.when || m.timestamp || null, 36 + })); 37 + } 38 + 39 + const [clock, system] = await Promise.all([ 40 + fetchInstance("clock"), 41 + fetchInstance("system"), 42 + ]); 43 + 44 + mkdirSync(dirname(OUT), { recursive: true }); 45 + writeFileSync(OUT, JSON.stringify({ 46 + fetchedAt: new Date().toISOString(), 47 + clock, 48 + system, 49 + }, null, 2)); 50 + 51 + console.log(`→ chat snapshot · clock: ${clock.length} · system: ${system.length}`); 52 + console.log(`✓ ${OUT}`);
+144
recap/bin/gen-photos.mjs
··· 1 + #!/usr/bin/env node 2 + // gen-photos.mjs — photo-ONLY batch gen for an audience. No puppeteer, 3 + // no slide compositing — just OpenAI gpt-image-2 calls + save PNGs to 4 + // recap/out/jeffrey-photos/<segment>.png. This is the RAM-cheap path. 5 + // 6 + // Iterates segments, gens missing (or all with --force), one at a time 7 + // by default (1 concurrent). Up to `--concurrency N` (capped at 3) for 8 + // users with headroom. The 8 GB MacBook should stay at 1. 9 + // 10 + // Each API call holds ~30 MB working set (8 reference images + base64 11 + // response). Memory stays low. Wallclock ≈ N_photos × 200s when 12 + // concurrency=1; ≈ N_photos × 200s / N when concurrent. 13 + // 14 + // Usage: 15 + // node bin/gen-photos.mjs <audience> 16 + // node bin/gen-photos.mjs <audience> --only 02_menuband_arc 17 + // node bin/gen-photos.mjs <audience> --force 18 + // node bin/gen-photos.mjs <audience> --concurrency 2 19 + 20 + import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; 21 + import { resolve, dirname } from "node:path"; 22 + import { fileURLToPath } from "node:url"; 23 + 24 + const HERE = dirname(fileURLToPath(import.meta.url)); 25 + const ROOT = resolve(HERE, ".."); 26 + const REPO = resolve(ROOT, ".."); 27 + 28 + const argv = process.argv.slice(2); 29 + const flags = {}; 30 + const positional = []; 31 + for (let i = 0; i < argv.length; i++) { 32 + const a = argv[i]; 33 + if (a.startsWith("--")) { 34 + const next = argv[i + 1]; 35 + if (next !== undefined && !next.startsWith("--")) { flags[a.slice(2)] = next; i++; } 36 + else flags[a.slice(2)] = true; 37 + } else positional.push(a); 38 + } 39 + const audienceName = positional[0]; 40 + if (!audienceName) { 41 + console.error("usage: gen-photos.mjs <audience-name> [--only NAME] [--force] [--concurrency N]"); 42 + process.exit(1); 43 + } 44 + const force = !!flags.force; 45 + const only = flags.only || null; 46 + const CONCURRENCY = Math.max(1, Math.min(3, Number(flags.concurrency || 1))); 47 + 48 + const { audience } = await import(`${ROOT}/audience/${audienceName}.mjs`); 49 + const PHOTOS_DIR = `${ROOT}/out/jeffrey-photos`; 50 + mkdirSync(PHOTOS_DIR, { recursive: true }); 51 + 52 + // ── refs (mirror jeffrey-photos.mjs) ────────────────────────────── 53 + const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot`; 54 + const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`; 55 + const REFS = [ 56 + `${SHOOT_DIR}/jeffery-av--07.jpg`, 57 + `${SHOOT_DIR}/jeffery-av--01.jpg`, 58 + `${SHOOT_DIR}/jeffery-av--04.jpg`, 59 + `${ARCHIVE_DIR}/2018-12-02_Bq4ckGFFNtW.jpg`, 60 + `${ARCHIVE_DIR}/2020-09-02_CEpxlO2FOvD.jpg`, 61 + `${ARCHIVE_DIR}/2021-07-10_CRI095Vl7AO_1.jpg`, 62 + `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`, 63 + `${ARCHIVE_DIR}/2017-04-10_BStid5yjTHq.jpg`, 64 + ].filter((p) => existsSync(p)); 65 + 66 + function loadOpenAIKey() { 67 + if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; 68 + const vault = `${REPO}/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env`; 69 + if (existsSync(vault)) { 70 + for (const line of readFileSync(vault, "utf8").split("\n")) { 71 + if (line.startsWith("OPENAI_API_KEY=")) { 72 + return line.slice("OPENAI_API_KEY=".length).trim().replace(/^['"]|['"]$/g, ""); 73 + } 74 + } 75 + } 76 + throw new Error("OPENAI_API_KEY not found"); 77 + } 78 + const apiKey = loadOpenAIKey(); 79 + 80 + async function genOne(segName, metaphor) { 81 + const outPath = `${PHOTOS_DIR}/${segName}.png`; 82 + if (existsSync(outPath) && !force) return { segName, status: "cached", outPath }; 83 + const fd = new FormData(); 84 + fd.append("model", "gpt-image-2"); 85 + fd.append("prompt", metaphor); 86 + fd.append("size", "1024x1536"); 87 + fd.append("quality", "high"); 88 + fd.append("n", "1"); 89 + for (const ref of REFS) { 90 + const buf = readFileSync(ref); 91 + const ext = ref.toLowerCase().endsWith(".png") ? "png" : "jpeg"; 92 + fd.append("image[]", new Blob([buf], { type: `image/${ext}` }), ref.split("/").pop()); 93 + } 94 + const t0 = Date.now(); 95 + const res = await fetch("https://api.openai.com/v1/images/edits", { 96 + method: "POST", 97 + headers: { Authorization: `Bearer ${apiKey}` }, 98 + body: fd, 99 + }); 100 + if (!res.ok) throw new Error(`OpenAI ${res.status}: ${(await res.text()).slice(0, 300)}`); 101 + const json = await res.json(); 102 + const b64 = json.data?.[0]?.b64_json; 103 + if (!b64) throw new Error(`no image returned`); 104 + writeFileSync(outPath, Buffer.from(b64, "base64")); 105 + return { segName, status: "fresh", outPath, durSec: (Date.now() - t0) / 1000 }; 106 + } 107 + 108 + // ── build target list ───────────────────────────────────────────── 109 + const targets = []; 110 + for (const seg of audience.segments) { 111 + if (only && seg.name !== only) continue; 112 + const slide = audience.slides[seg.name]; 113 + if (!slide || typeof slide !== "object" || !slide.metaphor) continue; 114 + targets.push({ segName: seg.name, metaphor: slide.metaphor }); 115 + } 116 + 117 + console.log(`▸ ${targets.length} target(s) · concurrency=${CONCURRENCY} · force=${force}`); 118 + 119 + // ── simple promise-pool ─────────────────────────────────────────── 120 + let cursor = 0; 121 + const results = []; 122 + async function worker() { 123 + while (cursor < targets.length) { 124 + const i = cursor++; 125 + const t = targets[i]; 126 + try { 127 + console.log(` → ${t.segName}`); 128 + const r = await genOne(t.segName, t.metaphor); 129 + if (r.status === "cached") console.log(` · cached: ${t.segName}`); 130 + else console.log(` ✓ ${t.segName} (${r.durSec.toFixed(1)}s)`); 131 + results.push(r); 132 + } catch (e) { 133 + console.error(` ✗ ${t.segName}: ${e.message}`); 134 + results.push({ segName: t.segName, status: "fail", error: e.message }); 135 + } 136 + } 137 + } 138 + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); 139 + 140 + const fresh = results.filter(r => r.status === "fresh").length; 141 + const cached = results.filter(r => r.status === "cached").length; 142 + const failed = results.filter(r => r.status === "fail").length; 143 + console.log(`✓ done · fresh ${fresh} · cached ${cached} · failed ${failed}`); 144 + if (failed) process.exit(2);
+19 -2
recap/bin/scout.mjs
··· 5 5 // { glob: "...pdf", pdfPage: 1, pdfWidth: 800 } → first PDF page 6 6 // { commits: "<git --grep regex>", since: "48 hours" } → commit list strings 7 7 // { files: "<glob>", since: "48 hours", limit: 8 } → recent matching paths 8 + // { json: "<repo-relative path>" } → parsed JSON value 9 + // { screenshot: "<URL>", ... } → pre-baked PNG 10 + // (cached at out/screenshots/<seg>-<name>.png 11 + // by `bin/screenshots.mjs`) 8 12 // Output: out/assets.json mapping slide-name → resolved values keyed by query name. 9 13 // Usage: node bin/scout.mjs [audience-name] 10 14 ··· 79 83 return matches.map((x) => x.f); 80 84 } 81 85 82 - function resolveQuery(name, q) { 86 + function resolveQuery(segName, name, q) { 87 + if (q.screenshot) { 88 + const cached = `${ROOT}/out/screenshots/${segName}-${name}.png`; 89 + if (!expandGlob(cached).length) { 90 + console.warn(` ⚠ ${name}: screenshot '${q.screenshot}' not cached at ${cached.replace(REPO + "/", "")} — run bin/screenshots.mjs first`); 91 + return null; 92 + } 93 + return imageToDataUrl(cached); 94 + } 83 95 if (q.glob) { 84 96 const matches = expandGlob(q.glob); 85 97 if (!matches.length) { ··· 92 104 } 93 105 if (q.commits) return recentCommits(q.commits, q.since, q.limit); 94 106 if (q.files) return recentFiles(q.files, q.sinceHours || 168, q.limit || 12); 107 + if (q.json) { 108 + const abs = q.json.startsWith("/") ? q.json : join(REPO, q.json); 109 + try { return JSON.parse(readFileSync(abs, "utf8")); } 110 + catch (e) { console.warn(` ⚠ ${name}: json '${q.json}' read failed: ${e.message}`); return null; } 111 + } 95 112 console.warn(` ⚠ ${name}: unknown query shape`); 96 113 return null; 97 114 } ··· 103 120 console.log(`▸ ${seg.name}`); 104 121 out[seg.name] = {}; 105 122 for (const [name, q] of Object.entries(slide.queries)) { 106 - const value = resolveQuery(name, q); 123 + const value = resolveQuery(seg.name, name, q); 107 124 if (value !== null) { 108 125 out[seg.name][name] = value; 109 126 const desc = typeof value === "string" && value.startsWith("data:")
+287
recap/bin/test-slide.mjs
··· 1 + #!/usr/bin/env node 2 + // test-slide.mjs — render ONE audience slide as a 1080x1920 PNG so we 3 + // can sanity-check the prompt + duotone composition without running the 4 + // full recap pipeline (no TTS, no transcribe, no align, no concat). 5 + // 6 + // Generates the per-segment photo via OpenAI gpt-image-2 if it doesn't 7 + // already exist (cached at recap/out/jeffrey-photos/<segment>.png), then 8 + // composes the slide HTML via puppeteer with the same fonts + palette 9 + // slides.mjs uses, and writes the result to --out. 10 + // 11 + // Usage: 12 + // node bin/test-slide.mjs <audience-name> <segment-name> --out <path> 13 + // node bin/test-slide.mjs jeffrey-73h-2026-05-02 02_menuband_arc \ 14 + // --out ~/Desktop/test-slide.png 15 + // 16 + // Flags: 17 + // --force regen the photo even if cached 18 + // --skip-photo use the existing cached photo only; fail if missing 19 + // --no-photo render the slide without the photo (background only) 20 + 21 + import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; 22 + import { resolve, dirname } from "node:path"; 23 + import { fileURLToPath } from "node:url"; 24 + import { homedir } from "node:os"; 25 + import { execSync } from "node:child_process"; 26 + 27 + // Resolve puppeteer from one of the known node_modules locations. 28 + const __HERE = dirname(fileURLToPath(import.meta.url)); 29 + const __PUPPETEER_DIR = [ 30 + resolve(__HERE, "../../oven/node_modules/puppeteer"), 31 + "/opt/oven/node_modules/puppeteer", 32 + resolve(__HERE, "../node_modules/puppeteer"), 33 + ].find((p) => existsSync(p)); 34 + if (!__PUPPETEER_DIR) { 35 + throw new Error("puppeteer not found in any known node_modules location"); 36 + } 37 + const puppeteer = (await import(`${__PUPPETEER_DIR}/lib/esm/puppeteer/puppeteer.js`)).default; 38 + 39 + const HERE = dirname(fileURLToPath(import.meta.url)); 40 + const ROOT = resolve(HERE, ".."); 41 + const REPO = resolve(ROOT, ".."); 42 + 43 + // ── puppeteer concurrency lock ───────────────────────────────────────── 44 + // 8 GB machine cannot host two Chromium instances at once (the user 45 + // crashed once on 2026-05-02 from parallel test-slide runs). Bail loud 46 + // if anything else is already holding a puppeteer browser open. 47 + { 48 + const myPid = String(process.pid); 49 + const sniff = (pat) => { 50 + try { 51 + return execSync(`pgrep -f "${pat}" 2>/dev/null || true`, { encoding: "utf8" }) 52 + .split("\n").map(s => s.trim()).filter(s => s && s !== myPid); 53 + } catch { return []; } 54 + }; 55 + const others = [ 56 + ...sniff("bin/test-slide\\.mjs"), 57 + ...sniff("bin/slides\\.mjs"), 58 + ...sniff("pipeline\\.fish"), 59 + ]; 60 + if (others.length) { 61 + console.error(`✗ another puppeteer-using process is running (pids ${others.join(", ")}) — refusing to launch a second Chromium on this 8 GB machine. Wait for it, or kill it.`); 62 + process.exit(2); 63 + } 64 + } 65 + 66 + // ── parse args ───────────────────────────────────────────────────────── 67 + const argv = process.argv.slice(2); 68 + const flags = {}; 69 + const positional = []; 70 + for (let i = 0; i < argv.length; i++) { 71 + const a = argv[i]; 72 + if (a.startsWith("--")) { 73 + const next = argv[i + 1]; 74 + if (next !== undefined && !next.startsWith("--")) { 75 + flags[a.slice(2)] = next; 76 + i++; 77 + } else { 78 + flags[a.slice(2)] = true; 79 + } 80 + } else positional.push(a); 81 + } 82 + 83 + const audienceName = positional[0]; 84 + const segmentName = positional[1]; 85 + if (!audienceName || !segmentName) { 86 + console.error("usage: test-slide.mjs <audience> <segment> [--out PATH] [--force | --skip-photo | --no-photo]"); 87 + process.exit(1); 88 + } 89 + const expandHome = (p) => p?.startsWith("~/") ? resolve(homedir(), p.slice(2)) : p; 90 + const outPath = expandHome(flags.out) || `${ROOT}/out/test-slide.png`; 91 + const force = !!flags.force; 92 + const skipPhoto = !!flags["skip-photo"]; 93 + const noPhoto = !!flags["no-photo"]; 94 + 95 + // ── load audience ───────────────────────────────────────────────────── 96 + const { audience, PALETTE } = await import(`${ROOT}/audience/${audienceName}.mjs`); 97 + const slide = audience.slides[segmentName]; 98 + if (!slide) { 99 + console.error(`segment '${segmentName}' not found in audience '${audienceName}'`); 100 + console.error(`available: ${Object.keys(audience.slides).join(", ")}`); 101 + process.exit(1); 102 + } 103 + 104 + // ── photo gen / cache ───────────────────────────────────────────────── 105 + const PHOTOS_DIR = `${ROOT}/out/jeffrey-photos`; 106 + mkdirSync(PHOTOS_DIR, { recursive: true }); 107 + const photoPath = `${PHOTOS_DIR}/${segmentName}.png`; 108 + let photoForBody = null; 109 + 110 + if (noPhoto) { 111 + console.log("→ photo: skipped (--no-photo)"); 112 + } else if (existsSync(photoPath) && !force) { 113 + console.log(`→ photo cached: ${photoPath.replace(REPO + "/", "")}`); 114 + photoForBody = photoPath; 115 + } else if (skipPhoto) { 116 + console.error(`✗ no cached photo at ${photoPath} and --skip-photo set`); 117 + process.exit(1); 118 + } else { 119 + if (!slide.metaphor) { 120 + console.error(`segment '${segmentName}' has no metaphor; cannot generate photo`); 121 + process.exit(1); 122 + } 123 + console.log("→ photo: generating via gpt-image-2"); 124 + photoForBody = await generatePhoto(slide.metaphor, photoPath); 125 + } 126 + 127 + // ── load OpenAI key (same path as jeffrey-photos.mjs) ──────────────── 128 + function loadOpenAIKey() { 129 + if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; 130 + const vault = `${REPO}/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env`; 131 + if (existsSync(vault)) { 132 + for (const line of readFileSync(vault, "utf8").split("\n")) { 133 + if (line.startsWith("OPENAI_API_KEY=")) { 134 + return line.slice("OPENAI_API_KEY=".length).trim().replace(/^['"]|['"]$/g, ""); 135 + } 136 + } 137 + } 138 + throw new Error("OPENAI_API_KEY not found in env or vault"); 139 + } 140 + 141 + async function generatePhoto(metaphor, outFile) { 142 + const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot`; 143 + const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`; 144 + const refs = [ 145 + `${SHOOT_DIR}/jeffery-av--07.jpg`, 146 + `${SHOOT_DIR}/jeffery-av--01.jpg`, 147 + `${SHOOT_DIR}/jeffery-av--04.jpg`, 148 + `${ARCHIVE_DIR}/2018-12-02_Bq4ckGFFNtW.jpg`, 149 + `${ARCHIVE_DIR}/2020-09-02_CEpxlO2FOvD.jpg`, 150 + `${ARCHIVE_DIR}/2021-07-10_CRI095Vl7AO_1.jpg`, 151 + `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`, 152 + `${ARCHIVE_DIR}/2017-04-10_BStid5yjTHq.jpg`, 153 + ].filter((p) => existsSync(p)); 154 + 155 + const apiKey = loadOpenAIKey(); 156 + const fd = new FormData(); 157 + fd.append("model", "gpt-image-2"); 158 + fd.append("prompt", metaphor); 159 + fd.append("size", "1024x1536"); 160 + fd.append("quality", "high"); 161 + fd.append("n", "1"); 162 + for (const ref of refs) { 163 + const buf = readFileSync(ref); 164 + const ext = ref.toLowerCase().endsWith(".png") ? "png" : "jpeg"; 165 + fd.append("image[]", new Blob([buf], { type: `image/${ext}` }), ref.split("/").pop()); 166 + } 167 + console.log(` refs: ${refs.length}`); 168 + const t0 = Date.now(); 169 + const res = await fetch("https://api.openai.com/v1/images/edits", { 170 + method: "POST", 171 + headers: { Authorization: `Bearer ${apiKey}` }, 172 + body: fd, 173 + }); 174 + if (!res.ok) { 175 + const err = await res.text(); 176 + throw new Error(`OpenAI ${res.status}: ${err.slice(0, 500)}`); 177 + } 178 + const json = await res.json(); 179 + const b64 = json.data?.[0]?.b64_json; 180 + if (!b64) throw new Error(`no image returned: ${JSON.stringify(json).slice(0, 200)}`); 181 + writeFileSync(outFile, Buffer.from(b64, "base64")); 182 + console.log(` ✓ ${outFile.replace(REPO + "/", "")} (${((Date.now() - t0) / 1000).toFixed(1)}s)`); 183 + return outFile; 184 + } 185 + 186 + // ── compose slide HTML ──────────────────────────────────────────────── 187 + console.log("→ composing slide HTML"); 188 + 189 + const FONT_BOLD = `${REPO}/system/public/type/webfonts/ywft-processing-bold.ttf`; 190 + const FONT_REG = `${REPO}/system/public/type/webfonts/ywft-processing-regular.ttf`; 191 + const fontBoldB64 = readFileSync(FONT_BOLD).toString("base64"); 192 + const fontRegB64 = readFileSync(FONT_REG).toString("base64"); 193 + 194 + // Resolve every query the slide declares (besides `photo`, already done 195 + // above) into the args object the slide's body() expects. We mini-replicate 196 + // scout.mjs here so the test-slide path doesn't depend on the full pipeline. 197 + function fileToDataUrl(p) { 198 + const ext = p.toLowerCase().split(".").pop(); 199 + const mime = ext === "svg" ? "image/svg+xml" : ext === "webp" ? "image/webp" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png"; 200 + return `data:${mime};base64,${readFileSync(p).toString("base64")}`; 201 + } 202 + 203 + const bodyArgs = {}; 204 + if (photoForBody) bodyArgs.photo = fileToDataUrl(photoForBody); 205 + 206 + if (slide.queries) { 207 + for (const [qname, q] of Object.entries(slide.queries)) { 208 + if (qname === "photo") continue; 209 + if (q.screenshot) { 210 + const cached = `${ROOT}/out/screenshots/${segmentName}-${qname}.png`; 211 + if (existsSync(cached)) { 212 + console.log(`→ screenshot cached: ${qname} (${cached.replace(REPO + "/", "")})`); 213 + bodyArgs[qname] = fileToDataUrl(cached); 214 + } else { 215 + console.log(`→ screenshot not cached: ${qname} — fetching ${q.screenshot}`); 216 + const { spawnSync } = await import("node:child_process"); 217 + const r = spawnSync("node", [`${ROOT}/bin/screenshots.mjs`, audienceName, "--only", segmentName], { stdio: "inherit" }); 218 + if (r.status !== 0) console.warn(` ⚠ screenshot fetch failed for ${qname}`); 219 + if (existsSync(cached)) bodyArgs[qname] = fileToDataUrl(cached); 220 + } 221 + } else if (q.glob) { 222 + // glob → expand via shell, take first match. PDF support: pdftoppm 223 + // first page → PNG → data URL. 224 + const { execSync, execFileSync } = await import("node:child_process"); 225 + const abs = q.glob.startsWith("/") ? q.glob : `${REPO}/${q.glob}`; 226 + try { 227 + const out = execSync(`ls -1 ${abs} 2>/dev/null | head -n 1`, { encoding: "utf8" }).trim(); 228 + if (!out) continue; 229 + if (q.pdfPage) { 230 + const { tmpdir } = await import("node:os"); 231 + const { basename } = await import("node:path"); 232 + const stem = `${tmpdir()}/test-slide-${basename(out, ".pdf")}-p${q.pdfPage}`; 233 + execFileSync("pdftoppm", [ 234 + "-png", "-r", "150", 235 + "-f", String(q.pdfPage), "-l", String(q.pdfPage), 236 + "-scale-to", String(q.pdfWidth || 800), 237 + out, stem, 238 + ]); 239 + const pngs = execSync(`ls -1 ${stem}-*.png 2>/dev/null | head -n 1`, { encoding: "utf8" }).trim(); 240 + if (pngs) bodyArgs[qname] = fileToDataUrl(pngs); 241 + } else { 242 + bodyArgs[qname] = fileToDataUrl(out); 243 + } 244 + } catch {} 245 + } else if (q.json) { 246 + const abs = q.json.startsWith("/") ? q.json : `${REPO}/${q.json}`; 247 + try { bodyArgs[qname] = JSON.parse(readFileSync(abs, "utf8")); } catch {} 248 + } 249 + } 250 + } 251 + 252 + const bodyHtml = typeof slide.body === "function" 253 + ? slide.body(bodyArgs) 254 + : slide.body || slide; 255 + 256 + const html = `<!DOCTYPE html> 257 + <html><head><meta charset="utf-8"><style> 258 + @font-face { 259 + font-family: 'ProcessingB'; 260 + src: url(data:font/ttf;base64,${fontBoldB64}) format('truetype'); 261 + } 262 + @font-face { 263 + font-family: 'ProcessingR'; 264 + src: url(data:font/ttf;base64,${fontRegB64}) format('truetype'); 265 + } 266 + * { box-sizing: border-box; margin: 0; padding: 0; } 267 + html, body { width: 1080px; height: 1920px; font-family: 'ProcessingR'; -webkit-font-smoothing: antialiased; } 268 + body { background: ${PALETTE.bg}; position: relative; overflow: hidden; } 269 + </style></head><body> 270 + ${bodyHtml} 271 + </body></html>`; 272 + 273 + // ── render ──────────────────────────────────────────────────────────── 274 + console.log("→ rendering 1080×1920 PNG via puppeteer"); 275 + const browser = await puppeteer.launch({ 276 + headless: true, 277 + args: ["--no-sandbox", "--allow-file-access-from-files"], 278 + }); 279 + const page = await browser.newPage(); 280 + await page.setViewport({ width: 1080, height: 1920, deviceScaleFactor: 1 }); 281 + await page.setContent(html, { waitUntil: "networkidle0" }); 282 + // Give fonts a beat; puppeteer's networkidle doesn't always wait on data: URIs. 283 + await new Promise((r) => setTimeout(r, 200)); 284 + mkdirSync(dirname(outPath), { recursive: true }); 285 + await page.screenshot({ path: outPath, type: "png", fullPage: false }); 286 + await browser.close(); 287 + console.log(`✓ ${outPath}`);