Files
story-studio/src/lib/llm-service.ts
Carlos Narro 2c08d47a0f fix: bugs y mejoras de la revisión de código
- guión conductor: al crear capítulo con virtud sugerida se generaba con GPT-4o pero se descartaba;
  ahora se persiste con saveGuionConductor (antes se pagaba y se perdía).
- escaleta GET: la condición de migración nunca se apagaba (planos sin personajes → []), reescribiendo
  toda la BD en cada carga; ahora migra solo si hay personajesEnPlano legacy y devuelve lo migrado.
- ficheros huérfanos: al borrar un personaje se elimina su carpeta de imágenes; savePromptsPlanos y
  saveFondos borran las imágenes de IDs que desaparecen (los reutilizados no se tocan).
- race en "generar todos": imágenes de plano y variaciones de personaje se persisten por fila
  (updatePromptPlano / nuevo updateCharacterAsset) en vez de reescribir todo el set por iteración.
- path traversal: path.basename en los segmentos de URL que forman rutas de fichero (image-service +
  serving routes de vídeo/fondo).
- hardcodes de localhost (Remotion chapter-data + CORS) ahora configurables por env con default local.
- IDs de personaje: backfill/dedup de id en generatePersonajes/improvePersonajes (evita 500 y sobrescritura).
- render: cola en memoria con TTL/purga (evita fuga y jobs zombie tras reinicio).
- selectVideoVariation valida pertenencia al plano/capítulo; createProject rechaza slug vacío;
  las migraciones loguean el error en vez de tragarlo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 18:15:22 +02:00

293 lines
10 KiB
TypeScript

