open-design/skills/editorial-collage/scripts/imagegen.ts
Tom Huang 6c2a8ba09f
feat(editorial-collage): introduce Atelier Zero style landing page as… (#366)
* feat(editorial-collage): introduce Atelier Zero style landing page assets and documentation

- Added new design system for Atelier Zero, including a detailed `DESIGN.md` file.
- Created an `editorial-collage` skill with associated assets for a magazine-grade landing page.
- Included example HTML and image assets for various sections (hero, about, capabilities, etc.).
- Updated README files to guide usage and customization of the new skill and design system.
- Introduced a new image generation prompt pack for consistent visual style across the landing page.

* fix(i18n): cover atelier-zero design system and editorial-collage skill in German content

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

* fix(editorial-collage): align manifest with shipped assets and address PR review

- Update image-manifest.json widths/heights/ratios to match the actual PNGs
  on disk: hero/about/cap/testimonial/cta = 1024x1024 (1:1), method-1..4 =
  816x816 (1:1), lab-1..5 and work-1..2 = 768x1024 (3:4). Mirror the new
  dimensions in imagegen-prompts.md headings and in README.md.
- Mark testimonial.png as rekey_on_brand_change so the manifest agrees
  with SKILL.md's "regenerate at minimum testimonial.png" guidance, and
  add work-1/work-2 to the rekey list in SKILL.md and README.md.
- Add a Hero (I.) sec-rule and renumber every following section II..VIII
  in example.html so the eight sections walk sequentially I -> VIII and
  the page-of-008 counter starts at 001.
- Delete editorial-artifact-system/ (16 duplicate PNGs + index.html +
  skills.md draft) — the canonical version is skills/editorial-collage/
  and the duplicate had no consumer references.
- DESIGN.md: spell out which dimensions of each magazine reference
  (Monocle/Apartamento/IDEA), document the rationale for single-accent
  vs multi-accent, and extend the anti-pattern list with AI-image-gen
  artifacts the system explicitly rejects.
- SKILL.md: add italic_words validation guidance (trim, cap at 4,
  verb->noun rewrite, punctuation strip) and replace the broken-image
  fallback with an inline SVG placeholder sized to the slot's
  manifest aspect ratio.

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

* fix(daemon): serve skill example assets via stable API route

Skill example HTML such as `skills/editorial-collage/example.html`
references shipped images via `./assets/*.png`. The web app loads the
example into a sandboxed iframe via `srcdoc`, where relative URLs
resolve against `about:srcdoc` and the PNGs render as broken images in
the Examples preview.

Add a `GET /api/skills/:id/assets/*` route that serves files under the
skill's `assets/` directory with path-traversal guards, and rewrite
`src='./assets/<file>'` / `href='./assets/<file>'` in the example
response to point at that route. The disk preview keeps working
because the on-disk files are unchanged.

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

* feat(landing-page): add new static Next.js 16 site for Open Design marketing

- Introduced a new landing page application using Next.js 16, featuring a static export setup.
- Added essential files including `package.json`, `next.config.ts`, and TypeScript configuration.
- Implemented global styles in `globals.css` to match the Atelier Zero design system.
- Created a detailed `AGENTS.md` for module-level boundaries and purpose.
- Included various image assets for the landing page, ensuring a visually cohesive experience.
- Established a root layout and main page structure to support the marketing content.

* style(landing-page): enhance topbar layout and improve responsiveness

- Added nowrap styling to topbar elements to prevent text overflow.
- Introduced media query to hide mid text in the topbar for screen widths between 1200px and 1280px.
- Updated layout.tsx to suppress hydration warnings for better rendering consistency.
- Removed redundant "Compiled by Open Design" text from the page component.

* feat(landing-page): implement scroll-reveal animations for enhanced user experience

- Added a new `RevealRoot` component to manage scroll-triggered reveal animations.
- Updated `globals.css` with styles for elements using the `data-reveal` attribute, including opacity, translation, and scaling effects.
- Modified `layout.tsx` to include the `RevealRoot` component for managing animations.
- Enhanced `page.tsx` by adding `data-reveal` attributes to various elements for staggered reveal effects.
- Implemented reduced motion support to ensure accessibility for users with motion sensitivity.

* fix(landing-page): update import paths and enhance link styles

- Changed the import path in `next-env.d.ts` to reference the correct routes type definition.
- Enhanced `globals.css` with new styles for topbar links, work cards, and partner elements, improving hover effects and transitions.
- Updated `page.tsx` to include canonical project URLs and made various links point to these URLs for better navigation and accessibility.

* feat(landing-page): implement headroom-style sticky header with live GitHub star count

- Introduced a new `Header` component to manage sticky navigation behavior on scroll, enhancing user experience.
- Updated `globals.css` to style the sticky header, including transitions and visibility toggling based on scroll direction.
- Modified `page.tsx` to replace the static header with the new `Header` component, which fetches and displays the live GitHub star count.
- Ensured accessibility by providing a fallback for users who prefer reduced motion.

* feat(landing-page): enhance editorial landing page with global ticker and new styles

- Updated `next-env.d.ts` to reference the correct routes type definition for development.
- Enhanced `globals.css` with new styles for the global ticker, including responsive design and improved overflow handling.
- Introduced a new `WIRE_CITIES` and `WIRE_CONTRIBS` data structure in `page.tsx` to display a counter-scrolling marquee of cities and contributors.
- Added a ghost button style for the navigation call-to-action in the header.
- Updated various sections in `page.tsx` to integrate the new ticker and improve overall layout and accessibility.

* refactor(landing-page): update paper texture overlay and remove multica-ai link

- Enhanced comments in `globals.css` to clarify the purpose and behavior of the paper texture overlay.
- Adjusted z-index of the overlay to ensure proper layering with other elements.
- Removed the `multica-ai` partner link from `page.tsx` to streamline the partner section.

* feat(landing-page): implement dynamic contributor marquee with GitHub integration

- Added a new `Wire` component to display a counter-scrolling marquee of cities and contributors.
- The contributor list is fetched live from the GitHub API, ensuring up-to-date information.
- Updated `page.tsx` to integrate the `Wire` component, replacing the static contributor list with dynamic content.
- Enhanced comments for clarity regarding the functionality and purpose of the global wire.

* fix(i18n): add German display copy for editorial-collage-deck skill

The Validate workspace test asserts that GERMAN_CONTENT_IDS.skills covers
every curated skill on disk; the new editorial-collage-deck skill was
missing from DE_SKILL_COPY, causing src/i18n/content.test.ts to fail.

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

* feat(landing-page): migrate marketing site to Astro

* perf(landing-page): remove React client runtime

* perf(landing-page): serve images from Cloudflare resizing

* fix(pr): address landing page review feedback

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-04 13:39:58 +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
/**
* editorial-collage — 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);
});
}