import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { generateViaFlow } from './gen-image-flow.js'; /** * Diegetic still-asset generation for in-world UI graphics — props, on-screen * dashboards, motion-graphic overlays — the kind the reference advert used (a * "Settlement Report Dashboard", a "SYSTEM COMPROMISED" alert) inside shots. * vclaw generates character frames and storyboard grids elsewhere; this module * covers ARBITRARY prop / screen / overlay graphics. * * Three interchangeable backends, selected by `backend`: * - `gobananas` (DEFAULT): the same image API `character-auto-create.ts` uses * (`POST ${apiUrl}/images`, no OpenAI key). Reads the returned image URL and * downloads it to disk, honoring the served file's REAL extension. * - `openai`: the OpenAI Images API (`POST /v1/images/generations`, gpt-image-1 / * the "gpt-image-2" family), returning `{ data: [{ b64_json }] }` decoded to disk. * - `flow`: Google Flow via useapi.net (`POST /google-flow/images`, imagen-4 / * nano-banana / nano-banana-pro) with reference_1..10 + character_1..7 slots * and inline @-marker validation — see `gen-image-flow.ts`. * * The fetcher is injectable so request composition, URL/b64 extraction, and the * download/decode are fully testable offline. */ export const GEN_IMAGE_KINDS = ['prop', 'screen', 'overlay'] as const; export type GenImageKind = typeof GEN_IMAGE_KINDS[number]; export const GEN_IMAGE_BACKENDS = ['gobananas', 'openai', 'flow'] as const; export type GenImageBackend = typeof GEN_IMAGE_BACKENDS[number]; /** The proven default backend (no OpenAI key required). */ export const DEFAULT_GEN_IMAGE_BACKEND: GenImageBackend = 'gobananas'; // --- Go Bananas backend constants --- export const DEFAULT_GO_BANANAS_API_URL = 'https://gobananasai.com/api'; export const DEFAULT_GEN_IMAGE_MODEL = 'gemini-pro-image'; // --- OpenAI backend constants --- export const DEFAULT_OPENAI_IMAGE_ENDPOINT = 'https://api.openai.com/v1/images/generations'; /** gpt-image-1 is the live API model id for the gpt-image ("gpt-image-2") family. */ export const DEFAULT_OPENAI_IMAGE_MODEL = 'gpt-image-1'; const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'; /** * Per-kind render directive woven into the prompt so the output is usable as a * composited asset: a screen is a flat capture (no bezel), an overlay sits on a * clean background for keying, a prop is isolated on neutral. */ const KIND_DIRECTIVE: Record = { screen: 'Rendered as a realistic on-screen software UI / dashboard captured flat (no device bezel, no perspective), crisp legible text, high contrast, fills the frame.', overlay: 'Rendered as a centered alert / motion-graphic overlay on a plain solid background for keying, bold high-contrast, no surrounding scene.', prop: 'Rendered as a single standalone prop object, centered on a clean neutral seamless background, even product lighting.', }; /** Default aspect ratio per kind (Go Bananas backend). */ const KIND_ASPECT: Record = { screen: '16:9', overlay: '1:1', prop: '1:1', }; /** Default canvas per kind (OpenAI backend; gpt-image-1 supports 1024x1024 / 1536x1024 / 1024x1536). */ const KIND_SIZE: Record = { screen: '1536x1024', overlay: '1024x1024', prop: '1024x1024', }; /** * Per-kind negative prompt (Go Bananas backend). Screens and overlays are TEXT * graphics (a dashboard, an alert), so they must NOT suppress text; only props do. */ const KIND_NEGATIVE: Record = { screen: 'watermark, logo, blurry, deformed', overlay: 'watermark, logo, blurry, deformed', prop: 'text, watermark, logo, blurry, deformed', }; /** Go Bananas `POST /images` request body. */ export interface GoBananasGenImageRequest { prompt: string; aspect_ratio: string; model_id: string; enhance_prompt: boolean; negative_prompt: string; /** Lock the still to a managed Go Bananas character for identity consistency. */ character_id?: number; /** Render via a Go Bananas style preset (e.g. the multi-view reference sheet). */ style_preset_id?: number; } /** OpenAI `POST /v1/images/generations` request body. */ export interface OpenAiGenImageRequest { model: string; prompt: string; size: string; n: number; } export type GenImageRequest = GoBananasGenImageRequest | OpenAiGenImageRequest; export interface BuildGenImageRequestOptions { prompt: string; kind: GenImageKind; /** Backend to compose for; defaults to `gobananas`. */ backend?: GenImageBackend; /** Go Bananas aspect ratio override (ignored by the OpenAI backend). */ aspectRatio?: string; /** OpenAI canvas size override (ignored by the Go Bananas backend). */ size?: string; /** Model id override for the selected backend. */ model?: string; /** Go Bananas only: lock the still to a managed character id for consistency. */ characterId?: number; /** Go Bananas only: render via a style preset (e.g. the reference-sheet preset). */ stylePresetId?: number; } /** * Weave the per-kind render directive into a prompt (PURE). Shared by every * backend's request composer so the directive treatment never drifts. */ export function composeGenImagePrompt(prompt: string, kind: GenImageKind): string { return `${prompt.trim()}\n\n${KIND_DIRECTIVE[kind]}`; } /** Default aspect ratio for a kind (PURE; shared with the Flow backend). */ export function defaultGenImageAspect(kind: GenImageKind): string { return KIND_ASPECT[kind]; } /** * Compose the image request body for the selected backend (PURE). Weaves the * per-kind render directive into the prompt. For Go Bananas it resolves the * aspect ratio and a text-aware negative prompt; for OpenAI it resolves the * canvas size. Exposed for tests and dry-run inspection. (The Flow backend has * its own composer — `buildFlowImageParams` in `gen-image-flow.ts` — because * its body carries reference/character slots and marker validation.) */ export function buildGenImageRequest(opts: BuildGenImageRequestOptions): GenImageRequest { const backend = opts.backend ?? DEFAULT_GEN_IMAGE_BACKEND; if (backend === 'flow') { throw new Error('gen-image: use buildFlowImageParams (gen-image-flow.ts) to compose flow backend requests.'); } if (backend === 'openai') { return { model: opts.model ?? process.env.VCLAW_OPENAI_IMAGE_MODEL ?? DEFAULT_OPENAI_IMAGE_MODEL, prompt: composeGenImagePrompt(opts.prompt, opts.kind), size: opts.size ?? KIND_SIZE[opts.kind], n: 1, }; } // Go Bananas. The per-kind directive describes a plain diegetic-UI asset // (prop/screen/overlay). When the still is locked to a managed character or a // style preset, the caller's prompt is authoritative — the directive would // fight it (a character portrait is not a "standalone prop on neutral"), so // omit it. --kind still drives the default aspect ratio and negative prompt. const prompt = (opts.characterId !== undefined || opts.stylePresetId !== undefined) ? opts.prompt.trim() : composeGenImagePrompt(opts.prompt, opts.kind); return { prompt, aspect_ratio: opts.aspectRatio ?? KIND_ASPECT[opts.kind], model_id: opts.model ?? process.env.VCLAW_GO_BANANAS_IMAGE_MODEL ?? DEFAULT_GEN_IMAGE_MODEL, enhance_prompt: false, negative_prompt: KIND_NEGATIVE[opts.kind], ...(opts.characterId !== undefined ? { character_id: opts.characterId } : {}), ...(opts.stylePresetId !== undefined ? { style_preset_id: opts.stylePresetId } : {}), }; } /** Extract the image URL from the many shapes the Go Bananas /images endpoint * may return (mirrors character-auto-create's fallback chain). */ export function extractGenImageUrl(payload: unknown): string | undefined { const p = payload as { url?: string; image_url?: string; data?: { url?: string; images?: Array<{ full_url?: string; url?: string }> }; images?: Array<{ full_url?: string; url?: string }>; }; return ( p?.url ?? p?.image_url ?? p?.data?.url ?? p?.data?.images?.[0]?.full_url ?? p?.data?.images?.[0]?.url ?? p?.images?.[0]?.full_url ?? p?.images?.[0]?.url ); } export interface GenerateGenImageOptions { prompt: string; kind: GenImageKind; /** Backend to use; defaults to `gobananas`. */ backend?: GenImageBackend; /** Where the resulting image is written. */ outputPath: string; /** Go Bananas aspect ratio override. */ aspectRatio?: string; /** OpenAI canvas size override. */ size?: string; /** Model id override for the selected backend. */ model?: string; /** Go Bananas only: lock the still to a managed character id for consistency. */ characterId?: number; /** Go Bananas only: render via a style preset (e.g. the reference-sheet preset). */ stylePresetId?: number; /** * API key. Go Bananas falls back to GO_BANANAS_API_KEY; OpenAI falls back to * OPENAI_API_KEY. */ apiKey?: string; /** Go Bananas API base URL; falls back to GO_BANANAS_API_URL then the default. */ apiUrl?: string; /** OpenAI endpoint override; falls back to VCLAW_OPENAI_IMAGE_ENDPOINT then the default. */ endpoint?: string; /** Injectable fetch for tests; defaults to the global fetch. */ fetcher?: typeof fetch; /** Flow backend: local image paths (uploaded first) or mediaGenerationIds for reference_1..10. */ refs?: string[]; /** Flow backend: saved Flow character refs for character_1..7. */ characterRefs?: string[]; /** Flow backend: images per generation (1-4, default 1). */ count?: number; /** Flow backend: seed for reproducible results. */ seed?: number; /** Flow backend: useapi token; falls back to USEAPI_API_TOKEN. */ apiToken?: string; /** Flow backend: useapi account email; falls back to USEAPI_ACCOUNT_EMAIL. */ accountEmail?: string; } export interface GenImageResult { path: string; kind: GenImageKind; backend: GenImageBackend; model: string; /** Present for the Go Bananas and Flow backends. */ aspectRatio?: string; /** Present for the OpenAI backend. */ size?: string; /** Present for the Go Bananas and Flow backends (the downloaded URL). */ imageUrl?: string; sizeBytes: number; } function goBananasAuthHeaders(apiKey: string): Record { return { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': BROWSER_UA, }; } /** * Return `outputPath` with its extension replaced by the image format implied by * `imageUrl` (jpg/jpeg→jpg, png, webp, gif, avif). When the URL carries no * recognizable image extension, `outputPath` is returned unchanged. PURE. */ export function withImageExtension(outputPath: string, imageUrl: string): string { const match = /\.(jpe?g|png|webp|gif|avif)(?:[?#]|$)/i.exec(imageUrl); if (!match) return outputPath; const ext = match[1].toLowerCase() === 'jpeg' ? 'jpg' : match[1].toLowerCase(); return /\.[^./\\]+$/.test(outputPath) ? outputPath.replace(/\.[^./\\]+$/, `.${ext}`) : `${outputPath}.${ext}`; } /** * Generate a diegetic still and write it to disk via the selected backend. * Throws a clear error when the key is missing, a request fails, or no image * data/URL comes back. */ export async function generateGenImage(opts: GenerateGenImageOptions): Promise { const backend = opts.backend ?? DEFAULT_GEN_IMAGE_BACKEND; if (backend === 'flow') { return generateViaFlow({ prompt: opts.prompt, kind: opts.kind, outputPath: opts.outputPath, ...(opts.model !== undefined ? { model: opts.model } : {}), ...(opts.aspectRatio !== undefined ? { aspectRatio: opts.aspectRatio } : {}), ...(opts.count !== undefined ? { count: opts.count } : {}), ...(opts.seed !== undefined ? { seed: opts.seed } : {}), ...(opts.refs !== undefined ? { refs: opts.refs } : {}), ...(opts.characterRefs !== undefined ? { characterRefs: opts.characterRefs } : {}), ...(opts.apiToken !== undefined ? { apiToken: opts.apiToken } : {}), ...(opts.accountEmail !== undefined ? { accountEmail: opts.accountEmail } : {}), ...(opts.fetcher !== undefined ? { fetcher: opts.fetcher } : {}), }); } return backend === 'openai' ? generateViaOpenAi(opts) : generateViaGoBananas(opts); } /** * Go Bananas path: POST `${apiUrl}/images`, read the returned image URL, download * it, and write it under the URL's REAL extension (the API commonly serves a JPEG * even when the caller's default path is `.png`). */ async function generateViaGoBananas(opts: GenerateGenImageOptions): Promise { const apiKey = opts.apiKey ?? process.env.GO_BANANAS_API_KEY; if (!apiKey) { throw new Error('gen-image requires a Go Bananas API key (set GO_BANANAS_API_KEY or pass apiKey).'); } const apiUrl = (opts.apiUrl ?? process.env.GO_BANANAS_API_URL ?? DEFAULT_GO_BANANAS_API_URL).trim(); const fetcher = opts.fetcher ?? fetch; const request = buildGenImageRequest({ ...opts, backend: 'gobananas' }) as GoBananasGenImageRequest; const genResponse = await fetcher(`${apiUrl}/images`, { method: 'POST', headers: goBananasAuthHeaders(apiKey), body: JSON.stringify(request), }); if (!genResponse.ok) { throw new Error(`gen-image POST /images failed with HTTP ${genResponse.status}`); } const imageUrl = extractGenImageUrl(await genResponse.json()); if (!imageUrl) { throw new Error('gen-image: POST /images succeeded but returned no image URL.'); } const imageResponse = await fetcher(imageUrl, {}); if (!imageResponse.ok) { throw new Error(`gen-image image download failed with HTTP ${imageResponse.status}`); } const bytes = Buffer.from(await imageResponse.arrayBuffer()); // Save with the downloaded image's REAL extension — Go Bananas commonly returns // a JPEG even though the caller's default path is `.png`, so honoring the // URL's extension avoids writing JPEG bytes under a .png name. const finalPath = withImageExtension(opts.outputPath, imageUrl); await mkdir(dirname(finalPath), { recursive: true }); await writeFile(finalPath, bytes); return { path: finalPath, kind: opts.kind, backend: 'gobananas', model: request.model_id, aspectRatio: request.aspect_ratio, imageUrl, sizeBytes: bytes.length, }; } /** * OpenAI path: POST `/v1/images/generations` and decode the first `b64_json` * result into the PNG at `outputPath`. */ async function generateViaOpenAi(opts: GenerateGenImageOptions): Promise { const apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error('gen-image requires an OpenAI API key (set OPENAI_API_KEY or pass apiKey).'); } const request = buildGenImageRequest({ ...opts, backend: 'openai' }) as OpenAiGenImageRequest; const endpoint = opts.endpoint ?? process.env.VCLAW_OPENAI_IMAGE_ENDPOINT ?? DEFAULT_OPENAI_IMAGE_ENDPOINT; const fetcher = opts.fetcher ?? fetch; const response = await fetcher(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(request), }); if (!response.ok) { throw new Error(`gen-image request failed with HTTP ${response.status}`); } const payload = (await response.json()) as { data?: Array<{ b64_json?: unknown }> }; const b64 = payload?.data?.[0]?.b64_json; if (typeof b64 !== 'string' || !b64) { throw new Error('gen-image response contained no image data (data[0].b64_json missing).'); } const bytes = Buffer.from(b64, 'base64'); await mkdir(dirname(opts.outputPath), { recursive: true }); await writeFile(opts.outputPath, bytes); return { path: opts.outputPath, kind: opts.kind, backend: 'openai', model: request.model, size: request.size, sizeBytes: bytes.length, }; }