import { writeFile, mkdir } from 'node:fs/promises'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { addCharacterProfile, characterIdFromName } from './characters.js'; import { searchCharactersByExactName } from './library-clean.js'; import { ensureProjectWorkspace } from './workspace.js'; import { readReferenceSheetsArtifact, referenceSheetsPathFor, writeReferenceSheetsArtifact, } from './reference-sheet-store.js'; import type { ReferenceSheet } from './types.js'; const DEFAULT_BASE_URL = 'https://gobananasai.com/api'; 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'; /** * Go Bananas style preset that renders the multi-view **Cinematic Character * Reference Sheet** (hero portrait + 6 reference views). Every auto-created * character gets one as its identity reference sheet — the consistency anchor * the rest of the pipeline (director identity gate, Seedance/Flow) depends on. */ export const CINEMATIC_CHARACTER_SHEET_PRESET_ID = 55; /** The 7-panel layout needs a multi-panel-capable model. */ const REFERENCE_SHEET_MODEL = 'openai-gpt-image-2'; export interface CharacterAutoCreateInput { name: string; description: string; style?: string; } /** * Build the canonical identity reference sheet for an auto-created character. * Pure/deterministic. Carries two `identity`-role references — the rendered * sheet image (project-relative path) and the managed Go Bananas character — * so the sheet satisfies the director identity gate and locks identity * downstream. Bindings/createdAt are preserved from `existing` on re-create. */ export function buildCharacterIdentitySheet(args: { name: string; characterId: number; sheetRelPath: string; now: string; existing?: ReferenceSheet; }): ReferenceSheet { const name = args.name.trim(); return { id: `${characterIdFromName(name)}-identity`, type: 'identity', name: `${name} identity sheet`, characterName: name, description: 'Cinematic character reference sheet (Go Bananas style preset 55), generated at character creation.', references: [ { path: args.sheetRelPath, role: 'identity', note: 'Cinematic Character Reference Sheet' }, { gbRef: { kind: 'character', id: args.characterId }, role: 'identity' }, ], bindings: { sceneIndices: args.existing?.bindings.sceneIndices ?? [] }, createdAt: args.existing?.createdAt ?? args.now, updatedAt: args.now, }; } export interface CharacterAutoCreateResult { characterId: number; imageUrl: string; created: boolean; importedToProject: boolean; /** Identity reference-sheet id, when one was generated + registered. */ referenceSheetId?: string; /** Project-relative path to the rendered Cinematic Character Reference Sheet. */ referenceSheetPath?: string; /** True when the identity sheet was merged into the Go Bananas character record. */ gbRefsSynced?: boolean; } function authHeaders(apiKey: string): Record { return { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': BROWSER_UA, }; } export function buildPortraitPrompt(input: CharacterAutoCreateInput): string { const stylePrefix = input.style?.trim() || 'photorealistic cinematic portrait'; return `${stylePrefix} character portrait of ${input.description.trim()}, front-facing, even neutral mid-gray seamless background with no seam line, full-body composition, high detail, consistent character design. No text, no watermarks.`; } function buildBasePrompt(input: CharacterAutoCreateInput): string { return input.style?.trim() ? `${input.description.trim()}. Style: ${input.style.trim()}.` : input.description.trim(); } /** * Prompt for the identity reference sheet. Leads with the character's `style` * (mirroring buildPortraitPrompt) so the sheet matches the portrait's look * rather than the sheet style preset's default — preset 55 ("Cinematic") * otherwise renders a 3D/photoreal sheet even for a flat-cartoon character, * leaving the identity anchor off-style from the portrait it should match. */ export function buildReferenceSheetPrompt(input: CharacterAutoCreateInput): string { const description = input.description.trim(); const style = input.style?.trim(); return style ? `${style}. ${description}.` : description; } /** * Pure builder for the POST /images body that generates the reference sheet. * Carries the style (via buildReferenceSheetPrompt) and, when the on-style * portrait image id is known, passes it as `reference_image_ids` so the sheet * inherits the portrait's exact look instead of the preset's default style. */ export function buildReferenceSheetRequestBody(args: { input: CharacterAutoCreateInput; characterId: number; presetId: number; portraitImageId?: number; }): Record { const body: Record = { prompt: buildReferenceSheetPrompt(args.input), style_preset_id: args.presetId, character_id: args.characterId, model_id: REFERENCE_SHEET_MODEL, aspect_ratio: '16:9', }; if (typeof args.portraitImageId === 'number') { body.reference_image_ids = [args.portraitImageId]; } return body; } async function generatePortraitUrl( input: CharacterAutoCreateInput, apiKey: string, apiUrl: string, ): Promise { const response = await fetch(`${apiUrl}/images`, { method: 'POST', headers: authHeaders(apiKey), body: JSON.stringify({ prompt: buildPortraitPrompt(input), // Go Bananas' Pro (Gemini 3 Pro) model dropped the legacy '1:1' keyword in // favor of 'square' (it now 500s on '1:1'). 'square' preserves the prior // intent — a centered full-body portrait reference. aspect_ratio: 'square', model_id: 'gemini-pro-image', enhance_prompt: false, negative_prompt: 'text, watermark, logo, title, blurry, deformed', }), }); if (!response.ok) { throw new Error(`POST /images failed: ${response.status} ${await response.text().then((text) => text.slice(0, 200))}`); } const payload = await response.json() as { url?: string; image_url?: string; data?: { url?: string; images?: Array<{ full_url?: string; url?: string }> }; images?: Array<{ full_url?: string; url?: string }>; }; const imageUrl = payload.url ?? payload.image_url ?? payload.data?.url ?? payload.data?.images?.[0]?.full_url ?? payload.data?.images?.[0]?.url ?? payload.images?.[0]?.full_url ?? payload.images?.[0]?.url; if (!imageUrl) { throw new Error('POST /images succeeded but returned no image URL'); } return imageUrl; } async function uploadForEditing( imageUrl: string, apiKey: string, apiUrl: string, ): Promise { const response = await fetch(`${apiUrl}/upload-for-editing`, { method: 'POST', headers: authHeaders(apiKey), body: JSON.stringify({ image_url: imageUrl }), }); if (!response.ok) { throw new Error(`POST /upload-for-editing failed: ${response.status} ${await response.text().then((text) => text.slice(0, 200))}`); } const payload = await response.json() as { image_id?: number; imageId?: number; }; const imageId = payload.image_id ?? payload.imageId; if (!imageId) { throw new Error('POST /upload-for-editing succeeded but returned no image_id'); } return imageId; } async function createCharacter( input: CharacterAutoCreateInput, imageId: number, apiKey: string, apiUrl: string, ): Promise { const response = await fetch(`${apiUrl}/characters`, { method: 'POST', headers: authHeaders(apiKey), body: JSON.stringify({ character_name: input.name, base_prompt: buildBasePrompt(input), description: input.description, reference_image_ids: [imageId], }), }); if (!response.ok) { throw new Error(`POST /characters failed: ${response.status} ${await response.text().then((text) => text.slice(0, 200))}`); } const payload = await response.json() as { id?: number; character_id?: number; data?: { id?: number; character_id?: number }; }; const characterId = payload.data?.id ?? payload.data?.character_id ?? payload.id ?? payload.character_id; if (!characterId) { throw new Error('POST /characters succeeded but returned no character id'); } return characterId; } /** The Go Bananas character record's current reference-image ids (GET /characters/). */ async function getCharacterReferenceImageIds( characterId: number, apiKey: string, apiUrl: string, ): Promise { const response = await fetch(`${apiUrl}/characters/${characterId}`, { headers: authHeaders(apiKey) }); if (!response.ok) { throw new Error(`GET /characters/${characterId} failed: ${response.status} ${await response.text().then((t) => t.slice(0, 200))}`); } const payload = await response.json() as { referenceImages?: Array<{ id?: number }>; reference_images?: Array<{ id?: number }>; data?: { referenceImages?: Array<{ id?: number }>; reference_images?: Array<{ id?: number }> }; }; const refs = payload.referenceImages ?? payload.reference_images ?? payload.data?.referenceImages ?? payload.data?.reference_images ?? []; return refs.map((r) => r.id).filter((id): id is number => typeof id === 'number'); } /** * Merge image ids into a Go Bananas character RECORD's reference set (the refs * `generate_with_character` actually uses), so a generated identity sheet is * wired into the character itself — not just registered in the project. The GB * update REPLACES the set, so we GET the current refs and append (dedup, cap at * the documented 24) rather than overwrite — preserving the portrait and making * re-runs idempotent. Returns the merged id list, or null on failure. */ async function syncCharacterReferenceImages( characterId: number, addImageIds: number[], apiKey: string, apiUrl: string, ): Promise { const existing = await getCharacterReferenceImageIds(characterId, apiKey, apiUrl); const merged = [...new Set([...existing, ...addImageIds])].slice(0, 24); // Nothing new to add (idempotent re-run) — skip the write. if (merged.length === existing.length && merged.every((id) => existing.includes(id))) { return merged; } const response = await fetch(`${apiUrl}/characters/${characterId}`, { method: 'PATCH', // Go Bananas updates characters with PATCH; PUT returns 405. headers: authHeaders(apiKey), body: JSON.stringify({ reference_image_ids: merged }), }); if (!response.ok) { throw new Error(`PATCH /characters/${characterId} failed: ${response.status} ${await response.text().then((t) => t.slice(0, 200))}`); } return merged; } function extractImageUrl(payload: { url?: string; image_url?: string; data?: { url?: string; images?: Array<{ full_url?: string; url?: string }> }; images?: Array<{ full_url?: string; url?: string }>; }): string | undefined { return payload.url ?? payload.image_url ?? payload.data?.url ?? payload.data?.images?.[0]?.full_url ?? payload.data?.images?.[0]?.url ?? payload.images?.[0]?.full_url ?? payload.images?.[0]?.url; } /** * Generate the multi-view Cinematic Character Reference Sheet for a created * character, locked to its Go Bananas character id via the sheet style preset. * The 7-panel layout needs the gpt-image model. */ async function generateReferenceSheetUrl( input: CharacterAutoCreateInput, characterId: number, presetId: number, apiKey: string, apiUrl: string, portraitImageId?: number, ): Promise { const response = await fetch(`${apiUrl}/images`, { method: 'POST', headers: authHeaders(apiKey), body: JSON.stringify(buildReferenceSheetRequestBody({ input, characterId, presetId, portraitImageId })), }); if (!response.ok) { throw new Error(`POST /images (reference sheet) failed: ${response.status} ${await response.text().then((t) => t.slice(0, 200))}`); } const url = extractImageUrl(await response.json() as Parameters[0]); if (!url) { throw new Error('reference-sheet generation succeeded but returned no image URL'); } return url; } /** * Generate + download the character's reference sheet, then register it as an * `identity` reference sheet (bridging the character into the reference-sheets * subsystem). Returns the sheet id + project-relative image path. Re-creating a * character refreshes the same sheet id in place (preserving its bindings). */ async function attachIdentityReferenceSheet( root: string, slug: string, input: CharacterAutoCreateInput, characterId: number, presetId: number, apiKey: string, apiUrl: string, portraitImageId?: number, ): Promise<{ sheetId: string; sheetPath: string; sheetImageId?: number; gbRefsSynced?: boolean }> { const url = await generateReferenceSheetUrl(input, characterId, presetId, apiKey, apiUrl, portraitImageId); // Upload the generated sheet so it has a Go Bananas image id usable as a // character reference (the same upload-for-editing step the portrait uses). const sheetImageId = await uploadForEditing(url, apiKey, apiUrl); const download = await fetch(url); if (!download.ok) { throw new Error(`failed to download reference sheet: ${download.status}`); } const bytes = Buffer.from(await download.arrayBuffer()); const referencesDir = dirname(referenceSheetsPathFor(root, slug)); // projects//references const sheetsDir = join(referencesDir, 'sheets'); await mkdir(sheetsDir, { recursive: true }); const fileName = `${characterIdFromName(input.name)}-identity.png`; await writeFile(join(sheetsDir, fileName), bytes); const sheetRelPath = `references/sheets/${fileName}`; const artifact = await readReferenceSheetsArtifact(root, slug); const sheetId = `${characterIdFromName(input.name.trim())}-identity`; const existing = artifact.sheets.find((sheet) => sheet.id === sheetId); const sheet = buildCharacterIdentitySheet({ name: input.name, characterId, sheetRelPath, now: new Date().toISOString(), ...(existing ? { existing } : {}), }); const index = artifact.sheets.findIndex((s) => s.id === sheetId); if (index >= 0) artifact.sheets[index] = sheet; else artifact.sheets.push(sheet); await writeReferenceSheetsArtifact(root, slug, artifact); // Wire the sheet into the Go Bananas character RECORD too (not just the // project), so generate_with_character actually sees it. Best-effort: the // character + project sheet already exist, so a GB-record update failure // must not abort an otherwise-successful create — warn and carry on. let gbRefsSynced = false; try { const merged = await syncCharacterReferenceImages(characterId, [sheetImageId], apiKey, apiUrl); gbRefsSynced = merged !== null; } catch (err) { process.stderr.write( `[character-auto-create] warning: could not sync identity sheet into Go Bananas character ${characterId} record: ${(err as Error).message}\n`, ); } return { sheetId, sheetPath: sheetRelPath, sheetImageId, gbRefsSynced }; } /** * Ensure the project holds an identity reference sheet for this character, * generating one only when it is missing. Idempotent: if the sheet is already * registered AND its image is on disk, it is reused (no repeat Go Bananas * spend). This is what lets the existing-character / re-import path carry a * sheet too — a registered character must never be "just an image" without the * consistency anchor a freshly-created one gets. */ async function ensureIdentitySheet( root: string, slug: string, input: CharacterAutoCreateInput, characterId: number, presetId: number, apiKey: string, apiUrl: string, ): Promise<{ sheetId: string; sheetPath: string; sheetImageId?: number; gbRefsSynced?: boolean }> { const fileName = `${characterIdFromName(input.name.trim())}-identity.png`; const sheetRelPath = `references/sheets/${fileName}`; const sheetAbsPath = join(dirname(referenceSheetsPathFor(root, slug)), 'sheets', fileName); const sheetId = `${characterIdFromName(input.name.trim())}-identity`; const artifact = await readReferenceSheetsArtifact(root, slug); const already = artifact.sheets.find((s) => s.id === sheetId); if (already && existsSync(sheetAbsPath)) { // Sheet already generated + on disk: reuse it (no re-spend). NOTE: this also // skips the Go Bananas record re-sync, so a character whose sheet predates // the record-sync wiring is NOT auto-synced on a no-op rerun — delete the // local sheet to force regeneration (which re-uploads + re-syncs). return { sheetId, sheetPath: sheetRelPath }; } return attachIdentityReferenceSheet(root, slug, input, characterId, presetId, apiKey, apiUrl); } export async function autoCreateCharacters(inputs: CharacterAutoCreateInput[], options?: { projectSlug?: string; root?: string; apiKey?: string; apiUrl?: string; dryRun?: boolean; /** Generate + register each character's identity reference sheet (default true). */ referenceSheet?: boolean; /** Go Bananas style-preset id for the sheet (default the Cinematic Character Reference Sheet). */ sheetPresetId?: number; }): Promise> { const apiKey = options?.apiKey ?? process.env.GO_BANANAS_API_KEY ?? ''; if (!apiKey) { throw new Error('GO_BANANAS_API_KEY is required for character auto-create'); } const apiUrl = (options?.apiUrl ?? process.env.GO_BANANAS_API_URL ?? DEFAULT_BASE_URL).trim(); const workspace = options?.projectSlug ? await ensureProjectWorkspace(options.projectSlug, options.root ?? resolveWorkspaceRootFromEnv()) : null; const results: Record = {}; for (const input of inputs) { const name = input.name.trim(); const description = input.description.trim(); if (!name || !description) { continue; } const existing = (await searchCharactersByExactName(name, apiKey, apiUrl)) .find((character) => String(character.character_name ?? '').trim().toLowerCase() === name.toLowerCase()); if (existing) { const characterId = existing.id; // A re-imported character must also carry an identity reference sheet — not // just the Go Bananas image ref — so it has the same consistency anchor a // freshly-created character gets. ensureIdentitySheet is idempotent, so a // re-run with the sheet already present does not re-spend. `--no-sheet` // (referenceSheet: false) still opts out. let sheet: { sheetId: string; sheetPath: string; sheetImageId?: number; gbRefsSynced?: boolean } | undefined; if (workspace && options?.projectSlug && options.referenceSheet !== false && !options.dryRun) { sheet = await ensureIdentitySheet( options.root ?? resolveWorkspaceRootFromEnv(), options.projectSlug, input, characterId, options?.sheetPresetId ?? CINEMATIC_CHARACTER_SHEET_PRESET_ID, apiKey, apiUrl, ); } if (workspace) { await addCharacterProfile(workspace, { name, goBananasId: characterId, description, referenceAssets: [ `gobananas://character/${characterId}`, ...(sheet ? [sheet.sheetPath] : []), ], ...(input.style ? { notes: [`style=${input.style}`] } : {}), }); } results[name] = { characterId, imageUrl: '', created: false, importedToProject: Boolean(workspace), ...(sheet ? { referenceSheetId: sheet.sheetId, referenceSheetPath: sheet.sheetPath, gbRefsSynced: sheet.gbRefsSynced } : {}), }; continue; } if (options?.dryRun) { results[name] = { characterId: -1, imageUrl: '', created: true, importedToProject: false, }; continue; } const imageUrl = await generatePortraitUrl(input, apiKey, apiUrl); const imageId = await uploadForEditing(imageUrl, apiKey, apiUrl); const characterId = await createCharacter(input, imageId, apiKey, apiUrl); // Every auto-created character gets its Cinematic Character Reference Sheet // registered as an identity reference sheet — the consistency anchor the // pipeline depends on. Default on; `referenceSheet: false` opts out. let sheet: { sheetId: string; sheetPath: string; sheetImageId?: number; gbRefsSynced?: boolean } | undefined; if (workspace && options && options.projectSlug && options.referenceSheet !== false) { sheet = await attachIdentityReferenceSheet( options.root ?? resolveWorkspaceRootFromEnv(), options.projectSlug, input, characterId, options?.sheetPresetId ?? CINEMATIC_CHARACTER_SHEET_PRESET_ID, apiKey, apiUrl, imageId, ); } if (workspace) { await addCharacterProfile(workspace, { name, goBananasId: characterId, description, referenceAssets: [ `gobananas://character/${characterId}`, ...(sheet ? [sheet.sheetPath] : []), ], ...(input.style ? { notes: [`style=${input.style}`] } : {}), }); } results[name] = { characterId, imageUrl, created: true, importedToProject: Boolean(workspace), ...(sheet ? { referenceSheetId: sheet.sheetId, referenceSheetPath: sheet.sheetPath, gbRefsSynced: sheet.gbRefsSynced } : {}), }; } return results; }