mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Catalog: mode-aligned staging surfaces (persuade/operate/read/experience), star ratings on approvals feeding challenger draw weights, family retirements, authoring strategy and territory guide, rework and breadth authoring rounds, composition mining from rejected worlds. Seed: six challengers (two per tier), --reroll chains, --mode staging filter, rating-weighted draws. New-work: Present/visualize/re-roll flow, image-gen requirement, register-neutral vocabulary. Pipeline: per-mode staging prompts with split frames, hero-from-board reference generation, render-safety guards. Labs: ratings UI, unrated filter, mode chips, composition approve-guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
354 lines
20 KiB
JavaScript
354 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Generate design-system translation cards for the concept world catalog.
|
|
*
|
|
* Each card renders one world as the web design system it implies: palette
|
|
* swatches and material chips, a type specimen, core UI components, and one
|
|
* small screen composition. The prompt is built from the concept's authored
|
|
* Palette/material and Type/composition rules, so the card tests exactly the
|
|
* translation the catalog claims.
|
|
*
|
|
* Output: site/public/worlds/cards/<concept-id>.webp (16:9, 1K, WebP) plus
|
|
* manifest.json mapping id -> { hash, generatedAt }. hash is the concept's
|
|
* content hash (same fingerprint reviews pin), so an edited concept marks its
|
|
* card stale and a rerun regenerates only what changed.
|
|
*
|
|
* Two image kinds per concept:
|
|
* board (<id>.webp): the design-system specimen sheet (palette, type,
|
|
* components, phone composition).
|
|
* hero (<id>-hero.webp): a full-frame desktop landing page composed in the
|
|
* world's system; the 16:9 frame IS the viewport, proving the world holds
|
|
* at desktop scale.
|
|
*
|
|
* Usage:
|
|
* node scripts/generate-world-cards.mjs # all stale/missing
|
|
* node scripts/generate-world-cards.mjs --kind hero # board | hero | both
|
|
* node scripts/generate-world-cards.mjs --only <id[,id]> # specific concepts
|
|
* node scripts/generate-world-cards.mjs --limit 5 # first N stale jobs
|
|
* node scripts/generate-world-cards.mjs --force # ignore manifest
|
|
* node scripts/generate-world-cards.mjs --model pro # lite | pro | gpt
|
|
* node scripts/generate-world-cards.mjs --out <dir> # alternate output dir
|
|
*/
|
|
|
|
import { GoogleGenAI } from '@google/genai';
|
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import sharp from 'sharp';
|
|
import { conceptContentHash, readConceptCatalog } from '../skill/scripts/lib/concept-catalog.mjs';
|
|
import { compositionContentHash, readCompositionCatalog } from '../skill/scripts/lib/composition-catalog.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const CARD_DIR = join(ROOT, 'site', 'public', 'worlds', 'cards');
|
|
// gpt (the default) won the human calibration bake-off decisively; lite and
|
|
// pro remain for future comparisons.
|
|
const MODELS = {
|
|
lite: { kind: 'gemini', id: 'models/gemini-3.1-flash-lite-image', imageSize: '1K' },
|
|
pro: { kind: 'gemini', id: 'models/gemini-3-pro-image', imageSize: '2K' },
|
|
gpt: { kind: 'openai', id: 'gpt-image-2', size: '2048x1152', quality: 'high' },
|
|
};
|
|
const DEFAULT_MODEL = 'gpt';
|
|
const CONCURRENCY = 6;
|
|
const MAX_ATTEMPTS = 4;
|
|
|
|
function loadEnv() {
|
|
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_CLOUD_API_KEY) return;
|
|
const envPath = join(ROOT, '.env');
|
|
if (!existsSync(envPath)) return;
|
|
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
|
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
if (match && !process.env[match[1]]) process.env[match[1]] = match[2].replace(/^"|"$/g, '');
|
|
}
|
|
}
|
|
|
|
function boardTitle(concept) {
|
|
return concept.form
|
|
.split(',')[0]
|
|
.replace(/^(a|an|the)\s+/i, '')
|
|
.replace(/\b\w/g, letter => letter.toUpperCase());
|
|
}
|
|
|
|
function buildHeroPrompt(concept) {
|
|
const palette = concept.system[0].replace(/^Palette\/material:\s*/, '');
|
|
const type = concept.system[1].replace(/^Type\/composition:\s*/, '');
|
|
return `A complete desktop landing page filling the entire 16:9 frame edge to edge, as if screenshotted at 1440 pixels wide: a slim top navigation with a wordmark and a few links, a hero section with a large headline, one supporting line, and a primary call-to-action button, and the top of the next content section just visible at the bottom edge. No browser chrome, no device mockup, no specimen-board framing, no caption: the page itself is the entire image.
|
|
|
|
The page is designed wholly inside this visual world: ${concept.form}.
|
|
Palette and materials: ${palette}
|
|
Typography and composition: ${type}
|
|
Atmosphere, never written on the page but carried into it: ${concept.spark}
|
|
Light the page like the world: its hour, light quality, and mood shape the page ground, surfaces, and imagery, not just the accent color. A nocturnal or interior world produces a genuinely dark, atmospherically lit page.
|
|
|
|
Invent a plausible fictional product or brand this world would naturally serve and write short realistic copy for it (plain punctuation, never an em dash): an invented name that reuses no brand, label, designer, or place name from the world description, a headline of at most eight words, one supporting sentence, and button labels. The result must read as a real, current, award-caliber website built from this world's laws: disciplined grid, aligned edges, believable interface details, generous intentional spacing. Not a poster, not a pastiche, a landing page.
|
|
|
|
The world must live in the interface itself, not only in imagery: the navigation, buttons, cards, dividers, and type voices are built from the world's materials, colors, textures, and lettering traditions. A photograph or illustration may appear as content, but a generic clean website wearing a themed hero image is a failure; a stranger shown only the page footer or a single button should still recognize the world.
|
|
|
|
Craft rules, all mandatory: every piece of interface copy is English, even when the world is Japanese, Arabic, or otherwise non-Latin in origin; at most one small non-Latin glyph may appear as a decorative motif, never in the wordmark, navigation, headlines, or body text, and when in doubt use none. Rendered materials must read premium and physically plausible: real metal, wood, paper, and glass with honest light, never plasticky gradient fakes of them. This is a product or editorial website, not a video-game HUD: no health bars, minimaps, or game-menu chrome unless the world itself is a game interface; when the world is screen- or game-born, art-direct it to contemporary award standard, never dated chrome. Vary the composition beyond the world's single most famous motif and refuse AI-cliche renderings of it (Matrix-style glyph rain, red recording dots); use the world's wider grammar.`;
|
|
}
|
|
|
|
function buildPrompt(concept) {
|
|
const palette = concept.system[0].replace(/^Palette\/material:\s*/, '');
|
|
const type = concept.system[1].replace(/^Type\/composition:\s*/, '');
|
|
return `A single flat design-system specimen board, the kind a design studio produces to prove a visual world translates into web and app design. The board is titled "${boardTitle(concept)}" and fills the whole image edge to edge: no desk, wall, binder clips, pins, or drop shadows around it.
|
|
|
|
The visual world being translated: ${concept.form}.
|
|
Mood reference for art direction only: ${concept.spark}
|
|
|
|
Board sections, left to right:
|
|
1. COLOR & MATERIAL column: five to seven labeled palette swatches plus two or three material texture chips, derived strictly from: ${palette}.
|
|
2. TYPOGRAPHY panel: a large display headline specimen and a short body-text block obeying: ${type}.
|
|
3. COMPONENTS panel: a primary button, a secondary button, a text input, one content card, and a small navigation bar, all styled by this exact system, with normal and active states.
|
|
4. One small phone-screen composition in the corner showing the system as a real app or landing screen.
|
|
|
|
The board ground and dividers take their tone from the world's palette. Precise, crisp, flat graphic rendering like a printed specimen sheet; not a photograph or illustration of the world itself, only the interface system it yields. The only text on the board is the title, short section labels, and brief invented specimen words; never transcribe any sentence from these instructions onto the board.`;
|
|
}
|
|
|
|
async function extractImage(interaction) {
|
|
if (interaction?.output_image?.data) return Buffer.from(interaction.output_image.data, 'base64');
|
|
for (const step of interaction?.steps || []) {
|
|
if (step.type !== 'model_output' || !step.content) continue;
|
|
for (const part of step.content) {
|
|
if (part.type === 'image' && part.data) return Buffer.from(part.data, 'base64');
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Sequence-heavy families render two consecutive beats as a split frame; a
|
|
// still image cannot show motion any other honest way.
|
|
const SEQUENCE_FAMILIES = new Set(['narrative-scroll', 'reveal-transform', 'interaction-physics']);
|
|
|
|
const MODE_FRAMES = {
|
|
persuade: 'an extraordinary marketing page at desktop scale',
|
|
operate: 'an extraordinary application workspace at desktop scale, real work visibly in progress',
|
|
read: 'an extraordinary documentation or long-form reading page at desktop scale, structure visibly serving comprehension',
|
|
experience: 'an extraordinary portfolio or showcase surface at desktop scale, the work itself owning the frame',
|
|
};
|
|
|
|
function buildStagingPrompt(composition) {
|
|
const modeFrame = MODE_FRAMES[composition.surface] || MODE_FRAMES.persuade;
|
|
const hierarchy = composition.grammar[0].replace(/^Staging\/hierarchy:\s*/, '');
|
|
const sequence = composition.grammar[1].replace(/^Sequence\/attention:\s*/, '');
|
|
const controls = composition.grammar[2].replace(/^Controls\/state:\s*/, '');
|
|
const capture = SEQUENCE_FAMILIES.has(composition.familyId)
|
|
? 'Render two consecutive beats of the same page as one hard-cut split frame, a thin seam dividing left and right halves like film frames seconds apart. The staging visibly advances between the beats: something mid-reveal on the left has landed on the right, attention has moved, state has changed.'
|
|
: composition.familyId === 'first-viewport'
|
|
? 'Render one cinematic frame of the opening screen at the most dramatic moment of its signature staging.'
|
|
: 'Render one cinematic frame of the page caught mid-sequence, never its resting opening state: elements between states, partial reveals, momentum readable in the composition itself.';
|
|
const copy = composition.surface === 'operate'
|
|
? 'Invent a plausible product with short realistic English copy (plain punctuation, never an em dash): an invented name and real-looking task content, labels, and data, never placeholder text.'
|
|
: 'Invent a plausible product with short realistic English copy (plain punctuation, never an em dash): an invented name, a headline of at most eight words, one supporting line, one committed action.';
|
|
return `${capture} The subject: ${modeFrame}, filling the entire 16:9 frame edge to edge. No browser chrome, no device mockup, no board framing, no caption.
|
|
|
|
The staging: ${composition.form}
|
|
What owns the frame: ${hierarchy}
|
|
The motion to make visible: ${sequence}
|
|
One interaction in play: ${controls}. Show exactly one control or element mid-gesture with its state visibly changing: a cursor in contact, a drag mid-flight, a value mid-update. Everything else holds still.
|
|
|
|
This must read as interactive, dynamic, fully realized web design that raises the bar: monumental scale contrast, fearless negative space, editorial typographic confidence, real art direction discipline, and web technology pushed as far as the staging implies (${composition.webLeverage}). Choose a product domain the staging serves best; when it is technical or game-adjacent, art-direct it to contemporary award standard, never dated game chrome or generic sci-fi mission styling, and never lean on cliche AI-generated motifs like recording indicators. Identity stays quiet so the staging is the star: a monochrome ground, one accent color, one strong typeface family, wielded with award-winning conviction, never as a wireframe. Only the elements this staging requires exist on the page: no toolbars, settings chrome, feature grids, or decorative widgets beyond the one interaction in play.
|
|
|
|
${copy} Never transcribe these instructions onto the image. The viewer should gasp at the design first, then understand the mechanism from what the frame implies.`;
|
|
}
|
|
|
|
function promptFor(concept, kind) {
|
|
if (kind === 'staging') return buildStagingPrompt(concept);
|
|
return kind === 'hero' ? buildHeroPrompt(concept) : buildPrompt(concept);
|
|
}
|
|
|
|
async function generateGemini(ai, model, concept, kind) {
|
|
const interaction = await ai.interactions.create({
|
|
model: model.id,
|
|
input: promptFor(concept, kind),
|
|
generation_config: {
|
|
temperature: 1,
|
|
top_p: 0.95,
|
|
thinking_level: 'low',
|
|
image_config: { image_size: model.imageSize, aspect_ratio: '16:9' },
|
|
},
|
|
response_modalities: ['image', 'text'],
|
|
});
|
|
return extractImage(interaction);
|
|
}
|
|
|
|
// Set by main() so hero generation can pick up the concept's board as a
|
|
// binding style reference (the board/hero pair must read as one system).
|
|
let BOARD_REFERENCE_DIR = null;
|
|
|
|
async function generateOpenAI(model, concept, kind) {
|
|
// Heroes are generated from the specimen board when it exists: the edits
|
|
// endpoint takes the board as reference so both images share one system.
|
|
if (kind === 'hero' && BOARD_REFERENCE_DIR) {
|
|
const boardPath = join(BOARD_REFERENCE_DIR, `${concept.id}.webp`);
|
|
if (existsSync(boardPath)) {
|
|
try {
|
|
const form = new FormData();
|
|
form.append('model', model.id);
|
|
form.append('image[]', new Blob([readFileSync(boardPath)], { type: 'image/webp' }), `${concept.id}.webp`);
|
|
form.append('prompt', `The attached image is this world's design-system specimen board. Treat its palette, materials, type voices, and component grammar as binding reference; the page you render must read as the same system. ${promptFor(concept, kind)}`);
|
|
form.append('size', model.size);
|
|
form.append('quality', model.quality);
|
|
const response = await fetch('https://api.openai.com/v1/images/edits', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
body: form,
|
|
});
|
|
const json = await response.json();
|
|
if (!response.ok) throw new Error(json.error?.message || `HTTP ${response.status}`);
|
|
const b64 = json.data?.[0]?.b64_json;
|
|
if (b64) return Buffer.from(b64, 'base64');
|
|
} catch (error) {
|
|
process.stderr.write(` board-reference hero failed for ${concept.id} (${error.message}); falling back to plain generation\n`);
|
|
}
|
|
}
|
|
}
|
|
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: model.id,
|
|
prompt: promptFor(concept, kind),
|
|
size: model.size,
|
|
quality: model.quality,
|
|
}),
|
|
});
|
|
const json = await response.json();
|
|
if (!response.ok) throw new Error(json.error?.message || `HTTP ${response.status}`);
|
|
const b64 = json.data?.[0]?.b64_json;
|
|
return b64 ? Buffer.from(b64, 'base64') : null;
|
|
}
|
|
|
|
async function generateCard(ai, model, concept, kind) {
|
|
let lastError;
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
const raw = model.kind === 'openai'
|
|
? await generateOpenAI(model, concept, kind)
|
|
: await generateGemini(ai, model, concept, kind);
|
|
if (!raw) throw new Error('no image part in response');
|
|
return await sharp(raw).webp({ quality: 90 }).toBuffer();
|
|
} catch (error) {
|
|
lastError = error;
|
|
const wait = attempt * 8000;
|
|
process.stderr.write(` retry ${concept.id} (${attempt}/${MAX_ATTEMPTS}): ${error.message}\n`);
|
|
await new Promise(resolve => setTimeout(resolve, wait));
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
async function main() {
|
|
loadEnv();
|
|
const args = process.argv.slice(2);
|
|
const modelKey = args.includes('--model') ? args[args.indexOf('--model') + 1] : DEFAULT_MODEL;
|
|
const model = MODELS[modelKey];
|
|
if (!model) throw new Error(`unknown --model ${modelKey}; use ${Object.keys(MODELS).join(', ')}`);
|
|
const outDir = args.includes('--out') ? args[args.indexOf('--out') + 1] : CARD_DIR;
|
|
const manifestPath = join(outDir, 'manifest.json');
|
|
const only = args.includes('--only') ? args[args.indexOf('--only') + 1].split(',').filter(Boolean) : null;
|
|
const limit = args.includes('--limit') ? Number(args[args.indexOf('--limit') + 1]) : Infinity;
|
|
const force = args.includes('--force');
|
|
const kindArg = args.includes('--kind') ? args[args.indexOf('--kind') + 1] : 'both';
|
|
if (!['board', 'hero', 'both', 'staging'].includes(kindArg)) throw new Error(`unknown --kind ${kindArg}; use board, hero, both, staging`);
|
|
const kinds = kindArg === 'both' ? ['board', 'hero'] : [kindArg];
|
|
const isStaging = kindArg === 'staging';
|
|
|
|
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_CLOUD_API_KEY;
|
|
if (model.kind === 'gemini' && !geminiKey) throw new Error('set GEMINI_API_KEY or GOOGLE_CLOUD_API_KEY (repo .env)');
|
|
if (model.kind === 'openai' && !process.env.OPENAI_API_KEY) throw new Error('set OPENAI_API_KEY (repo .env)');
|
|
const ai = model.kind === 'gemini' ? new GoogleGenAI({ apiKey: geminiKey }) : null;
|
|
|
|
// Staging images render composition-catalog entries; boards and heroes
|
|
// render world-catalog concepts. Both share the manifest and output dir.
|
|
const concepts = isStaging
|
|
? readCompositionCatalog(
|
|
join(ROOT, 'skill', 'scripts', 'composition-ingredients.json'),
|
|
join(ROOT, 'skill', 'scripts', 'composition-reviews.json')
|
|
).compositions
|
|
: readConceptCatalog(
|
|
join(ROOT, 'skill', 'scripts', 'concept-ingredients.json'),
|
|
join(ROOT, 'skill', 'scripts', 'concept-reviews.json')
|
|
).concepts;
|
|
const hashOf = isStaging ? compositionContentHash : conceptContentHash;
|
|
mkdirSync(outDir, { recursive: true });
|
|
BOARD_REFERENCE_DIR = outDir;
|
|
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
|
|
const writeManifest = () => writeFileSync(
|
|
manifestPath,
|
|
`${JSON.stringify(Object.fromEntries(Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b))), null, 2)}\n`
|
|
);
|
|
|
|
const fileFor = (concept, kind) => join(outDir, `${concept.id}${kind === 'hero' ? '-hero' : ''}.webp`);
|
|
const stampKey = kind => (kind === 'hero' ? 'heroGeneratedAt' : 'generatedAt');
|
|
const queue = [];
|
|
for (const concept of concepts) {
|
|
if (only && !only.includes(concept.id)) continue;
|
|
const hash = hashOf(concept);
|
|
const row = manifest[concept.id];
|
|
for (const kind of kinds) {
|
|
const needed = force
|
|
|| !existsSync(fileFor(concept, kind))
|
|
|| row?.hash !== hash
|
|
|| !row?.[stampKey(kind)];
|
|
if (needed || only) queue.push({ concept, kind });
|
|
}
|
|
}
|
|
queue.splice(limit);
|
|
|
|
if (only) {
|
|
const found = new Set(queue.map(job => job.concept.id));
|
|
const missing = only.filter(id => !found.has(id));
|
|
if (missing.length) throw new Error(`concept not found: ${missing.join(', ')}`);
|
|
}
|
|
process.stdout.write(`generating ${queue.length} images (${kinds.join('+')}) across ${concepts.length} concepts with ${modelKey} -> ${outDir}\n`);
|
|
|
|
let done = 0;
|
|
let failed = 0;
|
|
const worker = async () => {
|
|
while (queue.length > 0) {
|
|
const { concept, kind } = queue.shift();
|
|
try {
|
|
const webp = await generateCard(ai, model, concept, kind);
|
|
writeFileSync(fileFor(concept, kind), webp);
|
|
const row = manifest[concept.id] || {};
|
|
row.hash = hashOf(concept);
|
|
row.model = modelKey;
|
|
row[stampKey(kind)] = new Date().toISOString();
|
|
manifest[concept.id] = row;
|
|
writeManifest();
|
|
done += 1;
|
|
process.stdout.write(` [${done}] ${concept.id} ${kind} (${Math.round(webp.length / 1024)}KB)\n`);
|
|
} catch (error) {
|
|
failed += 1;
|
|
process.stderr.write(` FAILED ${concept.id} ${kind}: ${error.message}\n`);
|
|
}
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
|
|
// Prune manifest entries for ids that left both catalogs (they share the
|
|
// manifest, so pruning must check the union).
|
|
const worldIds = readConceptCatalog(
|
|
join(ROOT, 'skill', 'scripts', 'concept-ingredients.json'),
|
|
join(ROOT, 'skill', 'scripts', 'concept-reviews.json')
|
|
).concepts.map(concept => concept.id);
|
|
const stagingIds = readCompositionCatalog(
|
|
join(ROOT, 'skill', 'scripts', 'composition-ingredients.json'),
|
|
join(ROOT, 'skill', 'scripts', 'composition-reviews.json')
|
|
).compositions.map(composition => composition.id);
|
|
const liveIds = new Set([...worldIds, ...stagingIds]);
|
|
for (const id of Object.keys(manifest)) {
|
|
if (!liveIds.has(id)) delete manifest[id];
|
|
}
|
|
writeManifest();
|
|
process.stdout.write(`done: ${done} generated, ${failed} failed\n`);
|
|
if (failed > 0) process.exitCode = 1;
|
|
}
|
|
|
|
main().catch(error => {
|
|
process.stderr.write(`${error.message}\n`);
|
|
process.exit(1);
|
|
});
|