import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { extname } from 'node:path'; import { fetchGeminiWithPool } from './gemini-key-pool.js'; const DEFAULT_GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent'; const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']); const CONTINUITY_PROMPT = `You extract a compact continuity cue from a reference still so the NEXT shot of the same sequence stays visually consistent. Return ONLY a single comma-separated list (no JSON, no markdown, no prose) of the dominant visual style, lighting, primary subject, and setting. Keep it under 25 words.`; function imageMimeType(imagePath: string): string { switch (extname(imagePath).toLowerCase()) { case '.jpg': case '.jpeg': return 'image/jpeg'; case '.webp': return 'image/webp'; case '.png': default: return 'image/png'; } } /** * Resolve a single Gemini API key from the supplied env WITHOUT mutating * process.env or touching the global round-robin pool. Mirrors the pool's * source precedence (`GEMINI_API_KEYS` → `GOOGLE_API_KEYS` → `GOOGLE_API_KEY`) * and returns the first usable key. Undefined when no key is configured. */ function resolveKeyFromEnv(env: NodeJS.ProcessEnv): string | undefined { for (const source of [env.GEMINI_API_KEYS, env.GOOGLE_API_KEYS, env.GOOGLE_API_KEY]) { if (typeof source !== 'string') continue; for (const raw of source.split(/[,;\n\s]+/)) { const key = raw.trim(); if (key) return key; } } return undefined; } /** * Defensive parse of a Gemini text response — mirrors `parseGeminiTextResponse` * in gemini-analyze.ts but NEVER throws: returns undefined when no usable text * is present so callers can fall back gracefully. */ function parseGeminiText(payload: unknown): string | undefined { const candidates = (payload as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }) .candidates; const text = candidates?.[0]?.content?.parts ?.map((part) => part.text ?? '') .join('\n') .trim(); return text ? text : undefined; } /** Collapse a raw cue string into a single compact clause (no newlines). */ function tidyCues(raw: string): string { return raw .replace(/^```[a-z]*\s*/i, '') .replace(/\s*```$/i, '') .replace(/\s*\n+\s*/g, ', ') .replace(/\s{2,}/g, ' ') .replace(/(,\s*)+/g, ', ') .replace(/^[,\s]+|[,\s]+$/g, '') .trim(); } function prependContinuityClause(prompt: string, cues: string): string { const clause = `Continuity: ${cues}.`; const trimmed = prompt.trim(); return trimmed ? `${clause}\n\n${trimmed}` : clause; } export interface AugmentPromptWithContinuityInput { /** The next scene's prompt to augment. Returned unchanged when nothing applies. */ nextPrompt: string; /** * Path to the previous scene's already-rendered keyframe IMAGE. Only used for * the Gemini path, and only when it exists on disk and has an image extension. */ priorReferenceImagePath?: string; /** * Deterministic continuity descriptors (e.g. story-bible cast/setting/prop * lines). Used as the Gemini fallback source and when no image is available. */ priorDescriptors?: string[]; /** Injected fetch (tests). Defaults to global fetch via the key pool. */ fetcher?: typeof fetch; /** Env used to resolve the Gemini key. Defaults to process.env. */ env?: NodeJS.ProcessEnv; } export interface AugmentPromptWithContinuityResult { prompt: string; applied: boolean; source: 'gemini' | 'deterministic' | 'none'; } /** * Opt-in continuity loop: enriches `nextPrompt` with a concise continuity cue * derived from the prior scene's rendered keyframe. * * - Gemini path: when a key is configured AND `priorReferenceImagePath` exists * on disk as an image, one structured Gemini call extracts comma-separated * style/lighting/subject/setting cues and a `Continuity: ` clause is * prepended. source: 'gemini'. * - Deterministic path: otherwise, when `priorDescriptors` are supplied, a * `Continuity: ` clause is prepended. source: 'deterministic'. * - Otherwise the prompt is returned unchanged. source: 'none'. * * NEVER throws on a Gemini failure / network error / parse error: it falls back * to the deterministic path (if descriptors exist) or to 'none'. */ export async function augmentPromptWithContinuity( input: AugmentPromptWithContinuityInput, ): Promise { const env = input.env ?? process.env; const descriptors = (input.priorDescriptors ?? []) .map((value) => value.trim()) .filter(Boolean); const deterministicFallback = (): AugmentPromptWithContinuityResult => { if (descriptors.length === 0) { return { prompt: input.nextPrompt, applied: false, source: 'none' }; } return { prompt: prependContinuityClause(input.nextPrompt, descriptors.join('; ')), applied: true, source: 'deterministic', }; }; const key = resolveKeyFromEnv(env); const imagePath = input.priorReferenceImagePath; const imageUsable = typeof imagePath === 'string' && imagePath.trim() !== '' && IMAGE_EXTS.has(extname(imagePath).toLowerCase()) && existsSync(imagePath); if (!key || !imageUsable) { return deterministicFallback(); } try { const imageBytes = await readFile(imagePath as string); const endpoint = env.VCLAW_GEMINI_API_ENDPOINT ?? DEFAULT_GEMINI_ENDPOINT; const response = await fetchGeminiWithPool( (k) => `${endpoint}${endpoint.includes('?') ? '&' : '?'}key=${encodeURIComponent(k)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Connection': 'close' }, body: JSON.stringify({ contents: [{ parts: [ { inlineData: { mimeType: imageMimeType(imagePath as string), data: imageBytes.toString('base64') } }, { text: CONTINUITY_PROMPT }, ], }], generationConfig: { temperature: 0.2, maxOutputTokens: 120, responseMimeType: 'text/plain', }, }), }, { fetcher: input.fetcher, keyOverride: key, maxAttempts: 1 }, ); if (!response.ok) return deterministicFallback(); const payload = await response.json().catch(() => undefined); const text = parseGeminiText(payload); const cues = text ? tidyCues(text) : ''; if (!cues) return deterministicFallback(); return { prompt: prependContinuityClause(input.nextPrompt, cues), applied: true, source: 'gemini', }; } catch { return deterministicFallback(); } }