Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// jeffrey-photos.mjs — pre-pipeline step. For each segment in the audience
3// config that has a `metaphor` field, call OpenAI gpt-image-2 (images.edit)
4// with the platter SHOOT_REFS + SELFIE_REFS for identity grounding and save
5// the result to recap/out/jeffrey-photos/<segment>.png.
6//
7// Each successful generation is also archived into the jeffrey platter as a
8// new entry in the `gens` bucket: a dated copy of the PNG goes to
9// system/public/assets/jeffreys/gens/<context>-<segment>-<ts>.png (synced to
10// the CDN by `npm run assets:sync:up`), and an item with full provenance
11// (model, refs, prompt, context, segment, timestamp) is appended to
12// papers/jeffrey-platter/manifest.json under buckets.gens.items.
13//
14// Caching: skip if the working pipeline cache already exists; pass --force to
15// regen. Run a single segment by name: --only 02_menuband.
16//
17// Usage:
18// node bin/jeffrey-photos.mjs jeffrey-24h
19// node bin/jeffrey-photos.mjs jeffrey-24h --force
20// node bin/jeffrey-photos.mjs jeffrey-24h --only 04_platter
21
22import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
23import { resolve, dirname } from "node:path";
24import { fileURLToPath } from "node:url";
25
26const HERE = dirname(fileURLToPath(import.meta.url));
27const ROOT = resolve(HERE, "..");
28const REPO = resolve(ROOT, "..");
29
30const audienceName = process.argv[2] || "jeffrey-24h";
31const force = process.argv.includes("--force");
32const onlyIdx = process.argv.indexOf("--only");
33const only = onlyIdx >= 0 ? process.argv[onlyIdx + 1] : null;
34
35const { audience } = await import(`${ROOT}/audience/${audienceName}.mjs`);
36
37const PHOTOS_DIR = `${ROOT}/out/jeffrey-photos`;
38mkdirSync(PHOTOS_DIR, { recursive: true });
39
40// Platter archive — a copy of every successful gen goes here, plus a manifest
41// entry. The dir is synced to assets.aesthetic.computer/jeffreys/gens/ via
42// `npm run assets:sync:up`.
43const PLATTER_GENS_DIR = `${REPO}/system/public/assets/jeffreys/gens`;
44const PLATTER_MANIFEST = `${REPO}/papers/jeffrey-platter/manifest.json`;
45mkdirSync(PLATTER_GENS_DIR, { recursive: true });
46
47// Mirrors generate-neo.py refs.
48const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot`;
49const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`;
50const SHOOT_REFS = [
51 `${SHOOT_DIR}/jeffery-av--07.jpg`,
52 `${SHOOT_DIR}/jeffery-av--01.jpg`,
53 `${SHOOT_DIR}/jeffery-av--04.jpg`,
54];
55const SELFIE_REFS = [
56 `${ARCHIVE_DIR}/2018-12-02_Bq4ckGFFNtW.jpg`,
57 `${ARCHIVE_DIR}/2020-09-02_CEpxlO2FOvD.jpg`,
58 `${ARCHIVE_DIR}/2021-07-10_CRI095Vl7AO_1.jpg`,
59 `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`,
60 `${ARCHIVE_DIR}/2017-04-10_BStid5yjTHq.jpg`,
61];
62const REFS = [...SHOOT_REFS, ...SELFIE_REFS].filter((p) => {
63 if (existsSync(p)) return true;
64 console.warn(` ⚠ ref missing, dropping: ${p}`);
65 return false;
66});
67
68function loadOpenAIKey() {
69 if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
70 const vault = `${REPO}/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env`;
71 if (existsSync(vault)) {
72 for (const line of readFileSync(vault, "utf8").split("\n")) {
73 if (line.startsWith("OPENAI_API_KEY=")) {
74 return line.slice("OPENAI_API_KEY=".length).trim().replace(/^['"]|['"]$/g, "");
75 }
76 }
77 }
78 throw new Error("OPENAI_API_KEY not set and not found in vault devcontainer.env");
79}
80
81const apiKey = loadOpenAIKey();
82
83const MODEL = "gpt-image-2";
84const SIZE = "1024x1536";
85const QUALITY = "high";
86
87async function generate(metaphor, outPath) {
88 const fd = new FormData();
89 fd.append("model", MODEL);
90 fd.append("prompt", metaphor);
91 fd.append("size", SIZE);
92 fd.append("quality", QUALITY);
93 fd.append("n", "1");
94 for (const ref of REFS) {
95 const buf = readFileSync(ref);
96 const ext = ref.toLowerCase().endsWith(".png") ? "png" : "jpeg";
97 fd.append("image[]", new Blob([buf], { type: `image/${ext}` }), ref.split("/").pop());
98 }
99 const res = await fetch("https://api.openai.com/v1/images/edits", {
100 method: "POST",
101 headers: { Authorization: `Bearer ${apiKey}` },
102 body: fd,
103 });
104 if (!res.ok) {
105 const err = await res.text();
106 throw new Error(`OpenAI ${res.status}: ${err.slice(0, 500)}`);
107 }
108 const json = await res.json();
109 const b64 = json.data?.[0]?.b64_json;
110 if (!b64) throw new Error(`no image returned: ${JSON.stringify(json).slice(0, 200)}`);
111 writeFileSync(outPath, Buffer.from(b64, "base64"));
112 const usage = json.usage || {};
113 return { tokens_in: usage.input_tokens, tokens_out: usage.output_tokens };
114}
115
116function isoStamp() {
117 const d = new Date();
118 const pad = (n) => String(n).padStart(2, "0");
119 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}`;
120}
121
122// Archive a successful gen into the platter: copy PNG to the assets/gens dir
123// + append a metadata entry to papers/jeffrey-platter/manifest.json. Lazy-
124// initializes the `gens` bucket if it doesn't exist yet.
125function archiveToPlatter({ segName, metaphor, sourcePath, context }) {
126 const stamp = isoStamp();
127 const archiveName = `${context}-${segName}-${stamp}.png`;
128 const archivePath = `${PLATTER_GENS_DIR}/${archiveName}`;
129 writeFileSync(archivePath, readFileSync(sourcePath));
130
131 const manifest = JSON.parse(readFileSync(PLATTER_MANIFEST, "utf8"));
132 manifest.buckets.gens ??= {
133 label: "Generated images — gpt-image-2 with platter-grounded identity (real+goofy default tone). One PNG per successful gen, dated. Synced to assets CDN via `npm run assets:sync:up`.",
134 url_pattern: "https://assets.aesthetic.computer/jeffreys/gens/{name}",
135 key_includes_extension: true,
136 items: {},
137 };
138 manifest.buckets.gens.items[archiveName] = {
139 model: MODEL,
140 size: SIZE,
141 quality: QUALITY,
142 refs: REFS.map((r) => r.replace(REPO + "/", "")),
143 context,
144 segment: segName,
145 generated: new Date().toISOString(),
146 bytes: statSync(archivePath).size,
147 prompt: metaphor,
148 };
149 writeFileSync(PLATTER_MANIFEST, JSON.stringify(manifest, null, 2) + "\n");
150 return { archiveName, archivePath };
151}
152
153const context = `recap-${audienceName}`;
154
155console.log(`refs: ${REFS.length} (${REFS.length} found)`);
156console.log(`out: ${PHOTOS_DIR}`);
157console.log(`platter archive: ${PLATTER_GENS_DIR.replace(REPO + "/", "")}`);
158
159let generated = 0, cached = 0, failed = 0;
160for (const seg of audience.segments) {
161 if (only && seg.name !== only) continue;
162 const slide = audience.slides[seg.name];
163 const metaphor = slide && typeof slide === "object" ? slide.metaphor : null;
164 if (!metaphor) {
165 console.log(` · ${seg.name}: no metaphor, skipping`);
166 continue;
167 }
168 const outPath = `${PHOTOS_DIR}/${seg.name}.png`;
169 if (existsSync(outPath) && !force) {
170 console.log(` ✓ ${seg.name}.png (cached)`);
171 cached++;
172 continue;
173 }
174 process.stdout.write(`▸ ${seg.name}… `);
175 const t0 = Date.now();
176 try {
177 const usage = await generate(metaphor, outPath);
178 const archive = archiveToPlatter({ segName: seg.name, metaphor, sourcePath: outPath, context });
179 const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
180 const tok = usage.tokens_in
181 ? ` · tokens in=${usage.tokens_in} out=${usage.tokens_out}`
182 : "";
183 console.log(`✓ ${elapsed}s${tok} → ${archive.archiveName}`);
184 generated++;
185 } catch (e) {
186 console.log(`✗`);
187 console.error(` ${e.message}`);
188 failed++;
189 }
190}
191
192console.log(`✓ photos: ${generated} new, ${cached} cached, ${failed} failed`);
193if (generated > 0) {
194 console.log(` · platter manifest updated: papers/jeffrey-platter/manifest.json`);
195 console.log(` · run \`node papers/jeffrey-platter/sync.mjs\` to refresh consumer copy`);
196 console.log(` · run \`npm run assets:sync:up\` to push gens/ to the CDN`);
197}
198// Don't fail the pipeline on per-segment gen failures — slides fall back to a
199// dark-bg placeholder when the glob matches nothing. Re-run later to retry
200// just the missing photos.