open-design/skills/open-design-landing/scripts/imagegen.ts
Tom Huang aefba56a3f
feat(skills): open-design-landing rename, kami skills, landing OG (#428)
* feat(skills): open-design-landing rename, kami skills, landing OG

- Rename editorial-collage skills to open-design-landing and -deck; refresh examples and compose script layout
- Add kami-deck and kami-landing skills with HTML examples
- Landing page: og.astro, index wiring, and style tweaks; package.json bump
- Web i18n: German and Russian copy for renamed and new skills
- Daemon test: update skill-asset-rewrite expectations for new paths
- Design systems: README and atelier-zero doc touch-ups
- Cross-skill SKILL.md reference updates

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(landing-page): document version-slot invariant and deprecation timeline

Address P3 review notes on PR #428:
- Note the `data-github-version` wrapper invariant (version string only)
  near the canonical URL block in `app/page.tsx`.
- Expand the `formatVersion` helper comment in `app/pages/index.astro`
  with concrete `release.name` / `tag_name` example shapes for each
  branch of the regex fallback.
- Tighten the `EditorialCollageDeckInputs` deprecation in
  `skills/open-design-landing-deck/schema.ts` to a specific removal
  version (v0.4.0) and add a "Migrating from editorial-collage-deck"
  section to the skill README.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* docs(landing-page, skills): clarify version slot script and rename migrations

- Describe GitHub version slots as driven by the inline enhancement script,
  not React hydration.
- Add editorial-collage → open-design-landing migration notes; fix README
  link copy (Astro static landing app).
- Extend deck README migration table with shared asset path renames.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): alias deprecated editorial-collage skill ids

The PR renames the editorial-collage / editorial-collage-deck skills
to open-design-landing / open-design-landing-deck, but the daemon
persists exact skill_id strings on projects and resolves them via
listSkills().find((s) => s.id === storedId). After the rename, any
project saved against an old id silently composes without the intended
skill prompt because the listing no longer exposes that id.

Add a SKILL_ID_ALIASES map in skills.ts plus a findSkillById() helper
that rewrites deprecated ids to their current canonical form, then
route every server-side lookup (skill detail, example HTML, asset
proxy, system-prompt composer) through it. Cover the alias map, the
resolver, and end-to-end resolution against a temp skills directory
with a regression test.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(kami-deck): route host od:slide messages through local go()

The host bridge classifies kami-deck as class-driven because go() toggles
.slide.active, but the visible slide is moved by deck.style.transform
which the bridge cannot drive. Listen for od:slide messages and dispatch
them through the local go() so toolbar next/prev and initialSlideIndex
restore actually shift the deck.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(kami-deck): sync deck transform with host-driven .active changes

The previous fix added a local od:slide listener but the host bridge in
apps/web/src/runtime/srcdoc.ts also listens for the same message and
calls setActive() (toggles .slide.active) without driving the deck
transform. Both listeners fired, the bridge re-read the just-toggled
active class, and overshot by one — and the bridge's restoreInitialSlide
path could move .active without a message at all, leaving the deck on
the original transform.

Stop the bridge from double-handling by calling stopImmediatePropagation
in the local listener (registered first because the bridge script is
appended to </body>), and add a MutationObserver that pulls the deck
transform onto whichever slide currently carries .active so the bridge's
direct setActive calls (notably the initial-slide restore) move the deck
too.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(i18n): align French content with renamed/new skills

PR #434 (French localization) merged into main with French copy for the
old editorial-collage / editorial-collage-deck skill ids; this branch
renamed those to open-design-landing / open-design-landing-deck and
added kami-deck and kami-landing. Update content.fr.ts to track the
rename and add French copy for the new kami skills so the
LOCALIZED_CONTENT_IDS coverage test passes once main is merged.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(open-design-landing-deck): sync deck transform with host-driven .active changes

Apply the same fix that landed in skills/kami-deck/example.html
(commits 96b255b, 8cbca30) to the open-design-landing-deck composer
runtime: the host bridge classifies this deck as class-driven because
go() toggles .slide.active, but the visible slide is moved by
deck.style.transform which the bridge can't drive. Add an od:slide
message listener that calls stopImmediatePropagation() and routes nav
through the local go(), plus a MutationObserver that pulls the deck
transform onto whichever slide carries .active so the bridge's direct
setActive calls (notably restoreInitialSlide) move the deck too.

Regenerates example.html via scripts/compose.ts; the regeneration also
picks up upstream nav-cta and brand-meta CSS additions in the sister
open-design-landing styles.css that the example had drifted from.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* docs(open-design-landing): align deploy story with Astro landing app

