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 `` + + `` + + stops.map((s) => ``).join('') + `` + + `` + + rectEls.join('') + circles.join('') + + `` + + `` + + ``; +} +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'],