import OpenAI from 'openai';
import type { IdeaMotriz, Personaje, GuionConductor, Escaleta, PlanoEscaleta, PreEscaleta, CharacterAsset } from '@/types/project';
import {
getIdeaMotrizGeneratePrompt,
getIdeaMotrizRefinePrompt,
getPersonajesGeneratePrompt,
getPersonajeRefinePrompt,
getPersonajesImprovePrompt,
getNewPersonajePrompt,
getGuionConductorPrompt,
getRegenerateActosPrompt,
getEscaletaSegmentoPrompt,
getImagePromptsPrompt,
getCharacterAssetPromptsPrompt,
TPOSE_VARIANTS,
EMOTION_VARIANTS,
} from '@/lib/prompts';
let openaiClient: OpenAI | null = null;
function getOpenAI(): OpenAI {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY no está configurada. Crea un archivo .env.local con tu API key.');
}
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
}
return openaiClient;
}
async function callLLM(systemPrompt: string, userMessage?: string, options?: { temperature?: number; maxTokens?: number }): Promise<string> {
const messages: { role: 'system' | 'user'; content: string }[] = [
{ role: 'system', content: systemPrompt },
];
if (userMessage) {
messages.push({ role: 'user', content: userMessage });
}
const response = await getOpenAI().chat.completions.create({
model: 'gpt-4o',
messages,
response_format: { type: 'json_object' },
temperature: options?.temperature ?? 0.8,
...(options?.maxTokens ? { max_tokens: options.maxTokens } : {}),
});
const content = response.choices[0]?.message?.content;
if (!content) throw new Error('No se generó respuesta');
return content;
}
// =====================
// IDEA MOTRIZ
// =====================
export async function generateIdeaMotriz(userInput: string): Promise<IdeaMotriz> {
const content = await callLLM(getIdeaMotrizGeneratePrompt(), userInput);
return JSON.parse(content) as IdeaMotriz;
}
export async function refineIdeaMotriz(current: IdeaMotriz, feedback: string): Promise<IdeaMotriz> {
const content = await callLLM(getIdeaMotrizRefinePrompt(current), feedback, { temperature: 0.7 });
return JSON.parse(content) as IdeaMotriz;
}
// =====================
// PERSONAJES
// =====================
/**
* Garantiza que cada personaje tenga un id único. El LLM a veces omite el id (→ crash al
* bindear undefined en SQLite) o lo repite (→ upsert que sobrescribe uno con otro).
*/
function ensurePersonajeIds(personajes: Personaje[]): Personaje[] {
const seen = new Set<string>();
return (personajes || []).map(p => {
let id = p.id;
if (!id || seen.has(id)) id = crypto.randomUUID();
seen.add(id);
return { ...p, id };
});
}
export async function generatePersonajes(ideaMotriz: IdeaMotriz): Promise<Personaje[]> {
const content = await callLLM(getPersonajesGeneratePrompt(ideaMotriz));
const result = JSON.parse(content);
return ensurePersonajeIds(result.personajes as Personaje[]);
}
export async function refinePersonaje(personaje: Personaje, feedback: string): Promise<Personaje> {
const content = await callLLM(getPersonajeRefinePrompt(personaje), feedback, { temperature: 0.7 });
return JSON.parse(content) as Personaje;
}
export async function improvePersonajes(personajes: Personaje[], feedback: string): Promise<Personaje[]> {
const content = await callLLM(getPersonajesImprovePrompt(personajes), feedback, { temperature: 0.7 });
const result = JSON.parse(content);
return ensurePersonajeIds(result.personajes as Personaje[]);
}
export async function generateNewPersonaje(
datos: { nombre: string; tipo: string; descripcion: string },
ideaMotriz: IdeaMotriz,
personajesExistentes: Personaje[]
): Promise<Personaje> {
const content = await callLLM(getNewPersonajePrompt(datos, ideaMotriz, personajesExistentes));
const personaje = JSON.parse(content) as Personaje;
personaje.id = personaje.id || crypto.randomUUID();
return personaje;
}
// =====================
// GUIÓN CONDUCTOR
// =====================
export async function generateGuionConductor(
ideaMotriz: IdeaMotriz,
personajes: Personaje[],
capituloNumero: number,
virtudSugerida?: string
): Promise<GuionConductor> {
const content = await callLLM(getGuionConductorPrompt(ideaMotriz, personajes, capituloNumero, virtudSugerida));
return JSON.parse(content) as GuionConductor;
}
export async function regenerateGuionActos(
guionMetadata: {
titulo: string;
duracionTotal: string;
virtudGanada: string;
nudoDelMundo: string;
aliado: { nombre: string; descripcion: string };
gemaGanada: string;
},
ideaMotriz: IdeaMotriz,
personajes: Personaje[],
capituloNumero: number
): Promise<{ actos: GuionConductor['actos'] }> {
const content = await callLLM(getRegenerateActosPrompt(guionMetadata, ideaMotriz, personajes, capituloNumero));
return JSON.parse(content) as { actos: GuionConductor['actos'] };
}
// =====================
// ESCALETA
// =====================
export async function generateEscaleta(
guion: GuionConductor,
ideaMotriz: IdeaMotriz,
personajes: Personaje[],
preEscaleta: PreEscaleta,
onProgress?: (current: number, total: number, label: string) => void
): Promise<Escaleta> {
const allPlanos: PlanoEscaleta[] = [];
let planoCounter = 1;
const MAX_PLANOS_PER_CALL = 25;
const totalSegments = preEscaleta.segmentos.length;
// Use pre-escaleta segments as the source of truth for plano counts
for (let segIdx = 0; segIdx < preEscaleta.segmentos.length; segIdx++) {
const preSeg = preEscaleta.segmentos[segIdx];
onProgress?.(segIdx, totalSegments, `Segmento ${preSeg.segmentoId} "${preSeg.nombre}"`);
// Find matching segment in guion actos
const acto = guion.actos?.find(a => a.numero === preSeg.actoNumero);
const segmento = acto?.segmentos[preSeg.segmentoIndex];
if (!segmento) continue;
const targetPlanos = preSeg.totalPlanos;
const duracionSeg = preSeg.duracionSegundos;
if (targetPlanos <= MAX_PLANOS_PER_CALL) {
const prompt = getEscaletaSegmentoPrompt(
segmento, preSeg.actoNumero, preSeg.segmentoIndex, guion, ideaMotriz, personajes,
targetPlanos, duracionSeg
);
const content = await callLLM(prompt, undefined, { temperature: 0.7, maxTokens: 16000 });
const result = JSON.parse(content);
const planos = (result.planos || []).map((p: any, i: number) => ({
...p,
id: `plano-${String(planoCounter + i).padStart(3, '0')}`,
tiempoSegundos: p.tiempoSegundos || 5,
}));
planoCounter += planos.length;
allPlanos.push(...planos);
} else {
// Split into sub-batches for long segments
const totalBatches = Math.ceil(targetPlanos / MAX_PLANOS_PER_CALL);
let remaining = targetPlanos;
for (let batch = 0; batch < totalBatches; batch++) {
const planosInBatch = Math.min(MAX_PLANOS_PER_CALL, remaining);
const startPlano = targetPlanos - remaining + 1;
const prompt = getEscaletaSegmentoPrompt(
segmento, preSeg.actoNumero, preSeg.segmentoIndex, guion, ideaMotriz, personajes,
targetPlanos, duracionSeg,
{ batchIndex: batch, totalBatches, planosInThisBatch: planosInBatch, startPlano }
);
const content = await callLLM(prompt, undefined, { temperature: 0.7, maxTokens: 16000 });
const result = JSON.parse(content);
const planos = (result.planos || []).map((p: any, i: number) => ({
...p,
id: `plano-${String(planoCounter + i).padStart(3, '0')}`,
tiempoSegundos: p.tiempoSegundos || 5,
}));
planoCounter += planos.length;
remaining -= planos.length;
allPlanos.push(...planos);
}
}
}
return {
capitulo: guion.titulo,
virtudGanada: guion.virtudGanada,
duracionTotalSegundos: allPlanos.reduce((sum, p) => sum + (p.tiempoSegundos || 0), 0),
planos: allPlanos,
} as Escaleta;
}
// =====================
// PROMPTS PARA IMÁGENES
// =====================
export async function generateImagePrompts(
escaleta: Escaleta,
personajes: Personaje[],
onProgress?: (current: number, total: number, label: string) => void
): Promise<PlanoEscaleta[]> {
const systemPrompt = getImagePromptsPrompt(personajes);
const batchSize = 20;
const allPlanos: PlanoEscaleta[] = [];
const planos = escaleta.planos || [];
const totalBatches = Math.ceil(planos.length / batchSize);
for (let i = 0; i < planos.length; i += batchSize) {
const batchNum = Math.floor(i / batchSize);
onProgress?.(batchNum, totalBatches, `Lote ${batchNum + 1}/${totalBatches} (planos ${i + 1}-${Math.min(i + batchSize, planos.length)})`);
const batch = planos.slice(i, i + batchSize);
const content = await callLLM(
systemPrompt,
`Genera prompts para estos planos:\n${JSON.stringify(batch, null, 2)}`,
{ temperature: 0.7 }
);
const result = JSON.parse(content);
// Merge LLM output with original plano data to preserve personajesDetalle etc.
const merged = (result.planos || []).map((llmPlano: any, idx: number) => {
const original = batch[idx];
return { ...original, ...llmPlano, personajesDetalle: original?.personajesDetalle || llmPlano.personajesDetalle };
});
allPlanos.push(...merged);
}
return allPlanos;
}
// =====================
// CHARACTER ASSET PROMPTS (Nano Banana Pro)
// =====================
export async function generateCharacterAssetPrompts(
personaje: Personaje
): Promise<CharacterAsset[]> {
const systemPrompt = getCharacterAssetPromptsPrompt(personaje);
const content = await callLLM(
systemPrompt,
`Genera el character sheet completo para ${personaje.nombre} (${personaje.especie}). Incluye 3 T-poses y 6 emociones.`,
{ temperature: 0.7 }
);
const result = JSON.parse(content);
const assets: CharacterAsset[] = (result.assets || []).map((a: any, i: number) => {
const variantInfo = a.type === 'tpose'
? TPOSE_VARIANTS.find(v => v.variant === a.variant)
: EMOTION_VARIANTS.find(v => v.variant === a.variant);
return {
id: `${personaje.id}-${a.type}-${a.variant}`,
type: a.type,
variant: a.variant,
label: variantInfo?.label || a.variant,
prompt: a.prompt,
status: 'prompt_ready' as const,
};
});
return assets;
}