- 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>
293 lines
10 KiB
TypeScript
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;
|
|
}
|