- Update SKILL contract: apps/landing-page is Astro static; clarify
  nextjs-app output_format as a historical enum label and <out>/nextjs
  as a legacy folder name.
- Replace optional-deploy section with fork + pnpm --filter landing-page build.
- Fix styles.css header and regenerate landing + deck example.html so
  inlined comments match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(deck-runtime): bypass interaction lock for host/observer slide sync

The slide deck runtimes for kami-deck and open-design-landing-deck
gate go() behind a 700ms `lock` so wheel/key/touch input bursts can't
overshoot the transform transition. But applying the same gate to the
host bridge's od:slide messages and the MutationObserver watching
`.slide.active` creates a startup race: go(0) at the end of init sets
lock=true, and any host-driven `.active` change inside that window
(notably restoreInitialSlide) fires the observer, which calls go(i),
which exits at the lock guard — leaving the visible deck on slide 1
while the host counter advances to N.

Split the actual state update into an unthrottled `applySlide(n)`
helper that updates transform, `.active`, dot nav, and the progress
bar. Keep `lock` only on the user-input path through `go()`. Route
the message listener, the MutationObserver, and the initial render
through `applySlide` directly so host-driven sync always reaches the
deck transform regardless of the throttle state.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 19:22:46 +08:00

325 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env -S npx -y tsx
/**
* open-design-landing — gpt-image-2 generator (fal.ai backend).
*
* Generates the 16 collage assets defined in `assets/image-manifest.json`
* by composing per-slot prompts (style anchor + brand variables +
* per-slot composition) and calling fal.ai's `openai/gpt-image-2`
* synchronous endpoint. Downloads each result to the `--out` directory.
*
* Requires `FAL_KEY` in the environment. If it is missing, the script
* prints the prompts it would have sent so an operator can route them
* through the `/gpt-image-fal` skill manually, or set the key and re-run.
*
* Usage:
* FAL_KEY=... npx tsx scripts/imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta]
*
* Cost note: 16 images × ~$0.025 each ≈ $0.40 per full run at high
* quality. Re-running is idempotent — slots whose target file already
* exists are skipped unless `--force` is passed.
*/
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { resolve, dirname, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { EditorialCollageInputs } from '../schema';
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
interface ManifestSlot {
id: string;
file: string;
width: number;
height: number;
ratio: string;
prompt_section: string;
required: boolean;
rekey_on_brand_change: boolean;
}
interface Manifest { slots: ManifestSlot[] }
/* ------------------------------------------------------------------ *
* prompt constants (mirror assets/imagegen-prompts.md verbatim)
* ------------------------------------------------------------------ */
const STYLE_ANCHOR = `Use case: ads-marketing
Asset type: editorial website hero / creative studio landing page visual
Primary request: Generate a refined editorial web page composition in the
same visual language as a high-end creative AI research studio.
Style/medium: sophisticated digital collage, modern Swiss editorial layout,
Bauhaus geometric composition, classical plaster sculpture fragments,
brutalist/minimal architecture, art-direction website mockup, premium
agency aesthetic.
Scene/backdrop: warm off-white handmade paper background with subtle
grain, faint vertical folds, scanned paper fibers, lightly aged print
texture, thin drafting lines and registration marks.
Subject: a surreal collage combining a cropped classical plaster head or
face fragment, abstract architectural blocks, archways or stairs, sky
cutouts, one small human figure, a delicate tree or botanical element,
and geometric color planes.
Composition/framing: wide 16:9 web page layout, strong asymmetrical
grid, generous negative space, large typography area on the left or
top-left, collage focal object on the right or center-right, precise
alignment, thin divider lines, small UI navigation details.
Lighting/mood: soft diffused daylight, museum-like calm, intelligent,
restrained, tactile, poetic, premium, research-driven.
Color palette: warm ivory, stone beige, soft concrete gray, deep black
text, muted charcoal, washed coral-red accent, occasional mustard-yellow
accent, pale sky blue only inside small sky/image cutouts.
Materials/textures: matte plaster, limestone, travertine, concrete, rough
torn paper edges, halftone print grain, translucent vellum-like overlays,
fine grid paper, dotted matrix patterns.
Typography: large clean grotesk sans-serif for main headline, elegant
high-contrast italic serif for emphasized words, tiny uppercase coral
labels, compact UI microcopy. Text must be crisp, readable, and spelled
exactly as provided.
Graphic details: thin hairline circles, partial arcs, crosshair marks,
small black dots, dotted grids, fine coordinate lines, numbered
annotations, small arrow buttons, simple pill buttons, minimal logo mark.
Constraints: preserve a high-end editorial web design feel; keep spacing
elegant and uncluttered; no cartoon style; no neon colors; no glossy 3D;
no busy gradients; no generic stock-photo look.
Avoid: distorted typography, misspelled text, extra random words, heavy
shadows, childish illustration, cyberpunk, saturated purple/blue palette,
plastic materials, overly decorative UI cards, cluttered composition,
low-resolution textures, watermarks.`;
const PER_SLOT: Record<string, string> = {
hero: `Composition/framing: left half is intentionally empty/quiet to allow real
HTML headline overlay; right half holds a tall surreal collage of a
cropped classical plaster head with the top sliced open, sky/architecture
cutouts visible inside the head, a delicate young tree growing through
the composition, a coral sun disk behind, a mustard accent ring at the
base, hairline coordinate marks and dotted matrices around it, a small
human figure standing for scale in the lower-left of the image. Page
type: hero landing.`,
about: `Composition: a surreal museum-vitrine arrangement of a partial plaster
profile head facing right, with an open archway carved through the
torso, sky cutout inside the arch, a tree seedling growing out of the
shoulder, and a coral half-circle behind the head. Tiny dotted hairlines
trace contours. Strong negative space top-left for a side-note overlay.
Page type: about / manifesto plate.`,
capabilities: `Composition: a Bauhaus-grid stack of architectural fragments — a coral
arch on the left, a beige concrete column center, a mustard small disc
upper-right, a delicate tree mid-frame, a small classical hand fragment
holding a pencil bottom-center. Crosshair and circular hairlines
overlay. Page type: capabilities matrix.`,
'method-1': `Composition: a magnifying glass over a small architectural map. Coral
accent disc behind. One numbered annotation tag '01 · Detect'.
Page type: method tile.`,
'method-2': `Composition: a clipboard with a tiny questionnaire and a coral pen,
on the warm paper ground. Mustard sticker corner. Annotation '02 ·
Discover'. Page type: method tile.`,
'method-3': `Composition: a compass + ruler + color swatch fan arranged like an
architect's drafting kit. Coral accent on the swatch. Annotation
'03 · Direct'. Page type: method tile.`,
'method-4': `Composition: a printer's tray with stacked paper sheets exiting,
mustard ribbon tag. Annotation '04 · Deliver'. Page type: method tile.`,
'lab-1': `Portrait composition: a stack of folded magazine spreads, slight
perspective, coral spine, mustard tab. Page type: lab card.`,
'lab-2': `Portrait composition: a film strip + a synthetic eye + a soundwave
hairline. Coral arc behind. Page type: lab card.`,
'lab-3': `Portrait composition: a typewriter with prompt cards in the carriage,
coral platen knob. Page type: lab card.`,
'lab-4': `Portrait composition: five small dotted gauges arranged in a circle
(5-dim critique), one filled coral. Page type: lab card.`,
'lab-5': `Portrait composition: a glass dome / cloche over a tiny sandbox
cityscape, mustard sun behind. Page type: lab card.`,
'work-1': `Portrait composition: an oversized open magazine spread on a desk,
coral spine, mustard tab. Slight perspective. Page type: work card.`,
'work-2': `Portrait composition: a concrete dashboard slab, a coral graph bar
rising, a small classical bust beside it for scale. Page type: work card.`,
testimonial: `Composition: a classical plaster bust facing 3/4 left, slightly cropped,
with a small sky cutout where the eye would be, a thin coral arc around
the back of the head, mustard dot at the chin. Quiet background, lots of
negative space upper right. Page type: testimonial portrait.`,
cta: `Composition: a closing-plate collage — a mustard sun behind a single
coral arch on the right, a delicate tree growing through the arch, a
small human figure in the lower-left foreground reading a folded
broadsheet, hairline coordinate ladder up the left edge, and a small
"FIN." dotted seal in the upper-right. Page type: closing CTA plate.`,
};
/* ------------------------------------------------------------------ *
* prompt builder
* ------------------------------------------------------------------ */
function brandVarsBlock(inputs: EditorialCollageInputs): string {
// Pull the brand-shaped strings the model should bias toward.
const navText = inputs.nav.slice(0, 5).map((n) => `"${n.label}"`).join(', ');
const eyebrow = `${inputs.hero.label} ${inputs.hero.ix}`;
const headline = inputs.hero.headline.map((s) => s.text).join('');
const italic = inputs.hero.headline.filter((s) => s.em).map((s) => `"${s.text}"`).join(', ');
const body = inputs.hero.lead.replace(/<[^>]+>/g, '').replace(/&[^;]+;/g, '');
return `Brand/logo text: "${inputs.brand.name}"
Navigation text: ${navText}
Eyebrow label: "${eyebrow}"
Main headline: "${headline}"
Italic emphasis words: ${italic}
Body copy: "${body}"
Primary button: "${inputs.hero.primary.label}"
Secondary button: "${inputs.hero.secondary.label}"
Footer/micro labels: "${inputs.brand.location}", "${inputs.brand.coordinates}"`;
}
export function promptForSlot(slot: ManifestSlot, inputs: EditorialCollageInputs): string {
const override = inputs.imagery.prompts?.[slot.id];
const composition = override ?? PER_SLOT[slot.id] ?? `Page type: ${slot.id} plate.`;
return [STYLE_ANCHOR, brandVarsBlock(inputs), composition].join('\n\n');
}
/* ------------------------------------------------------------------ *
* fal.ai client (raw fetch — no npm dependency)
* ------------------------------------------------------------------ */
interface FalImageResult {
images: Array<{ url: string; width?: number; height?: number; content_type?: string }>;
}
async function callFalGptImage(
prompt: string,
width: number,
height: number,
apiKey: string,
): Promise<Uint8Array> {
// fal.ai exposes both queue (async) and run (sync) endpoints. Use sync
// for simpler scripting; per-image latency is ~25-45s.
const endpoint = 'https://fal.run/openai/gpt-image-2';
const res = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Key ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt,
image_size: { width, height },
num_images: 1,
quality: 'high',
output_format: 'png',
background: 'opaque',
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '<unreadable>');
throw new Error(`fal.run/openai/gpt-image-2 ${res.status}: ${text.slice(0, 400)}`);
}
const json = (await res.json()) as FalImageResult;
const url = json.images?.[0]?.url;
if (!url) throw new Error('fal.ai response missing images[0].url');
const dl = await fetch(url);
if (!dl.ok) throw new Error(`download ${url} failed: ${dl.status}`);
const buf = await dl.arrayBuffer();
return new Uint8Array(buf);
}
/* ------------------------------------------------------------------ *
* top-level
* ------------------------------------------------------------------ */
interface CliArgs {
inputsPath: string;
outDir: string;
only?: Set<string>;
force: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const inputsPath = argv[2];
if (!inputsPath || inputsPath.startsWith('--')) {
throw new Error('Usage: imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta] [--force]');
}
let outDir = './assets/';
let only: Set<string> | undefined;
let force = false;
for (const arg of argv.slice(3)) {
if (arg.startsWith('--out=')) outDir = arg.slice('--out='.length);
else if (arg.startsWith('--only=')) only = new Set(arg.slice('--only='.length).split(','));
else if (arg === '--force') force = true;
else throw new Error(`unknown arg: ${arg}`);
}
return {
inputsPath: isAbsolute(inputsPath) ? inputsPath : resolve(process.cwd(), inputsPath),
outDir: isAbsolute(outDir) ? outDir : resolve(process.cwd(), outDir),
only,
force,
};
}
async function fileExists(path: string): Promise<boolean> {
try {
const s = await stat(path);
return s.isFile() && s.size > 256;
} catch {
return false;
}
}
async function main(): Promise<void> {
const { inputsPath, outDir, only, force } = parseArgs(process.argv);
const apiKey = process.env.FAL_KEY ?? '';
const dryRun = !apiKey;
const inputs = JSON.parse(await readFile(inputsPath, 'utf8')) as EditorialCollageInputs;
const manifestPath = resolve(SKILL_ROOT, 'assets', 'image-manifest.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest;
await mkdir(outDir, { recursive: true });
const targets = manifest.slots.filter((s) => !only || only.has(s.id));
if (dryRun) {
console.log(`FAL_KEY not set — dry run. Printing prompts for ${targets.length} slot(s).\n`);
} else {
console.log(`Generating ${targets.length} slot(s) → ${outDir}`);
}
for (const slot of targets) {
const target = resolve(outDir, slot.file);
if (!force && (await fileExists(target))) {
console.log(`· ${slot.id} — skip (exists)`);
continue;
}
const prompt = promptForSlot(slot, inputs);
if (dryRun) {
console.log(`\n=== ${slot.id} (${slot.width}×${slot.height}) → ${slot.file} ===`);
console.log(prompt);
console.log(`=== end ${slot.id} ===\n`);
continue;
}
process.stdout.write(`· ${slot.id} (${slot.width}×${slot.height}) … `);
try {
const png = await callFalGptImage(prompt, slot.width, slot.height, apiKey);
await writeFile(target, png);
console.log(`ok (${(png.byteLength / 1024).toFixed(0)} KB)`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.log(`fail — ${msg}`);
}
}
if (dryRun) {
console.log(`\nNext: set FAL_KEY in env and re-run to generate, or paste each prompt block into /gpt-image-fal manually.`);
}
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}