diff --git a/tests/local-synthetic-images.test.mjs b/tests/local-synthetic-images.test.mjs
new file mode 100644
index 0000000..a7e1feb
--- /dev/null
+++ b/tests/local-synthetic-images.test.mjs
@@ -0,0 +1,66 @@
+// tests/local-synthetic-images.test.mjs — the offline image engine.
+//
+// When no cloud image engine is available (no OpenAI/Grok key, or their probes fail), the hero
+// and section images are rendered deterministically from the brand palette with sharp — a seeded
+// SVG palette-gradient. It is clearly LABELLED local-synthetic and recorded as $0 in the cost
+// slot, never a faked "gpt-image" receipt. EXPLAINMYREPO_LOCAL_IMAGES=1 forces it explicitly.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const TOOL = path.join(REPO, 'tools', 'generate-image.mjs');
+const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+// Source-level pins: the selection rule and the honest cost basis live in generate-image.mjs.
+test('local-synthetic — selected as a fallback when no cloud image key is available', () => {
+ const src = fs.readFileSync(TOOL, 'utf8');
+ assert.match(src, /const useLocal = localFlag \|\| \(!grokOK && !openaiModel\)/,
+ 'the synthetic lane is the fallback when neither cloud engine is available');
+ assert.match(src, /EXPLAINMYREPO_LOCAL_IMAGES/, 'an explicit opt-in flag must force it');
+ assert.match(src, /engines\.hero = 'local-synthetic'/);
+ assert.match(src, /engines\.section = 'local-synthetic'/);
+ assert.match(src, /no external image API used/, 'the cost basis must say it used no cloud API');
+});
+
+// Behavioural: force the flag, no cloud keys in env, and confirm real PNGs + a $0 labelled slot.
+test('local-synthetic — produces real PNGs and a $0 labelled cost slot with no cloud keys', () => {
+ const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'synth-'));
+ const ctx = {
+ repo: { name: 'demo', slug: 'demo' },
+ concept: { palette: { primary: '#7c3aed', accent: '#38bdf8', secondary: '#f472b6' } },
+ visuals: {
+ hero: { id: 'hero', role: 'hero', px: '1536x1024', prompt: 'a calm architectural hero' },
+ sections: [
+ { id: 'sec1', role: 'section', px: '1024x1024', prompt: 'a data flow section' },
+ ],
+ },
+ };
+ fs.writeFileSync(path.join(buildDir, 'build.json'), JSON.stringify(ctx, null, 2));
+ const r = spawnSync(process.execPath, [TOOL, buildDir], {
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ EXPLAINMYREPO_LOCAL_IMAGES: '1',
+ OPENAI_API_KEY: '', OPEN_AI_KEY: '', GROK_API_KEY: '', XAI_API_KEY: '',
+ },
+ });
+ assert.equal(r.status, 0, `synthetic generate-image must succeed: ${r.stdout}\n${r.stderr}`);
+ const out = JSON.parse(r.stdout);
+ assert.equal(out.ok, true);
+ assert.match(String(out.outputs.engine || ''), /local-synthetic/);
+ // Every produced file is a genuine PNG.
+ for (const f of out.outputs.files) {
+ const buf = fs.readFileSync(f);
+ assert.ok(buf.subarray(0, 8).equals(PNG_MAGIC), `${f} must be a real PNG`);
+ }
+ // Cost slot is honest: $0, and the basis names no cloud API.
+ const merged = JSON.parse(fs.readFileSync(path.join(buildDir, 'build.json'), 'utf8'));
+ assert.equal(merged.visuals.cost.usd, 0, 'a synthetic render costs $0');
+ assert.match(merged.visuals.cost.basis, /no external image API used/);
+});
diff --git a/tools/generate-image.mjs b/tools/generate-image.mjs
index 16c4e9c..e172d29 100644
--- a/tools/generate-image.mjs
+++ b/tools/generate-image.mjs
@@ -226,6 +226,84 @@ async function generateOneGrok(prompt, targetPx, apiKey) {
function safeName(s) { return String(s).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'image'; }
+// ── LOCAL-SYNTHETIC engine ──────────────────────────────────────────────────────────────────────
+// When no cloud image engine is available (no OpenAI/Grok key, or their probes fail), the
+// atmospheric rungs are rendered deterministically from the brand palette with sharp: a diagonal
+// palette gradient plus soft translucent shapes, seeded from the prompt so every rung differs but
+// re-runs are stable (the resume cache still applies on top). It is clearly LABELLED
+// local-synthetic and costs $0 in the slot — an honest stand-in, not a fake "gpt-image" receipt.
+// Auto-selected only as a fallback when the cloud engines are absent, so hosted runs are
+// untouched; EXPLAINMYREPO_LOCAL_IMAGES=1 forces it explicitly (offline builds by choice).
+function fnv1a(s) {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
+ return h >>> 0;
+}
+function mulberry32(a) {
+ return function () {
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+function hexToRgbaStr(h, alpha) {
+ let s = String(h).replace('#', '');
+ if (s.length === 3 || s.length === 4) s = s.slice(0, 3).split('').map((c) => c + c).join('');
+ if (s.length < 6) return null;
+ const r = parseInt(s.slice(0, 2), 16), g = parseInt(s.slice(2, 4), 16), b = parseInt(s.slice(4, 6), 16);
+ if ([r, g, b].some((n) => Number.isNaN(n))) return null;
+ return `rgba(${r},${g},${b},${alpha})`;
+}
+function buildSyntheticSvg(w, h, prompt, palette) {
+ const rnd = mulberry32(fnv1a(prompt || 'explainmyrepo'));
+ const stops = [
+ { off: '0%', color: 'rgba(15,18,32,1)' },
+ { off: '55%', color: 'rgba(15,18,32,1)' },
+ { off: '100%', color: 'rgba(15,18,32,1)' },
+ ];
+ const circles = [];
+ const rectEls = [];
+ const n = 7 + Math.floor(rnd() * 5);
+ for (let i = 0; i < n; i++) {
+ const c = palette[i % palette.length];
+ const fill = hexToRgbaStr(c, (0.10 + rnd() * 0.22).toFixed(3));
+ if (!fill) continue;
+ if (i % 3 === 0) {
+ rectEls.push(``);
+ } else {
+ circles.push(``);
+ }
+ }
+ const cA = hexToRgbaStr(palette[0], 0.85) || 'rgba(124,58,237,0.85)';
+ const cB = hexToRgbaStr(palette[palette.length - 1], 0.85) || 'rgba(56,189,248,0.85)';
+ stops[2].color = cB;
+ const gradAngle = 20 + rnd() * 50;
+ return ``;
+}
+async function generateOneLocal(prompt, px, palette) {
+ const [w, h] = px.split('x').map(Number);
+ if (!w || !h) throw new Error(`cannot parse px="${px}" for the local synthetic engine`);
+ const flat = [];
+ (function collect(v) {
+ if (flat.length >= 6) return;
+ if (typeof v === 'string') { const s = v.trim(); if (/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s)) flat.push(s); }
+ else if (Array.isArray(v)) { for (const x of v) collect(x); }
+ else if (v && typeof v === 'object') { for (const x of Object.values(v)) collect(x); }
+ })(palette || {});
+ const colors = flat.length ? flat : ['#7c3aed', '#38bdf8', '#f472b6'];
+ const buf = await sharp(Buffer.from(buildSyntheticSvg(w, h, prompt, colors))).png().toBuffer();
+ if (!buf.subarray(0, 8).equals(PNG_MAGIC)) throw new Error('local synthetic engine produced non-PNG output');
+ return buf;
+}
+
// Build a deterministic colour-direction suffix from the brain's palette (pure transform).
function paletteSuffix(palette) {
if (!palette || typeof palette !== 'object') return '';
@@ -300,20 +378,24 @@ async function main() {
// Either engine covers for the other if its probe fails, so a missing key degrades, never dies.
let generateFn = null;
const engines = {};
+ // LOCAL-SYNTHETIC lane: an explicit opt-in flag forces it; otherwise it is selected only as a
+ // FALLBACK when no cloud image engine is available (no working OpenAI or Grok key). Hosted runs
+ // with a valid key are untouched.
+ const localFlag = /^(1|true|yes|on)$/i.test(String(process.env.EXPLAINMYREPO_LOCAL_IMAGES || '').trim());
const grokKey = loadGrokKey();
- const grokOK = !!grokKey && await probeGrok(grokKey);
+ const grokOK = !localFlag && !!grokKey && await probeGrok(grokKey);
const apiKey = loadOpenAiKey();
let openaiModel = null;
- if (apiKey) {
+ if (!localFlag && apiKey) {
if (await probeModel(PRIMARY_MODEL, apiKey)) openaiModel = PRIMARY_MODEL;
else if (await probeModel(FALLBACK_MODEL, apiKey)) {
openaiModel = FALLBACK_MODEL;
console.error(`[generate-image] gpt-image-2 probe failed — falling back to ${FALLBACK_MODEL}`);
}
}
- if (!grokOK && !openaiModel) {
- return fail(`image-engine probe failed for Grok AND the whole OpenAI chain (${PRIMARY_MODEL}, ${FALLBACK_MODEL}) — refusing to substitute or fake an image`);
- }
+ // No cloud engine available (missing key or failed probe) → the labelled synthetic engine
+ // carries the build rather than failing it.
+ const useLocal = localFlag || (!grokOK && !openaiModel);
const grokFn = grokOK ? (prompt, px) => generateOneGrok(prompt, px, grokKey) : null;
const openaiFn = openaiModel ? (prompt, px) => generateOne(openaiModel, prompt, px, apiKey) : null;
@@ -323,13 +405,20 @@ async function main() {
// engine produced a problem-section image nobody could decode. Grok stays as the FALLBACK (it is genuinely
// 10-23x faster and a fine safety net), but it is no longer the default for anything a reader sees big.
// Cost of this decision: ~2 extra minutes per build. That is the correct trade and we measured it.
- engines.hero = openaiModel || GROK_MODEL;
- engines.section = openaiModel || GROK_MODEL;
- const heroFn = openaiFn || grokFn;
- const sectionFn = openaiFn || grokFn;
- generateFn = (prompt, px, kind) => (kind === 'hero' ? heroFn : sectionFn)(prompt, px);
+ if (useLocal) {
+ engines.hero = 'local-synthetic';
+ engines.section = 'local-synthetic';
+ generateFn = (prompt, px) => generateOneLocal(prompt, px, palette);
+ console.error(`[generate-image] engines — hero + sections: local-synthetic (palette gradient via sharp; no cloud image key, so cloud engines skipped)`);
+ } else {
+ engines.hero = openaiModel || GROK_MODEL;
+ engines.section = openaiModel || GROK_MODEL;
+ const heroFn = openaiFn || grokFn;
+ const sectionFn = openaiFn || grokFn;
+ generateFn = (prompt, px, kind) => (kind === 'hero' ? heroFn : sectionFn)(prompt, px);
+ console.error(`[generate-image] engines — hero: ${engines.hero} (quality, above the fold) · sections: ${engines.section} (speed)`);
+ }
const engine = `hero:${engines.hero} + sections:${engines.section}`;
- console.error(`[generate-image] engines — hero: ${engines.hero} (quality, above the fold) · sections: ${engines.section} (speed)`);
const assetsDir = path.join(absBuildDir, 'assets');
fs.mkdirSync(assetsDir, { recursive: true });
@@ -438,8 +527,10 @@ async function main() {
usd: Math.round(imageUsd * 1e4) / 1e4,
images: results.length,
perImage,
- basis: 'derived from published per-image rates (the image APIs return no charge)',
- ratesCheckedAt: '2026-08-09',
+ basis: useLocal
+ ? 'local-synthetic render (palette gradient via sharp) — $0, no external image API used'
+ : 'derived from published per-image rates (the image APIs return no charge)',
+ ratesCheckedAt: useLocal ? null : '2026-08-09',
};
fs.writeFileSync(buildJsonPath, JSON.stringify(fresh, null, 2) + '\n');
@@ -447,7 +538,7 @@ async function main() {
const files = results.map((res) => res.value.filePath);
succeed({
engine,
- quality: engine === GROK_MODEL ? 'n/a (Grok has no quality param; see px for the aspect_ratio/resolution tier used)' : QUALITY,
+ quality: useLocal ? 'n/a (local-synthetic palette render — no external image model)' : engine === GROK_MODEL ? 'n/a (Grok has no quality param; see px for the aspect_ratio/resolution tier used)' : QUALITY,
rungs: results.map((res) => ({ id: res.value.rung.id, kind: res.value.rung.kind, px: res.value.rung.px, file: res.value.filePath, http200: true })),
files,
slots: ['visuals.hero', 'visuals.sections'],