mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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>
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
#!/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);
|
||
});
|
||
}
|