Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278#!/usr/bin/env node// slides.mjs — render audience slides as 1080x1920 PNGs and write// out/concat.txt with per-slide durations from out/segments.json.// Usage: node bin/slides.mjs [audience-name]
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";import { resolve, dirname } from "node:path";import { fileURLToPath } from "node:url";
// Optional progress heartbeats → Slab menubar.let progress = { begin: () => null, update: () => {}, end: () => {} };try { progress = await import("../../pop/lib/render-progress.mjs");} catch { /* heartbeats are optional */ }
// Resolve puppeteer from one of the known node_modules locations. Local dev// uses ../../oven/node_modules; oven prod uses /opt/oven/node_modules.const __HERE = dirname(fileURLToPath(import.meta.url));const __PUPPETEER_DIR = [ resolve(__HERE, "../../oven/node_modules/puppeteer"), "/opt/oven/node_modules/puppeteer", resolve(__HERE, "../node_modules/puppeteer"),].find((p) => existsSync(p));if (!__PUPPETEER_DIR) { throw new Error("puppeteer not found in any known node_modules location");}const puppeteer = (await import(`${__PUPPETEER_DIR}/lib/esm/puppeteer/puppeteer.js`)).default;
const HERE = dirname(fileURLToPath(import.meta.url));const ROOT = resolve(HERE, "..");const audienceName = process.argv[2] || "fia";const { audience, PALETTE } = await import(`${ROOT}/audience/${audienceName}.mjs`);
const SLIDE_DIR = `${ROOT}/out/slides`;mkdirSync(SLIDE_DIR, { recursive: true });
const REPO = resolve(HERE, "../..");const FONT_BOLD = `${REPO}/system/public/type/webfonts/ywft-processing-bold.ttf`;const FONT_REG = `${REPO}/system/public/type/webfonts/ywft-processing-regular.ttf`;const PALS_SVG = `${REPO}/system/public/purple-pals.svg`;
const fontBoldB64 = readFileSync(FONT_BOLD).toString("base64");const fontRegB64 = readFileSync(FONT_REG).toString("base64");const palsSvgB64 = Buffer.from(readFileSync(PALS_SVG, "utf8"), "utf8").toString("base64");
const segments = JSON.parse(readFileSync(`${ROOT}/out/segments.json`, "utf8"));const assets = (() => { try { return JSON.parse(readFileSync(`${ROOT}/out/assets.json`, "utf8")); } catch { return {}; }})();
const cssTemplate = `@font-face { font-family: 'ProcessingB'; src: url(data:font/ttf;base64,${fontBoldB64}) format('truetype'); unicode-range: U+0020-007E;}@font-face { font-family: 'ProcessingR'; src: url(data:font/ttf;base64,${fontRegB64}) format('truetype'); unicode-range: U+0020-007E;}* { box-sizing: border-box; margin: 0; padding: 0; }html, body { width: 1080px; height: 1920px; font-family: 'ProcessingR', 'Helvetica Neue', 'Hiragino Sans', monospace; -webkit-font-smoothing: antialiased;}body { background: ${PALETTE.bg}; padding: 80px 70px; position: relative; overflow: hidden; }.frame { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 28px; position: relative; }.pals { background-image: url(data:image/svg+xml;base64,${palsSvgB64}); background-size: contain; background-repeat: no-repeat; background-position: center;}.pals.big { width: 720px; height: 720px; align-self: center; margin-top: 60px; }.pals.med { width: 480px; height: 480px; align-self: center; margin-top: 80px; }.pals.bug { position: absolute; top: 30px; left: 30px; width: 200px; height: 200px; opacity: 1; /* Rainbow drop shadow — multi-step layered shadows in cycling hues * give a soft chromatic aberration / risograph misregister feel. */ filter: drop-shadow(3px 3px 0 rgba(255, 80, 180, 0.85)) drop-shadow(-3px 3px 0 rgba(80, 200, 255, 0.85)) drop-shadow(0 -3px 0 rgba(255, 220, 80, 0.85)) drop-shadow(6px 0 0 rgba(140, 80, 255, 0.55)) drop-shadow(-6px 0 0 rgba(80, 255, 160, 0.55)) drop-shadow(0 8px 22px rgba(0, 0, 0, 0.5));}/* PALS bug — top-LEFT corner, large with rainbow drop shadow. Top-right * is owned by the QR code (audience photoSlide renders that). */.kicker { font-family: 'ProcessingB'; font-size: 38px; letter-spacing: 8px; text-transform: uppercase; text-align: center; }.huge { font-family: 'ProcessingB'; font-size: 240px; letter-spacing: -8px; text-align: center; line-height: 0.95; margin-top: 10px; }.sub { font-family: 'ProcessingB'; font-size: 46px; letter-spacing: 2px; text-align: center; margin-top: 24px; }.datestamp { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; font-family: 'ProcessingR'; font-size: 30px; letter-spacing: 4px; }.chapter { font-family: 'ProcessingR'; font-size: 30px; letter-spacing: 6px; text-transform: uppercase; }.title { font-family: 'ProcessingB'; font-size: 130px; letter-spacing: -3px; line-height: 1.0; margin-top: 6px; }.body { font-family: 'ProcessingR'; font-size: 56px; line-height: 1.32; margin-top: 32px; }.body em { font-style: normal; color: ${PALETTE.magenta}; }.body.small { font-size: 44px; }.cap { font-family: 'ProcessingB'; font-size: 44px; margin-top: auto; padding-top: 30px; }.commits { margin-top: 36px; display: flex; flex-direction: column; gap: 14px; }.commit { font-family: 'ProcessingR'; font-size: 30px; color: ${PALETTE.off}; line-height: 1.3; }.commit .hash { display: inline-block; width: 90px; color: ${PALETTE.lime}; font-family: 'ProcessingB'; }.paper-card { margin-top: 50px; padding: 50px 40px; border: 4px solid ${PALETTE.cyan}; border-radius: 6px; background: rgba(112, 240, 224, 0.06); }.paper-title { font-family: 'ProcessingB'; font-size: 74px; color: ${PALETTE.cyan}; letter-spacing: -1px; }.paper-sub { font-family: 'ProcessingR'; font-size: 38px; color: ${PALETTE.cream}; margin-top: 18px; }.paper-meta { font-family: 'ProcessingR'; font-size: 28px; color: ${PALETTE.dim}; margin-top: 14px; letter-spacing: 2px; }.lang-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-top: 50px; }.lang { font-family: 'ProcessingB'; font-size: 86px; color: ${PALETTE.cream}; padding: 36px 30px; border: 3px solid rgba(255,255,255,0.15); border-radius: 4px; display: flex; align-items: baseline; gap: 22px; }.lang .flag { font-family: 'ProcessingB'; font-size: 60px; letter-spacing: -2px; }.ticker { margin-top: 40px; display: flex; flex-direction: column; gap: 18px; padding: 30px 36px; background: rgba(255, 216, 96, 0.07); border-left: 6px solid ${PALETTE.yellow}; }.tick { font-family: 'ProcessingR'; font-size: 32px; color: ${PALETTE.cream}; }.rhyme { margin-top: 220px; text-align: center; display: flex; flex-direction: column; gap: 30px; }.rhyme .line1, .rhyme .line2 { font-family: 'ProcessingB'; font-size: 92px; line-height: 1.05; }.rhyme .line2 { margin-top: 60px; }.endline { font-family: 'ProcessingB'; font-size: 92px; letter-spacing: -2px; text-align: center; margin-top: 40px; }.endsub { font-family: 'ProcessingR'; font-size: 36px; letter-spacing: 4px; text-align: center; margin-top: 14px; }/* .cornerbug retired — was overlapping the subtitle pill in the new * bottom-chrome layout. The PALS bug + chapter prompt stack handle * branding now (rendered in audience photoSlide). */
/* asset-driven slide additions */.title-row { display: flex; align-items: center; gap: 24px; }.brand-icon { width: 110px; height: 110px; border-radius: 18px; flex-shrink: 0; }.row-with-aside { display: flex; gap: 30px; margin-top: 36px; align-items: flex-start; }.row-with-aside .commits { flex: 1; margin-top: 0; }.paper-thumb { width: 280px; border: 3px solid ${PALETTE.cyan}; border-radius: 6px; box-shadow: 0 6px 30px rgba(0,0,0,0.4); }.cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-top: 40px; }.cover { display: flex; flex-direction: column; gap: 14px; align-items: center; }.cover-img { width: 100%; max-width: 360px; border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }.cover-img.placeholder { aspect-ratio: 0.71; background: rgba(255,255,255,0.06); border: 2px dashed rgba(255,255,255,0.2); }.cover-label { font-family: 'ProcessingB'; font-size: 38px; color: ${PALETTE.cream}; }.cover-label span { font-size: 32px; margin-right: 10px; letter-spacing: -1px; }.pdf-strip { margin-top: 30px; display: flex; flex-wrap: wrap; gap: 12px; }.pdf-chip { font-family: 'ProcessingR'; font-size: 24px; color: ${PALETTE.cream}; padding: 10px 18px; background: rgba(255, 216, 96, 0.1); border: 2px solid rgba(255, 216, 96, 0.3); border-radius: 4px; }.tick .hash { color: ${PALETTE.lime}; font-family: 'ProcessingB'; margin-right: 14px; }`;
// Prefer puppeteer's managed Chrome-for-Testing (works on both local Mac// via ~/.cache/puppeteer and oven Linux). If it isn't installed, fall// back to a system Chrome/Chromium so slides can render without first// downloading the managed browser. The fallback is local-dev only — the// oven keeps the managed browser installed.function findSystemChrome() { const candidates = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", ]; return candidates.find((p) => existsSync(p)) || null;}
let browser;try { browser = await puppeteer.launch({ args: ["--no-sandbox"] });} catch (e) { const sys = findSystemChrome(); if (!sys) throw e; console.warn(` ⚠ managed Chrome unavailable (${e.message.split("\n")[0]});`); console.warn(` falling back to system Chrome: ${sys}`); browser = await puppeteer.launch({ executablePath: sys, args: ["--no-sandbox"] });}
const slidesOrder = audience.segments.map((s) => s.name);const showBug = (name) => name !== slidesOrder[0] && name !== slidesOrder[slidesOrder.length - 1];
progress.begin?.({ type: "video", label: `recap slides · ${audienceName}` });let _slideIdx = 0;for (const name of slidesOrder) { const slide = audience.slides[name]; if (!slide) { console.error(`✗ no slide HTML for segment "${name}" in audience.slides`); process.exit(1); } // Slide can be a string or an object { body, queries }; body can also be a // function (assets) => string for query-driven slides. const slideAssets = assets[name] || {}; let body; if (typeof slide === "string") body = slide; else if (typeof slide.body === "function") body = slide.body(slideAssets); else body = slide.body; const page = await browser.newPage(); await page.setViewport({ width: 1080, height: 1920, deviceScaleFactor: 1 }); const html = `<!doctype html><html><head><meta charset="utf-8"><style>${cssTemplate}</style></head><body>${body} ${/* PALS bug + cornerbug are rendered inside audience photoSlide() now so they can carry per-slide chapter color (drop shadow, scrim). Title + end slide skip them via their own templates. */ ""} </body></html>`; // `domcontentloaded` instead of `networkidle0` — with multi-MB base64 // photos embedded inline, networkidle0 sometimes hangs waiting for the // browser to settle. domcontentloaded fires once the DOM is parsed, // which is all we need before screenshotting (data URLs render // synchronously). 90s timeout for safety on slow oven CPU. await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 90000 }); await new Promise((r) => setTimeout(r, 400)); await new Promise((r) => setTimeout(r, 200)); const png = await page.screenshot({ type: "png", omitBackground: false }); writeFileSync(`${SLIDE_DIR}/${name}.png`, png);
// Chrome-only sibling — same body but with the full-bleed photo // <img> stripped + transparent background. Used by compose.fish to // overlay chrome on top of a separately-shaken photo stream so the // chapter prompt / QR / PALS watermarks stay still while the // portrait below them does the handicam crop. const chromeBody = body.replace(/<img[^>]*object-fit:\s*cover[^>]*\/?\s*>/g, ""); const chromeHtml = `<!doctype html><html><head><meta charset="utf-8"><style>${cssTemplate}\nhtml,body{background:transparent;}</style></head><body>${chromeBody}</body></html>`; await page.setContent(chromeHtml, { waitUntil: "domcontentloaded", timeout: 90000 }); await new Promise((r) => setTimeout(r, 200)); const chromePng = await page.screenshot({ type: "png", omitBackground: true }); mkdirSync(`${ROOT}/out/chrome`, { recursive: true }); writeFileSync(`${ROOT}/out/chrome/${name}.png`, chromePng);
await page.close(); const seg = segments.find((s) => s.name === name); console.log(`✓ ${name}.png + chrome/${name}.png · ${seg.durationSec.toFixed(2)}s (${seg.startSec}s → ${seg.endSec}s)`); _slideIdx++; progress.update?.((_slideIdx / slidesOrder.length) * 100, { done: _slideIdx, total: slidesOrder.length });}await browser.close();progress.end?.();
// concat.txt with real durations (full composited slides — kept for// backward-compat / debugging). compose.fish now uses the photo +// chrome split below.const lines = [];for (const seg of segments) { lines.push(`file '${SLIDE_DIR}/${seg.name}.png'`); lines.push(`duration ${seg.durationSec}`);}lines.push(`file '${SLIDE_DIR}/${segments[segments.length - 1].name}.png'`);writeFileSync(`${ROOT}/out/concat.txt`, lines.join("\n") + "\n");
// photos.txt — raw jeffrey-photos for chapters (the camera-shake// stream operates on these), full end-slide for the static end card.// A 1080×1920 black placeholder is used for any segment without a// jeffrey-photos sibling.import { spawnSync } from "node:child_process";const photosDir = `${ROOT}/out/jeffrey-photos`;const blackPath = `${ROOT}/out/black-1080x1920.png`;if (!existsSync(blackPath)) { const ff = "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg"; spawnSync(ff, ["-hide_banner","-loglevel","error","-y","-f","lavfi","-i","color=c=black:s=1080x1920:d=1","-frames:v","1",blackPath], { stdio: "inherit" });}function photoFor(seg) { const candidate = `${photosDir}/${seg.name}.png`; if (existsSync(candidate)) return candidate; return blackPath;}const photoLines = [];for (const seg of segments) { photoLines.push(`file '${photoFor(seg)}'`); photoLines.push(`duration ${seg.durationSec}`);}photoLines.push(`file '${photoFor(segments[segments.length - 1])}'`);writeFileSync(`${ROOT}/out/photos.txt`, photoLines.join("\n") + "\n");
// chrome.txt — the chrome PNGs for each slide.const chromeLines = [];for (const seg of segments) { chromeLines.push(`file '${ROOT}/out/chrome/${seg.name}.png'`); chromeLines.push(`duration ${seg.durationSec}`);}chromeLines.push(`file '${ROOT}/out/chrome/${segments[segments.length - 1].name}.png'`);writeFileSync(`${ROOT}/out/chrome.txt`, chromeLines.join("\n") + "\n");
const total = segments[segments.length - 1].endSec;writeFileSync(`${ROOT}/out/duration.txt`, String(total));console.log(`✓ ${ROOT}/out/concat.txt + photos.txt + chrome.txt · total ${total}s`);