From 335647512f0b421cbe9695648beff6e4448ceaf4 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Fri, 07 Aug 2026 19:31:11 +0000 Subject: [PATCH] Add pals-turnaround publisher and community float tools --- marketing/podcast/bin/publish-pals-turnaround.mjs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ marketing/talking-head/bin/float-community.mjs | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ marketing/talking-head/community-2026-08-05.json | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 371 insertion(s)(+), 0 deletion(s)(-) diff --git a/marketing/podcast/bin/publish-pals-turnaround.mjs b/marketing/podcast/bin/publish-pals-turnaround.mjs new file mode 100644 --- /dev/null +++ b/marketing/podcast/bin/publish-pals-turnaround.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Publish an accepted Pal turnaround master and animated WebP to stable, +// versioned Art Space keys. +// +// Usage: node bin/publish-pals-turnaround.mjs + +import { existsSync, readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../../.."); +const OUT = resolve(HERE, "../out/pals/turnarounds"); +const slug = process.argv[2]; + +if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) { + console.error("usage: node bin/publish-pals-turnaround.mjs "); + process.exit(1); +} + +const env = { ...process.env }; +const vault = resolve(REPO, "aesthetic-computer-vault/oven/.env"); +if (existsSync(vault)) { + for (const line of readFileSync(vault, "utf8").split("\n")) { + const match = line.match(/^\s*(ART_SPACES_ENDPOINT|ART_SPACES_KEY|ART_SPACES_SECRET|ART_SPACES_BUCKET|ART_CDN_BASE)\s*=\s*(.+?)\s*$/); + if (match) env[match[1]] ||= match[2].replace(/^['"]|['"]$/g, ""); + } +} + +if (!env.ART_SPACES_KEY || !env.ART_SPACES_SECRET) { + throw new Error(`Art Space credentials missing from environment or ${vault}`); +} + +const files = [ + { ext: "mp4", type: "video/mp4" }, + { ext: "webp", type: "image/webp" }, +].map((asset) => ({ + ...asset, + path: resolve(OUT, `${slug}.${asset.ext}`), + key: `pals/turnarounds/v1/${slug}.${asset.ext}`, +})); + +for (const asset of files) { + if (!existsSync(asset.path)) throw new Error(`Missing ${asset.path}`); +} + +const client = new S3Client({ + endpoint: env.ART_SPACES_ENDPOINT || "https://sfo3.digitaloceanspaces.com", + region: "sfo3", + credentials: { + accessKeyId: env.ART_SPACES_KEY, + secretAccessKey: env.ART_SPACES_SECRET, + }, +}); +const bucket = env.ART_SPACES_BUCKET || "art-aesthetic-computer"; +const cdn = env.ART_CDN_BASE || "https://art.aesthetic.computer"; + +for (const asset of files) { + await client.send(new PutObjectCommand({ + Bucket: bucket, + Key: asset.key, + Body: readFileSync(asset.path), + ContentType: asset.type, + ACL: "public-read", + CacheControl: "public, max-age=31536000, immutable", + })); + const url = `${cdn}/${asset.key}`; + const response = await fetch(url, { method: "HEAD", cache: "no-store" }); + if (!response.ok) throw new Error(`Published ${url}, verification returned ${response.status}`); + console.log(`${asset.ext}: ${url}`); +} diff --git a/marketing/talking-head/bin/float-community.mjs b/marketing/talking-head/bin/float-community.mjs new file mode 100644 --- /dev/null +++ b/marketing/talking-head/bin/float-community.mjs @@ -0,0 +1,218 @@ +// float-community.mjs — let public AC moods and chats rise through a finished reel. +// +// This is a post-process so the expensive talking-head song, lip sync, karaoke, +// and side chrome stay untouched. A reviewed public snapshot is supplied as JSON; +// the render never fetches live user text by itself. +// +// node marketing/talking-head/bin/float-community.mjs \ +// marketing/talking-head/out/yc-2018-song-reel.mp4 \ +// --data marketing/talking-head/community-2026-08-05.json \ +// --out marketing/talking-head/out/yc-2018-community-reel.mp4 +// +// --start 39.8 first community line in source time +// --from 38 begin an excerpt at source time (review renders) +// --seconds 24 excerpt duration + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { createCanvas, createImageData, registerFont } from "canvas"; + +const W = 1080; +const H = 1920; +const FPS = 30; +const argv = process.argv.slice(2); +const flag = (name, fallback = null) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") + ? argv[i + 1] + : fallback; +}; + +const SRC = resolve(argv.find((arg) => !arg.startsWith("--")) || ""); +const DATA = resolve(flag("data", "marketing/talking-head/community-2026-08-05.json")); +const OUT = resolve(flag("out", "marketing/talking-head/out/community-reel.mp4")); +const START = Number(flag("start", "39.8")); +const FROM = Math.max(0, Number(flag("from", "0"))); + +if (!existsSync(SRC) || !existsSync(DATA)) { + console.error("usage: float-community.mjs --data snapshot.json [--out reel.mp4]"); + process.exit(2); +} + +const probe = spawnSync("ffprobe", [ + "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", SRC, +], { encoding: "utf8" }); +if (probe.status !== 0) throw new Error(probe.stderr || "ffprobe failed"); +const sourceDuration = Number(probe.stdout.trim()); +const requestedSeconds = flag("seconds") ? Number(flag("seconds")) : sourceDuration - FROM; +const DURATION = Math.max(0, Math.min(requestedSeconds, sourceDuration - FROM)); +const FRAMES = Math.round(DURATION * FPS); +const items = JSON.parse(readFileSync(DATA, "utf8")).items || []; + +const fontPath = [ + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/HelveticaNeue.ttc", +].find(existsSync); +if (fontPath) registerFont(fontPath, { family: "Community", weight: "bold" }); +const FONT = fontPath ? "Community" : "Helvetica"; + +const dec = spawn("ffmpeg", [ + "-v", "error", "-ss", String(FROM), "-i", SRC, + "-t", String(DURATION), "-f", "rawvideo", "-pix_fmt", "rgba", "-vf", `fps=${FPS}`, "-", +], { stdio: ["ignore", "pipe", "inherit"] }); + +const enc = spawn("ffmpeg", [ + "-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pix_fmt", "bgra", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "-", + "-ss", String(FROM), "-t", String(DURATION), "-i", SRC, + "-map", "0:v", "-map", "1:a:0", + "-c:v", "libx264", "-preset", "medium", "-crf", "16", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", OUT, +], { stdio: ["pipe", "inherit", "inherit"] }); + +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); +ctx.imageSmoothingEnabled = true; +ctx.imageSmoothingQuality = "high"; +const FRAME_BYTES = W * H * 4; + +const wrap = (text, maxWidth) => { + ctx.font = `bold 44px ${FONT}`; + const words = String(text).replace(/\s+/g, " ").trim().split(" "); + const lines = []; + let line = ""; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (line && ctx.measureText(next).width > maxWidth) { + lines.push(line); + line = word; + } else { + line = next; + } + } + if (line) lines.push(line); + return lines.slice(0, 3); +}; + +const entries = items.map((item, index) => ({ + ...item, + at: START + index * 1.8, + duration: 9, + lane: index % 2, + phase: index * 1.73, + lines: wrap(item.text, 360), +})); + +const smoothstep = (x) => { + const v = Math.max(0, Math.min(1, x)); + return v * v * (3 - 2 * v); +}; + +const overlay = createCanvas(W, H); +const overlayCtx = overlay.getContext("2d"); + +const drawText = (text, x, y, size, color, alpha) => { + overlayCtx.font = `bold ${size}px ${FONT}`; + overlayCtx.lineJoin = "round"; + overlayCtx.lineWidth = size >= 40 ? 7 : 5; + overlayCtx.strokeStyle = `rgba(0,0,0,${0.78 * alpha})`; + overlayCtx.fillStyle = color; + overlayCtx.globalAlpha = alpha; + overlayCtx.strokeText(text, x, y); + overlayCtx.fillText(text, x, y); + overlayCtx.globalAlpha = 1; +}; + +const drawCommunity = (sourceTime) => { + overlayCtx.clearRect(0, 0, W, H); + for (const entry of entries) { + const life = (sourceTime - entry.at) / entry.duration; + if (life < 0 || life > 1) continue; + + const blockHeight = 38 + entry.lines.length * 52; + const y = H + blockHeight - life * (H + blockHeight * 2); + const x = (entry.lane === 0 ? 148 : 578) + Math.sin(life * Math.PI * 2 + entry.phase) * 16; + const edgeFade = Math.min(smoothstep(life / 0.09), smoothstep((1 - life) / 0.13)); + const centerY = y - blockHeight / 2; + const alpha = 0.62 * edgeFade; + const textColor = entry.kind === "mood" ? "rgb(72,255,176)" : "rgb(255,245,202)"; + const mark = entry.kind === "mood" ? "○" : ">"; + + drawText(`${mark} ${entry.from}`, x, y, 28, "rgb(255,74,196)", alpha * 0.9); + entry.lines.forEach((line, lineIndex) => { + drawText(line, x, y + 52 + lineIndex * 52, 44, textColor, alpha); + }); + } + + // The messages are atmosphere, not foreground labels. Punch the stable face + // crop out of the overlay with a feathered ellipse so lines travel behind the + // speaker instead of tattooing his eyes and mouth. + overlayCtx.save(); + overlayCtx.globalCompositeOperation = "destination-out"; + overlayCtx.translate(W / 2, 585); + overlayCtx.scale(1, 1.42); + const faceMask = overlayCtx.createRadialGradient(0, 0, 285, 0, 0, 460); + faceMask.addColorStop(0, "rgba(0,0,0,1)"); + faceMask.addColorStop(0.72, "rgba(0,0,0,0.96)"); + faceMask.addColorStop(1, "rgba(0,0,0,0)"); + overlayCtx.fillStyle = faceMask; + overlayCtx.beginPath(); + overlayCtx.arc(0, 0, 460, 0, Math.PI * 2); + overlayCtx.fill(); + overlayCtx.restore(); + + // Let community lines travel continuously, but occlude them behind the baked + // karaoke band. Feathering the edges makes the passage read as depth rather + // than a line abruptly blinking off. + overlayCtx.save(); + overlayCtx.globalCompositeOperation = "destination-out"; + const captionMask = overlayCtx.createLinearGradient(0, 680, 0, 1120); + captionMask.addColorStop(0, "rgba(0,0,0,0)"); + captionMask.addColorStop(0.25, "rgba(0,0,0,1)"); + captionMask.addColorStop(0.75, "rgba(0,0,0,1)"); + captionMask.addColorStop(1, "rgba(0,0,0,0)"); + overlayCtx.fillStyle = captionMask; + overlayCtx.fillRect(0, 680, W, 440); + overlayCtx.restore(); + + ctx.drawImage(overlay, 0, 0); +}; + +const write = (buffer) => new Promise((resolveWrite) => { + if (enc.stdin.write(buffer)) resolveWrite(); + else enc.stdin.once("drain", resolveWrite); +}); + +const it = dec.stdout[Symbol.asyncIterator](); +let buffered = Buffer.alloc(0); +let ended = false; +const nextFrame = async () => { + while (buffered.length < FRAME_BYTES && !ended) { + const { value, done } = await it.next(); + if (done) { ended = true; break; } + buffered = buffered.length ? Buffer.concat([buffered, value]) : value; + } + if (buffered.length < FRAME_BYTES) return null; + const frame = buffered.subarray(0, FRAME_BYTES); + buffered = buffered.subarray(FRAME_BYTES); + return frame; +}; + +console.log(`· ${items.length} public community lines · ${START.toFixed(1)}s start`); +console.log(`· rendering ${FROM.toFixed(1)}–${(FROM + DURATION).toFixed(1)}s → ${basename(OUT)}`); +let rendered = 0; +for (let frame = 0; frame < FRAMES; frame += 1) { + const rgba = await nextFrame(); + if (!rgba) break; + ctx.putImageData(createImageData(new Uint8ClampedArray(rgba), W, H), 0, 0); + drawCommunity(FROM + frame / FPS); + await write(canvas.toBuffer("raw")); + rendered += 1; + if (frame % 150 === 0) process.stdout.write(`\r ${frame}/${FRAMES}`); +} + +enc.stdin.end(); +await new Promise((resolveClose) => enc.on("close", resolveClose)); +process.stdout.write(`\r ${rendered}/${FRAMES}\n`); +console.log(`✓ ${OUT}`); diff --git a/marketing/talking-head/community-2026-08-05.json b/marketing/talking-head/community-2026-08-05.json new file mode 100644 --- /dev/null +++ b/marketing/talking-head/community-2026-08-05.json @@ -0,0 +1,81 @@ +{ + "fetchedAt": "2026-08-05T20:24:00-07:00", + "sources": { + "mood": "https://aesthetic.computer/api/mood/all", + "chat": "https://aesthetic.computer/api/chat-messages?instance=system&limit=100" + }, + "items": [ + { + "kind": "mood", + "from": "@theamerican", + "text": "all is noise", + "when": "2026-07-22T21:51:42.501Z" + }, + { + "kind": "chat", + "from": "@pazuzu", + "text": "i found a cool stick in the woods today", + "when": "2026-06-12T20:43:30.548Z" + }, + { + "kind": "mood", + "from": "@sol", + "text": "art blocked", + "when": "2026-07-18T02:29:21.548Z" + }, + { + "kind": "chat", + "from": "@wickmrt", + "text": "hello im new", + "when": "2026-06-18T08:13:28.124Z" + }, + { + "kind": "mood", + "from": "@sat", + "text": "go and draw", + "when": "2026-07-15T18:28:59.681Z" + }, + { + "kind": "chat", + "from": "@handlehun", + "text": "Vinker til alle. Jeg er ny her. Vi ses!", + "when": "2026-06-18T17:40:00.933Z" + }, + { + "kind": "mood", + "from": "@q13R608tx", + "text": "early spacetravel", + "when": "2026-07-08T08:33:22.710Z" + }, + { + "kind": "chat", + "from": "@DaveDave", + "text": "what is this, where am I?", + "when": "2026-07-10T08:58:14.028Z" + }, + { + "kind": "mood", + "from": "@steve", + "text": "little bits of sesame seeds in my salad", + "when": "2026-07-08T06:19:59.809Z" + }, + { + "kind": "chat", + "from": "@KSHALE", + "text": "upgrade to paper", + "when": "2026-08-03T16:50:32.320Z" + }, + { + "kind": "mood", + "from": "@HendeFraHoeve", + "text": "sunny side up", + "when": "2026-04-24T10:20:11.242Z" + }, + { + "kind": "chat", + "from": "@caeneus", + "text": "interesting radio", + "when": "2026-06-01T22:45:18.143Z" + } + ] +} -- tangled.sh