diff --git a/marketing/bin/build-ac-platter.py b/marketing/bin/build-ac-platter.py new file mode 100644 index 0000000000..19e8c22a72 --- /dev/null +++ b/marketing/bin/build-ac-platter.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +build-ac-platter.py — assemble the real AC screen captures (capture-ac-platter.mjs +output in marketing/captures/platter/) into one labeled contact-sheet "platter". + +Real product frames, no generation. Use the platter as a reference board for +gpt-image-2 regens (e.g. the Restless Egg felt video's laptop screens) and as +laptop-screen imagery. Brand styling matches the deck/video (YWFT + Arial, +warm cream, coral accents). + +Usage: python3 marketing/bin/build-ac-platter.py +""" + +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +HERE = Path(__file__).parent +SHOTS = HERE.parent / "captures" / "platter" +OUT = HERE.parent / "captures" / "ac-platter.png" + +CREAM = (240, 230, 207); INK = (44, 32, 50); DIM = (120, 100, 86) +CORAL = (208, 96, 84); VIOLET = (138, 100, 184); GREEN = (110, 168, 92) +ACCENTS = [CORAL, VIOLET, GREEN] + +YWFT_BOLD = str(Path.home() / "Library" / "Fonts" / "ywft-processing-bold.ttf") +ARIAL = "/System/Library/Fonts/Supplemental/Arial.ttf" +ARIAL_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" +FALLBACK = "/System/Library/Fonts/SFNS.ttf" + +# name → (display label). Order here = order on the platter. +LABELS = [ + ("notepat", "notepat — the instrument"), + ("prompt", "the prompt — aesthetic.computer"), + ("kidlisp-berz", "$berz — a KidLisp piece"), + ("kidlisp-cow", "$cow — a KidLisp piece"), + ("kidlisp-roz", "$roz — a KidLisp piece"), + ("kidlisp-ican", "$ican — a KidLisp piece"), +] + +COLS = 2 +THUMB_W = 1080 +PAD = 80 +GAP = 56 +LABEL_H = 76 + + +def font(path, size): + try: return ImageFont.truetype(path, size) + except: return ImageFont.truetype(FALLBACK, size) + + +def fit_contain(img, tw, th, bg=(18, 14, 24)): + canvas = Image.new("RGB", (tw, th), bg) + sw, sh = img.size + s = min(tw / sw, th / sh) + nw, nh = int(sw * s), int(sh * s) + canvas.paste(img.resize((nw, nh), Image.LANCZOS), ((tw - nw) // 2, (th - nh) // 2)) + return canvas + + +present = [(n, l) for n, l in LABELS if (SHOTS / f"{n}.png").exists()] +if not present: + raise SystemExit(f"no captures found in {SHOTS} — run capture-ac-platter.mjs first") + +# thumbnail height from the first shot's aspect (they share a viewport) +first = Image.open(SHOTS / f"{present[0][0]}.png") +aspect = first.size[1] / first.size[0] +THUMB_H = int(THUMB_W * aspect) +cell_h = THUMB_H + LABEL_H + +rows = (len(present) + COLS - 1) // COLS +HEAD = 200 +W = PAD * 2 + COLS * THUMB_W + (COLS - 1) * GAP +H = HEAD + rows * cell_h + (rows - 1) * GAP + PAD + +img = Image.new("RGB", (W, H), CREAM) +d = ImageDraw.Draw(img) + +# header +d.text((PAD, 56), "AESTHETIC.COMPUTER", font=font(ARIAL_BOLD, 30), fill=CORAL) +d.text((PAD, 92), "screen platter / real product frames", font=font(YWFT_BOLD, 78), fill=INK) +d.rectangle([PAD, HEAD - 22, PAD + 90, HEAD - 14], fill=CORAL) + +for i, (name, label) in enumerate(present): + r, c = divmod(i, COLS) + x = PAD + c * (THUMB_W + GAP) + y = HEAD + r * (cell_h + GAP) + accent = ACCENTS[i % len(ACCENTS)] + thumb = fit_contain(Image.open(SHOTS / f"{name}.png").convert("RGB"), THUMB_W, THUMB_H) + img.paste(thumb, (x, y)) + d.rectangle([x, y, x + THUMB_W - 1, y + THUMB_H - 1], outline=accent, width=4) + d.ellipse([x, y + THUMB_H + 26, x + 14, y + THUMB_H + 40], fill=accent) + d.text((x + 32, y + THUMB_H + 18), label, font=font(ARIAL, 36), fill=INK) + +d.text((PAD, H - 58), "marketing/captures/platter · regenerate with capture-ac-platter.mjs", + font=font(ARIAL, 24), fill=DIM) + +img.save(OUT) +print(f"✓ {OUT} ({len(present)} screens, {W}x{H})") diff --git a/marketing/bin/capture-ac-platter.mjs b/marketing/bin/capture-ac-platter.mjs new file mode 100644 index 0000000000..fe248c566d --- /dev/null +++ b/marketing/bin/capture-ac-platter.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +// capture-ac-platter.mjs — capture a platter of REAL aesthetic.computer / +// notepat screens (live production), at laptop aspect, for use as laptop-screen +// imagery and as identity refs for gpt-image-2 regens (e.g. the Restless Egg +// felt video). No generation — these are actual frames of the running product. +// +// Writes one PNG per target to marketing/captures/platter/.png, then run +// build-ac-platter.py to assemble the labeled contact sheet. +// +// Usage: +// node marketing/bin/capture-ac-platter.mjs # all targets +// node marketing/bin/capture-ac-platter.mjs --only notepat # one target +// node marketing/bin/capture-ac-platter.mjs --out ~/Desktop/foo +// node marketing/bin/capture-ac-platter.mjs --width 1280 --height 800 --scale 2 + +import { existsSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); + +const argv = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[a.slice(2)] = next; i++; } + else flags[a.slice(2)] = true; + } +} + +const OUT = resolve((flags.out || `${REPO}/marketing/captures/platter`).replace(/^~/, process.env.HOME)); +mkdirSync(OUT, { recursive: true }); + +const PUPPETEER_DIR = [ + `${REPO}/oven/node_modules/puppeteer`, + "/opt/oven/node_modules/puppeteer", +].find((p) => existsSync(p)); +if (!PUPPETEER_DIR) throw new Error("puppeteer not found in oven/node_modules"); +const puppeteer = (await import(`${PUPPETEER_DIR}/lib/esm/puppeteer/puppeteer.js`)).default; + +// Curated platter of live AC screens. boot=true → click + Space to wake audio/ +// piece; settle is per-target ms to let the piece paint. +const BASE = "https://aesthetic.computer"; +const TARGETS = [ + { name: "notepat", url: `${BASE}/notepat`, label: "notepat — the instrument", settle: 6000, boot: true }, + { name: "prompt", url: `${BASE}/prompt`, label: "aesthetic.computer — the prompt", settle: 5000, boot: true, type: "notepat" }, + { name: "kidlisp-berz", url: `${BASE}/$berz`, label: "$berz — a KidLisp piece", settle: 7000, boot: true }, + { name: "kidlisp-cow", url: `${BASE}/$cow`, label: "$cow — a KidLisp piece", settle: 7000, boot: true }, + { name: "kidlisp-roz", url: `${BASE}/$roz`, label: "$roz — a KidLisp piece", settle: 7000, boot: true }, + { name: "kidlisp-ican", url: `${BASE}/$ican`, label: "$ican — a KidLisp piece", settle: 7000, boot: true }, +]; + +// Chunkier pixels read better as laptop-screen imagery / felt-screen refs. +// AC's ?density=N scales the canvas buffer — higher = bigger pixels (device +// mode uses 8). Tune per capture with --density. +const DENSITY = flags.density ? String(flags.density) : "4"; +const withDensity = (url) => `${url}${url.includes("?") ? "&" : "?"}density=${DENSITY}`; + +const only = typeof flags.only === "string" ? flags.only.split(",").map((s) => s.trim()) : null; +const targets = only ? TARGETS.filter((t) => only.includes(t.name)) : TARGETS; + +// Puppeteer's bundled Chrome may be absent; fall back to the system Chrome. +const CHROME = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", +].find((p) => p && existsSync(p)); + +console.log(`▸ capture-ac-platter → ${OUT} (${targets.length} targets)${CHROME ? ` [${CHROME.split("/").pop()}]` : ""}`); +const browser = await puppeteer.launch({ + headless: "new", + ...(CHROME ? { executablePath: CHROME } : {}), + args: ["--no-sandbox", "--use-gl=swiftshader", "--autoplay-policy=no-user-gesture-required"], +}); + +for (const t of targets) { + const page = await browser.newPage(); + await page.setViewport({ + width: parseInt(flags.width || 1280, 10), + height: parseInt(flags.height || 800, 10), + deviceScaleFactor: parseFloat(flags.scale || 2), + }); + page.on("pageerror", (e) => console.log(` [${t.name}/pageerror] ${e.message.slice(0, 120)}`)); + try { + await page.goto(withDensity(t.url), { waitUntil: "networkidle2", timeout: 45000 }); + } catch (e) { + console.log(` ⚠ ${t.name}: goto ${e.message.slice(0, 80)} — capturing anyway`); + } + if (t.boot) { + await page.evaluate(() => { + const c = document.querySelector("canvas") || document.getElementById("screen"); + if (c) { c.focus(); c.click(); } + }).catch(() => {}); + await page.keyboard.press("Space").catch(() => {}); + } + await new Promise((r) => setTimeout(r, t.settle)); + // optional: type a command to reveal the prompt's live discovery UI (no Enter) + if (typeof t.type === "string") { + await page.keyboard.type(t.type, { delay: 110 }).catch(() => {}); + await new Promise((r) => setTimeout(r, 2500)); + } + const out = `${OUT}/${t.name}.png`; + await page.screenshot({ path: out, omitBackground: false }); + console.log(`✓ ${t.name} → ${out}`); + await page.close(); +} + +await browser.close(); +console.log("done — assemble with: python3 marketing/bin/build-ac-platter.py");