init commit

This commit is contained in:
Carlos
2026-02-21 10:33:18 +01:00
parent c863a943ed
commit 9d955bf338
9512 changed files with 2015317 additions and 1305 deletions

View File

@@ -1,21 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { getIdeaMotriz, getPersonajes } from '@/lib/file-service';
import type { Personaje, PersonajeEnPlano, PlanoEscaleta, PreEscaleta, EmotionVariant } from '@/types/project';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
import { getIdeaMotriz, getPersonajes, getEscaleta, saveEscaleta, getPreEscaleta } from '@/lib/db-service';
import type { Personaje, PersonajeEnPlano, PlanoEscaleta, EmotionVariant, Escaleta } from '@/types/project';
const VALID_EMOTIONS: EmotionVariant[] = ['alegria', 'tristeza', 'sorpresa', 'miedo', 'enfado', 'neutral'];
async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
return dirs.find(d =>
d.isDirectory() && d.name.startsWith(numero.padStart(2, '0'))
);
}
/** Validate and fix personajesDetalle IDs against real personajes list */
function normalizePlanos(planos: any[], personajes: Personaje[]): PlanoEscaleta[] {
const byId = new Map(personajes.map(p => [p.id, p]));
@@ -26,7 +14,6 @@ function normalizePlanos(planos: any[], personajes: Personaje[]): PlanoEscaleta[
if (Array.isArray(plano.personajesDetalle)) {
detalle = plano.personajesDetalle.map((pd: any) => {
// Try to resolve the personaje by ID first, then by name
let persona = byId.get(pd.personajeId) || byName.get((pd.nombre || '').toLowerCase());
if (!persona) return null;
const emocion = VALID_EMOTIONS.includes(pd.emocion) ? pd.emocion : 'neutral';
@@ -34,12 +21,7 @@ function normalizePlanos(planos: any[], personajes: Personaje[]): PlanoEscaleta[
}).filter(Boolean) as PersonajeEnPlano[];
}
// Backfill personajesEnPlano from personajesDetalle
const personajesEnPlano = detalle.length > 0
? detalle.map(d => d.nombre)
: plano.personajesEnPlano || [];
return { ...plano, personajesDetalle: detalle, personajesEnPlano };
return { ...plano, personajesDetalle: detalle };
});
}
@@ -49,7 +31,7 @@ function migrateLegacyPlanos(planos: any[], personajes: Personaje[]): PlanoEscal
return planos.map((plano) => {
if (Array.isArray(plano.personajesDetalle) && plano.personajesDetalle.length > 0) {
return plano; // already has structured data
return plano;
}
if (!Array.isArray(plano.personajesEnPlano) || plano.personajesEnPlano.length === 0) {
return { ...plano, personajesDetalle: [] };
@@ -71,38 +53,21 @@ export async function GET(
) {
try {
const { slug, numero } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
const escaleta = getEscaleta(slug, parseInt(numero));
if (!escaleta) {
return NextResponse.json(null);
}
const capPath = path.join(capitulosDir, capDir.name);
const escaletaPath = path.join(capPath, 'escaleta.md');
const planosPath = path.join(capPath, 'escaleta-planos.json');
try {
const fileContent = await fs.readFile(escaletaPath, 'utf-8');
const { data, content } = matter(fileContent);
let planos: any[] = [];
try {
const planosJson = await fs.readFile(planosPath, 'utf-8');
planos = JSON.parse(planosJson);
} catch { /* no planos file yet */ }
// Migrate legacy planos: add personajesDetalle from personajesEnPlano if missing
if (planos.length > 0 && planos.some((p: any) => !p.personajesDetalle || p.personajesDetalle.length === 0)) {
const personajes = await getPersonajes(slug);
planos = migrateLegacyPlanos(planos, personajes);
// Persist migration so it only runs once
await fs.writeFile(planosPath, JSON.stringify(planos, null, 2));
}
return NextResponse.json({ ...data, contenidoMarkdown: content, planos });
} catch {
return NextResponse.json(null);
// Migrate legacy planos if needed
let planos = escaleta.planos || [];
if (planos.length > 0 && planos.some((p: any) => !p.personajesDetalle || p.personajesDetalle.length === 0)) {
const personajes = getPersonajes(slug);
planos = migrateLegacyPlanos(planos, personajes);
// Persist migration
saveEscaleta(slug, parseInt(numero), { ...escaleta, planos });
}
return NextResponse.json(escaleta);
} catch (error: any) {
console.error('Error fetching escaleta:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -115,76 +80,58 @@ export async function POST(
) {
try {
const { slug, numero } = await params;
const num = parseInt(numero);
const { action, guion, contenidoMarkdown } = await request.json();
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const capPath = path.join(capitulosDir, capDir.name);
const escaletaPath = path.join(capPath, 'escaleta.md');
const planosPath = path.join(capPath, 'escaleta-planos.json');
if (action === 'generate') {
if (!guion) {
return NextResponse.json({ error: 'Se necesita el guión conductor para generar la escaleta' }, { status: 400 });
}
const ideaMotriz = await getIdeaMotriz(slug);
const ideaMotriz = getIdeaMotriz(slug);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const personajes = await getPersonajes(slug);
// Load pre-escaleta (required — defines exact plano counts per segment)
let preEscaleta: PreEscaleta | null = null;
try {
const preEscaletaJson = await fs.readFile(path.join(capPath, 'pre-escaleta.json'), 'utf-8');
preEscaleta = JSON.parse(preEscaletaJson);
} catch { /* no pre-escaleta */ }
const personajes = getPersonajes(slug);
const preEscaleta = getPreEscaleta(slug, num);
if (!preEscaleta) {
return NextResponse.json({ error: 'Primero genera la pre-escaleta para definir la estructura de planos.' }, { status: 400 });
}
const { generateEscaleta } = await import('@/lib/llm-service');
const escaleta = await generateEscaleta(guion, ideaMotriz, personajes, preEscaleta);
const escaletaResult = await generateEscaleta(guion, ideaMotriz, personajes, preEscaleta);
const { contenidoMarkdown: _ignore, planos: rawPlanos, ...frontmatterData } = escaleta as any;
// Normalize personajesDetalle: validate IDs and backfill personajesEnPlano
const { contenidoMarkdown: _ignore, planos: rawPlanos, ...metaData } = escaletaResult as any;
const planos = normalizePlanos(rawPlanos || [], personajes);
const mdContent = generateEscaletaMarkdown({ ...frontmatterData, planos });
const fileContent = matter.stringify(mdContent, frontmatterData);
await fs.writeFile(escaletaPath, fileContent);
// Save planos as structured JSON for downstream use (prompts generation)
await fs.writeFile(planosPath, JSON.stringify(planos, null, 2));
const mdContent = generateEscaletaMarkdown({ ...metaData, planos });
return NextResponse.json({ ...frontmatterData, contenidoMarkdown: mdContent, planos });
const escaletaToSave: Escaleta = {
capitulo: metaData.capitulo || '',
virtudGanada: metaData.virtudGanada || '',
duracionTotalSegundos: metaData.duracionTotalSegundos,
planos,
contenidoMarkdown: mdContent,
};
saveEscaleta(slug, num, escaletaToSave);
return NextResponse.json(escaletaToSave);
}
if (action === 'generate-stream') {
if (!guion) {
return NextResponse.json({ error: 'Se necesita el guión conductor para generar la escaleta' }, { status: 400 });
}
const ideaMotriz = await getIdeaMotriz(slug);
const ideaMotriz = getIdeaMotriz(slug);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const personajes = await getPersonajes(slug);
const personajes = getPersonajes(slug);
let preEscaletaData: import('@/types/project').PreEscaleta | null = null;
try {
const preEscaletaJson = await fs.readFile(path.join(capPath, 'pre-escaleta.json'), 'utf-8');
preEscaletaData = JSON.parse(preEscaletaJson);
} catch { /* no pre-escaleta */ }
if (!preEscaletaData) {
const preEscaleta = getPreEscaleta(slug, num);
if (!preEscaleta) {
return NextResponse.json({ error: 'Primero genera la pre-escaleta para definir la estructura de planos.' }, { status: 400 });
}
const preEscaletaFinal = preEscaletaData;
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
@@ -196,20 +143,26 @@ export async function POST(
try {
const { generateEscaleta } = await import('@/lib/llm-service');
const escaletaResult = await generateEscaleta(guion, ideaMotriz, personajes, preEscaletaFinal,
const escaletaResult = await generateEscaleta(guion, ideaMotriz, personajes, preEscaleta,
(current, total, label) => {
send({ type: 'progress', current, total, label });
}
);
const { contenidoMarkdown: _ignore, planos: rawPlanos, ...frontmatterData } = escaletaResult as any;
const { contenidoMarkdown: _ignore, planos: rawPlanos, ...metaData } = escaletaResult as any;
const planosSaved = normalizePlanos(rawPlanos || [], personajes);
const mdContent = generateEscaletaMarkdown({ ...frontmatterData, planos: planosSaved });
const fileContent = matter.stringify(mdContent, frontmatterData);
await fs.writeFile(escaletaPath, fileContent);
await fs.writeFile(planosPath, JSON.stringify(planosSaved, null, 2));
const mdContent = generateEscaletaMarkdown({ ...metaData, planos: planosSaved });
send({ type: 'done', result: { ...frontmatterData, contenidoMarkdown: mdContent, planos: planosSaved } });
const escaletaToSave: Escaleta = {
capitulo: metaData.capitulo || '',
virtudGanada: metaData.virtudGanada || '',
duracionTotalSegundos: metaData.duracionTotalSegundos,
planos: planosSaved,
contenidoMarkdown: mdContent,
};
saveEscaleta(slug, num, escaletaToSave);
send({ type: 'done', result: escaletaToSave });
} catch (error: any) {
send({ type: 'error', message: error.message });
}
@@ -228,15 +181,11 @@ export async function POST(
}
if (action === 'save' && contenidoMarkdown !== undefined) {
let frontmatterData: Record<string, any> = {};
try {
const existing = await fs.readFile(escaletaPath, 'utf-8');
const { data } = matter(existing);
frontmatterData = data;
} catch { /* no existing file */ }
const fileContent = matter.stringify(contenidoMarkdown, frontmatterData);
await fs.writeFile(escaletaPath, fileContent);
const current = getEscaleta(slug, num);
if (!current) {
return NextResponse.json({ error: 'No hay escaleta existente' }, { status: 400 });
}
saveEscaleta(slug, num, { ...current, contenidoMarkdown });
return NextResponse.json({ success: true });
}

View File

@@ -31,7 +31,7 @@ export async function GET(
return new NextResponse(new Uint8Array(imageBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-store, must-revalidate',
},
});
} catch {

View File

@@ -1,57 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { generateImage } from '@/lib/image-service';
import { getIdeaMotriz } from '@/lib/file-service';
import {
getIdeaMotriz, getFondos, saveFondos, updateFondo,
getCapDirName, getEscaleta, getGuionConductor,
} from '@/lib/db-service';
import { resolveEstilo } from '@/lib/prompt-utils';
import type { FondoReferencia, ActoGuion } from '@/types/project';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
const DEFAULT_ESTILO = 'Pixar-style 3D animation, vibrant colors, expressive characters';
async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
return dirs.find(d =>
d.isDirectory() && d.name.startsWith(numero.padStart(2, '0'))
);
}
/** Extract unique escenarios from escaleta planos + guion actos */
async function extractFondos(capPath: string): Promise<FondoReferencia[]> {
// Read escaleta planos
let planos: any[] = [];
try {
planos = JSON.parse(await fs.readFile(path.join(capPath, 'escaleta-planos.json'), 'utf-8'));
} catch { return []; }
function extractFondosFromData(planos: any[], actos?: ActoGuion[]): FondoReferencia[] {
if (!planos || planos.length === 0) return [];
// Read guion actos for disenoFondo / accionVisualClave
let actos: ActoGuion[] = [];
try {
actos = JSON.parse(await fs.readFile(path.join(capPath, 'guion-actos.json'), 'utf-8'));
} catch { /* ok */ }
// Build segment lookup: segmentoId → { nombre, disenoFondo, accionVisualClave }
// Build segment lookup from actos
const segmentoMap = new Map<string, { nombre: string; disenoFondo: string; accionVisualClave: string }>();
for (const acto of actos) {
for (const seg of acto.segmentos) {
segmentoMap.set(seg.id, { nombre: seg.nombre, disenoFondo: (seg as any).disenoFondo || '', accionVisualClave: seg.accionVisualClave });
if (actos) {
for (const acto of actos) {
for (const seg of acto.segmentos) {
segmentoMap.set(seg.id, {
nombre: seg.nombre,
disenoFondo: (seg as any).disenoFondo || '',
accionVisualClave: seg.accionVisualClave,
});
}
}
}
// Get unique segments from planos (segmentoGuion = "I.1 Nombre del Segmento")
const seen = new Set<string>();
const fondos: FondoReferencia[] = [];
for (const plano of planos) {
const segGuion: string = plano.segmentoGuion || '';
// Extract segment ID (e.g. "I.1" from "I.1 El Silencio del Bosque")
const segId = segGuion.split(' ')[0];
if (!segId || seen.has(segId)) continue;
seen.add(segId);
const segInfo = segmentoMap.get(segId);
const nombre = segInfo?.nombre || segGuion.replace(segId, '').trim() || segId;
// Prefer disenoFondo (dedicated background description), fall back to accionVisualClave
const descripcion = segInfo?.disenoFondo || segInfo?.accionVisualClave || '';
fondos.push({
@@ -66,25 +54,6 @@ async function extractFondos(capPath: string): Promise<FondoReferencia[]> {
return fondos;
}
/** Load or auto-generate fondos.json */
async function loadFondos(capPath: string): Promise<FondoReferencia[]> {
const fondosPath = path.join(capPath, 'fondos.json');
try {
return JSON.parse(await fs.readFile(fondosPath, 'utf-8'));
} catch { /* not yet */ }
// Auto-generate from escaleta
const fondos = await extractFondos(capPath);
if (fondos.length > 0) {
await fs.writeFile(fondosPath, JSON.stringify(fondos, null, 2));
}
return fondos;
}
async function saveFondos(capPath: string, fondos: FondoReferencia[]): Promise<void> {
await fs.writeFile(path.join(capPath, 'fondos.json'), JSON.stringify(fondos, null, 2));
}
/** Check if fondo image exists on disk */
async function enrichWithImageStatus(
fondos: FondoReferencia[],
@@ -111,16 +80,24 @@ export async function GET(
) {
try {
const { slug, numero } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
const num = parseInt(numero);
const capDirName = getCapDirName(slug, num);
if (!capDirName) {
return NextResponse.json(null);
}
const capPath = path.join(capitulosDir, capDir.name);
const fondos = await loadFondos(capPath);
const enriched = await enrichWithImageStatus(fondos, slug, capDir.name);
// Load from DB, auto-extract if empty
let fondos = getFondos(slug, num);
if (fondos.length === 0) {
const escaleta = getEscaleta(slug, num);
const guion = getGuionConductor(slug, num);
fondos = extractFondosFromData(escaleta?.planos || [], guion?.actos);
if (fondos.length > 0) {
saveFondos(slug, num, fondos);
}
}
const enriched = await enrichWithImageStatus(fondos, slug, capDirName);
const generated = enriched.filter(f => f.imagenUrl).length;
return NextResponse.json({ fondos: enriched, total: enriched.length, generated });
@@ -136,17 +113,15 @@ export async function POST(
) {
try {
const { slug, numero } = await params;
const num = parseInt(numero);
const { action, fondoId, descripcion, promptGeneracion } = await request.json();
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
const capDirName = getCapDirName(slug, num);
if (!capDirName) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const capPath = path.join(capitulosDir, capDir.name);
let fondos = await loadFondos(capPath);
let fondos = getFondos(slug, num);
if (action === 'generate') {
if (!fondoId) {
@@ -159,34 +134,29 @@ export async function POST(
}
const fondo = fondos[fondoIdx];
const ideaMotriz = await getIdeaMotriz(slug);
const estilo = ideaMotriz?.estiloVisual || DEFAULT_ESTILO;
const ideaMotriz = getIdeaMotriz(slug);
const estilo = resolveEstilo(ideaMotriz);
// Always compose from current descripcion + estilo (promptGeneracion is only a user override)
const prompt = `${estilo}. Empty scene without characters: ${fondo.descripcion}. Panoramic view of the environment, lighting, and atmosphere of the place. No characters, no text, no UI elements.`;
const imageBuffer = await generateImage(prompt, { aspectRatio: '16:9' });
// Save image
const fondosDir = path.join(capPath, 'fondos');
// Save image to disk
const fondosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'fondos');
await fs.mkdir(fondosDir, { recursive: true });
await fs.writeFile(path.join(fondosDir, `${fondoId}.png`), imageBuffer);
// Update fondos.json
fondos[fondoIdx] = {
...fondo,
promptGeneracion: prompt,
imagenUrl: `/api/projects/${slug}/capitulos/${capDir.name}/fondos/${fondoId}`,
};
await saveFondos(capPath, fondos);
// Update DB
const imagenUrl = `/api/projects/${slug}/capitulos/${capDirName}/fondos/${fondoId}`;
updateFondo(slug, num, fondoId, { promptGeneracion: prompt, imagenUrl });
return NextResponse.json({ fondo: fondos[fondoIdx] });
return NextResponse.json({ fondo: { ...fondo, promptGeneracion: prompt, imagenUrl } });
}
if (action === 'generate-all') {
const ideaMotriz = await getIdeaMotriz(slug);
const estilo = ideaMotriz?.estiloVisual || DEFAULT_ESTILO;
const fondosDir = path.join(capPath, 'fondos');
const ideaMotriz = getIdeaMotriz(slug);
const estilo = resolveEstilo(ideaMotriz);
const fondosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'fondos');
await fs.mkdir(fondosDir, { recursive: true });
const results: { id: string; success: boolean; error?: string }[] = [];
@@ -195,17 +165,14 @@ export async function POST(
const fondo = fondos[i];
try {
// Always compose from current descripcion + estilo
const prompt = `${estilo}. Empty scene without characters: ${fondo.descripcion}. Panoramic view of the environment, lighting, and atmosphere of the place. No characters, no text, no UI elements.`;
const imageBuffer = await generateImage(prompt, { aspectRatio: '16:9' });
await fs.writeFile(path.join(fondosDir, `${fondo.id}.png`), imageBuffer);
fondos[i] = {
...fondo,
promptGeneracion: prompt,
imagenUrl: `/api/projects/${slug}/capitulos/${capDir.name}/fondos/${fondo.id}`,
};
const imagenUrl = `/api/projects/${slug}/capitulos/${capDirName}/fondos/${fondo.id}`;
updateFondo(slug, num, fondo.id, { promptGeneracion: prompt, imagenUrl });
fondos[i] = { ...fondo, promptGeneracion: prompt, imagenUrl };
results.push({ id: fondo.id, success: true });
} catch (err: any) {
console.error(`Error generando fondo ${fondo.id}:`, err);
@@ -213,27 +180,20 @@ export async function POST(
}
}
await saveFondos(capPath, fondos);
const enriched = await enrichWithImageStatus(fondos, slug, capDir.name);
const enriched = await enrichWithImageStatus(fondos, slug, capDirName);
const generated = enriched.filter(f => f.imagenUrl).length;
return NextResponse.json({ fondos: enriched, total: enriched.length, generated, results });
}
if (action === 'refresh') {
// Re-extract fondos from guión, merging with existing data
const freshFondos = await extractFondos(capPath);
const existingMap = new Map(fondos.map(f => [f.id, f]));
// Re-extract fondos from guión/escaleta
const escaleta = getEscaleta(slug, num);
const guion = getGuionConductor(slug, num);
const freshFondos = extractFondosFromData(escaleta?.planos || [], guion?.actos);
const merged = freshFondos.map(fresh => {
const existing = existingMap.get(fresh.id);
if (!existing) return fresh;
// Update descripcion from guión, clear promptGeneracion so it recomposes on next generate
return { ...fresh, promptGeneracion: '' };
});
await saveFondos(capPath, merged);
const enriched = await enrichWithImageStatus(merged, slug, capDir.name);
saveFondos(slug, num, freshFondos);
const enriched = await enrichWithImageStatus(freshFondos, slug, capDirName);
const generated = enriched.filter(f => f.imagenUrl).length;
return NextResponse.json({ fondos: enriched, total: enriched.length, generated });
@@ -249,14 +209,13 @@ export async function POST(
return NextResponse.json({ error: 'Fondo no encontrado' }, { status: 404 });
}
fondos[fondoIdx] = {
...fondos[fondoIdx],
...(descripcion !== undefined && { descripcion }),
...(promptGeneracion !== undefined && { promptGeneracion }),
};
await saveFondos(capPath, fondos);
const updates: Partial<FondoReferencia> = {};
if (descripcion !== undefined) updates.descripcion = descripcion;
if (promptGeneracion !== undefined) updates.promptGeneracion = promptGeneracion;
return NextResponse.json({ fondo: fondos[fondoIdx] });
updateFondo(slug, num, fondoId, updates);
return NextResponse.json({ fondo: { ...fondos[fondoIdx], ...updates } });
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });

View File

@@ -1,17 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { getIdeaMotriz, getPersonajes } from '@/lib/file-service';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
return dirs.find(d =>
d.isDirectory() && d.name.startsWith(numero.padStart(2, '0'))
);
}
import { getIdeaMotriz, getPersonajes, getGuionConductor, saveGuionConductor } from '@/lib/db-service';
import type { GuionConductor } from '@/types/project';
export async function GET(
request: NextRequest,
@@ -19,31 +8,11 @@ export async function GET(
) {
try {
const { slug, numero } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
return NextResponse.json(null);
}
const guionPath = path.join(capitulosDir, capDir.name, 'guion-conductor.md');
try {
const fileContent = await fs.readFile(guionPath, 'utf-8');
const { data, content } = matter(fileContent);
// Load structured actos from JSON (saved alongside markdown)
let actos;
try {
const actosPath = path.join(capitulosDir, capDir.name, 'guion-actos.json');
const actosJson = await fs.readFile(actosPath, 'utf-8');
actos = JSON.parse(actosJson);
} catch { /* no actos file yet — old guion */ }
return NextResponse.json({ ...data, actos, contenidoMarkdown: content });
} catch {
const guion = getGuionConductor(slug, parseInt(numero));
if (!guion) {
return NextResponse.json(null);
}
return NextResponse.json(guion);
} catch (error: any) {
console.error('Error fetching guion:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -56,63 +25,50 @@ export async function POST(
) {
try {
const { slug, numero } = await params;
const num = parseInt(numero);
const body = await request.json();
const { action } = body;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const guionPath = path.join(capitulosDir, capDir.name, 'guion-conductor.md');
if (action === 'generate') {
const ideaMotriz = await getIdeaMotriz(slug);
const ideaMotriz = getIdeaMotriz(slug);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const personajes = await getPersonajes(slug);
const personajes = getPersonajes(slug);
if (personajes.length === 0) {
return NextResponse.json({ error: 'Primero debes crear los personajes' }, { status: 400 });
}
const { generateGuionConductor } = await import('@/lib/llm-service');
const guion = await generateGuionConductor(ideaMotriz, personajes, parseInt(numero));
const guion = await generateGuionConductor(ideaMotriz, personajes, num);
// Separate actos (too complex for frontmatter) and save as JSON
const { contenidoMarkdown: _ignore, actos, ...frontmatterData } = guion as any;
const mdContent = generateGuionMarkdown(guion);
const fileContent = matter.stringify(mdContent, frontmatterData);
await fs.writeFile(guionPath, fileContent);
const guionToSave: GuionConductor = {
titulo: guion.titulo || '',
duracionTotal: guion.duracionTotal || '20:00',
virtudGanada: guion.virtudGanada || '',
nudoDelMundo: guion.nudoDelMundo || '',
aliado: guion.aliado || { nombre: '', descripcion: '' },
gemaGanada: guion.gemaGanada || '',
actos: guion.actos,
contenidoMarkdown: mdContent,
};
saveGuionConductor(slug, num, guionToSave);
// Save actos as structured JSON for downstream escaleta generation
if (actos) {
const actosPath = path.join(capitulosDir, capDir.name, 'guion-actos.json');
await fs.writeFile(actosPath, JSON.stringify(actos, null, 2));
}
// Return with actos included so frontend can pass them to escaleta generation
return NextResponse.json({ ...frontmatterData, actos, contenidoMarkdown: mdContent });
return NextResponse.json(guionToSave);
}
if (action === 'regenerate-actos') {
// Read current guión metadata from disk
let currentGuion: Record<string, any> = {};
try {
const existing = await fs.readFile(guionPath, 'utf-8');
const { data } = matter(existing);
currentGuion = data;
} catch {
const currentGuion = getGuionConductor(slug, num);
if (!currentGuion) {
return NextResponse.json({ error: 'No hay guión existente para regenerar actos' }, { status: 400 });
}
const ideaMotriz = await getIdeaMotriz(slug);
const ideaMotriz = getIdeaMotriz(slug);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const personajes = await getPersonajes(slug);
const personajes = getPersonajes(slug);
if (personajes.length === 0) {
return NextResponse.json({ error: 'Primero debes crear los personajes' }, { status: 400 });
}
@@ -127,36 +83,27 @@ export async function POST(
gemaGanada: currentGuion.gemaGanada || '',
};
const result = await regenerateGuionActos(guionMetadata, ideaMotriz, personajes, parseInt(numero));
const result = await regenerateGuionActos(guionMetadata, ideaMotriz, personajes, num);
const actos = result.actos;
// Merge: keep existing metadata, replace actos
const fullGuion = { ...guionMetadata, actos };
const { actos: _a, contenidoMarkdown: _c, ...frontmatterData } = fullGuion as any;
const mdContent = generateGuionMarkdown(fullGuion);
const fileContent = matter.stringify(mdContent, frontmatterData);
await fs.writeFile(guionPath, fileContent);
const guionToSave: GuionConductor = {
...guionMetadata,
actos,
contenidoMarkdown: mdContent,
};
saveGuionConductor(slug, num, guionToSave);
// Save actos as structured JSON
if (actos) {
const actosPath = path.join(capitulosDir, capDir.name, 'guion-actos.json');
await fs.writeFile(actosPath, JSON.stringify(actos, null, 2));
}
return NextResponse.json({ ...frontmatterData, actos, contenidoMarkdown: mdContent });
return NextResponse.json(guionToSave);
}
if (action === 'save' && body.contenidoMarkdown !== undefined) {
// Leer frontmatter actual y guardar con nuevo contenido
let frontmatterData: Record<string, any> = {};
try {
const existing = await fs.readFile(guionPath, 'utf-8');
const { data } = matter(existing);
frontmatterData = data;
} catch { /* no existing file */ }
const fileContent = matter.stringify(body.contenidoMarkdown, frontmatterData);
await fs.writeFile(guionPath, fileContent);
const currentGuion = getGuionConductor(slug, num);
if (!currentGuion) {
return NextResponse.json({ error: 'No hay guión existente' }, { status: 400 });
}
saveGuionConductor(slug, num, { ...currentGuion, contenidoMarkdown: body.contenidoMarkdown });
return NextResponse.json({ success: true });
}
@@ -166,21 +113,20 @@ export async function POST(
return NextResponse.json({ error: 'Se necesita guionData' }, { status: 400 });
}
// Extract actos from the guion data (stored separately as JSON)
const { actos, contenidoMarkdown: _ignore2, ...frontmatterFields } = guionData;
// Regenerate markdown from updated fields
const mdContent = generateGuionMarkdown(guionData);
const fileContent = matter.stringify(mdContent, frontmatterFields);
await fs.writeFile(guionPath, fileContent);
const guionToSave: GuionConductor = {
titulo: guionData.titulo || '',
duracionTotal: guionData.duracionTotal || '20:00',
virtudGanada: guionData.virtudGanada || '',
nudoDelMundo: guionData.nudoDelMundo || '',
aliado: guionData.aliado || { nombre: '', descripcion: '' },
gemaGanada: guionData.gemaGanada || '',
actos: guionData.actos,
contenidoMarkdown: mdContent,
};
saveGuionConductor(slug, num, guionToSave);
// Save actos as structured JSON
const actosPath = path.join(capitulosDir, capDir.name, 'guion-actos.json');
if (actos) {
await fs.writeFile(actosPath, JSON.stringify(actos, null, 2));
}
return NextResponse.json({ ...frontmatterFields, actos, contenidoMarkdown: mdContent });
return NextResponse.json(guionToSave);
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });

View File

@@ -34,7 +34,7 @@ export async function GET(
return new NextResponse(new Uint8Array(imageBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-store, must-revalidate',
},
});
} catch (error: any) {

View File

@@ -2,71 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { loadImage, generateSceneImage, savePlanoImage, getPlanoImagePath } from '@/lib/image-service';
import {
getPromptsPlanos, savePromptsPlanos, getEscaleta,
getCapDirName, savePromptComposition, updatePromptPlano, getFondos,
getPersonajes, getCharacterAssetsMap, getIdeaMotriz,
} from '@/lib/db-service';
import { composePrompt, resolveEstilo, withEstilo, resolveReferences } from '@/lib/prompt-utils';
import type { PlanoConPrompt, ReferenceAsset, PromptComposition, FondoReferencia } from '@/types/project';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
return dirs.find(d =>
d.isDirectory() && d.name.startsWith(numero.padStart(2, '0'))
);
}
/** Migrate old referenceAsset format: tposeUrl → tposeFrontUrl */
function migrateReferenceAssets(refs: any[]): ReferenceAsset[] {
return refs.map(ref => {
if (ref.tposeFrontUrl || !ref.tposeUrl) return ref;
const { tposeUrl, ...rest } = ref;
return { ...rest, tposeFrontUrl: tposeUrl };
});
}
/** Load planos: prefer prompts-planos.json, fall back to escaleta-planos.json */
async function loadPlanos(capPath: string): Promise<{ planos: PlanoConPrompt[]; source: 'prompts' | 'escaleta' }> {
try {
const content = await fs.readFile(path.join(capPath, 'prompts-planos.json'), 'utf-8');
const promptPlanos: PlanoConPrompt[] = JSON.parse(content);
let escaletaPlanos: any[] = [];
try {
const esc = await fs.readFile(path.join(capPath, 'escaleta-planos.json'), 'utf-8');
escaletaPlanos = JSON.parse(esc);
} catch { /* ok */ }
const promptMap = new Map(promptPlanos.map(p => [p.id, p]));
const merged = escaletaPlanos.map((ep: any) => {
const withPrompt = promptMap.get(ep.id);
if (!withPrompt) return { ...ep, promptImagen: '', referenceAssets: [] };
// Migrate old referenceAssets format
return {
...withPrompt,
referenceAssets: migrateReferenceAssets(withPrompt.referenceAssets || []),
};
});
return { planos: merged.length > 0 ? merged : promptPlanos.map(p => ({
...p,
referenceAssets: migrateReferenceAssets(p.referenceAssets || []),
})), source: 'prompts' };
} catch { /* no prompts-planos.json */ }
try {
const content = await fs.readFile(path.join(capPath, 'escaleta-planos.json'), 'utf-8');
const escaletaPlanos = JSON.parse(content);
const planos: PlanoConPrompt[] = escaletaPlanos.map((p: any) => ({
...p,
promptImagen: '',
referenceAssets: [],
}));
return { planos, source: 'escaleta' };
} catch { /* no escaleta either */ }
throw new Error('NO_PLANOS');
}
async function savePromptPlanos(capPath: string, planos: PlanoConPrompt[]): Promise<void> {
await fs.writeFile(path.join(capPath, 'prompts-planos.json'), JSON.stringify(planos, null, 2));
}
/** Check which planos already have generated images on disk */
async function enrichWithImageStatus(
planos: PlanoConPrompt[],
@@ -91,7 +36,8 @@ async function loadReferenceImages(
slug: string,
capDirName: string,
refs: ReferenceAsset[],
fondoId?: string
fondoId?: string,
fondos?: FondoReferencia[]
): Promise<{ buffer: Buffer; label: string }[]> {
const images: { buffer: Buffer; label: string }[] = [];
@@ -100,31 +46,24 @@ async function loadReferenceImages(
const fondoPath = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'fondos', `${fondoId}.png`);
try {
const buffer = await fs.readFile(fondoPath);
// Load fondos.json to get the fondo name
let fondoNombre = fondoId;
try {
const fondosJson: FondoReferencia[] = JSON.parse(
await fs.readFile(path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'fondos.json'), 'utf-8')
);
const fondo = fondosJson.find(f => f.id === fondoId);
if (fondos) {
const fondo = fondos.find(f => f.id === fondoId);
if (fondo) fondoNombre = fondo.nombre;
} catch { /* ok */ }
}
images.push({ buffer, label: `Background reference for "${fondoNombre}". Use this image as inspiration for the background/environment where the action takes place. Match the atmosphere, lighting, and setting shown.` });
} catch { /* no fondo image */ }
}
// Load character reference images
for (const ref of refs) {
// Front T-pose (also check legacy tposeUrl field)
const frontUrl = ref.tposeFrontUrl || (ref as any).tposeUrl;
if (frontUrl) {
const assetId = frontUrl.split('/').pop()!;
if (ref.tposeFrontUrl) {
const assetId = ref.tposeFrontUrl.split('/').pop()!;
const buffer = await loadImage(slug, ref.personajeId, assetId);
if (buffer) {
images.push({ buffer, label: `${ref.nombre} - T-pose front` });
}
}
// Side T-pose
if (ref.tposeSideUrl) {
const assetId = ref.tposeSideUrl.split('/').pop()!;
const buffer = await loadImage(slug, ref.personajeId, assetId);
@@ -132,7 +71,6 @@ async function loadReferenceImages(
images.push({ buffer, label: `${ref.nombre} - T-pose side` });
}
}
// Back T-pose
if (ref.tposeBackUrl) {
const assetId = ref.tposeBackUrl.split('/').pop()!;
const buffer = await loadImage(slug, ref.personajeId, assetId);
@@ -140,7 +78,6 @@ async function loadReferenceImages(
images.push({ buffer, label: `${ref.nombre} - T-pose back` });
}
}
// Emotion
if (ref.emotionUrl) {
const assetId = ref.emotionUrl.split('/').pop()!;
const buffer = await loadImage(slug, ref.personajeId, assetId);
@@ -153,45 +90,44 @@ async function loadReferenceImages(
return images;
}
/** Compose a final prompt string from sub-fields */
function composePrompt(comp: PromptComposition): string {
const parts = [
comp.tipoPlano,
comp.camara,
comp.optica,
comp.personajes,
comp.accion,
comp.luz,
comp.estilo,
comp.custom,
].filter(Boolean);
return parts.join('. ') + '.';
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
const num = parseInt(numero);
const capDirName = getCapDirName(slug, num);
if (!capDirName) {
return NextResponse.json(null);
}
const capPath = path.join(capitulosDir, capDir.name);
try {
const { planos } = await loadPlanos(capPath);
const enriched = await enrichWithImageStatus(planos, slug, capDir.name);
const generated = enriched.filter(p => p.imagenUrl).length;
const withPrompt = enriched.filter(p => p.promptImagen).length;
return NextResponse.json({ planos: enriched, total: enriched.length, generated, withPrompt });
} catch {
return NextResponse.json(null);
// Try prompts_planos first, fall back to escaleta planos
let planos = getPromptsPlanos(slug, num);
if (planos.length === 0) {
const escaleta = getEscaleta(slug, num);
if (!escaleta?.planos?.length) {
return NextResponse.json(null);
}
planos = escaleta.planos.map((p: any) => ({
...p,
promptImagen: '',
}));
}
// Resolve referenceAssets at runtime from personajesDetalle + character assets
const personajes = getPersonajes(slug);
const assetsMap = getCharacterAssetsMap(slug, personajes);
const planosWithRefs = planos.map(p => ({
...p,
referenceAssets: resolveReferences(p.personajesDetalle || [], assetsMap),
}));
const enriched = await enrichWithImageStatus(planosWithRefs, slug, capDirName);
const generated = enriched.filter(p => p.imagenUrl).length;
const withPrompt = enriched.filter(p => p.promptImagen).length;
const estiloVisual = resolveEstilo(getIdeaMotriz(slug));
return NextResponse.json({ planos: enriched, total: enriched.length, generated, withPrompt, estiloVisual });
} catch (error: any) {
console.error('Error fetching imagenes:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -204,28 +140,28 @@ export async function POST(
) {
try {
const { slug, numero } = await params;
const { action, planoId, composition } = await request.json();
const num = parseInt(numero);
const { action, planoId, composition, personajesDetalle } = await request.json();
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
const capDirName = getCapDirName(slug, num);
if (!capDirName) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const capPath = path.join(capitulosDir, capDir.name);
let planos: PlanoConPrompt[];
try {
const result = await loadPlanos(capPath);
planos = result.planos;
} catch {
let planos = getPromptsPlanos(slug, num);
if (planos.length === 0) {
return NextResponse.json(
{ error: 'No se encontraron planos. Genera la escaleta primero.' },
{ status: 400 }
);
}
// Load fondos + resolve character refs + estilo for image generation
const fondos = getFondos(slug, num);
const personajes = getPersonajes(slug);
const assetsMap = getCharacterAssetsMap(slug, personajes);
const estilo = resolveEstilo(getIdeaMotriz(slug));
if (action === 'generate') {
if (!planoId) {
return NextResponse.json({ error: 'Se necesita planoId' }, { status: 400 });
@@ -241,19 +177,21 @@ export async function POST(
return NextResponse.json({ error: 'Este plano no tiene prompt. Genera el prompt primero.' }, { status: 400 });
}
const refs = await loadReferenceImages(slug, capDir.name, plano.referenceAssets || [], plano.fondoId);
const resolvedRefs = resolveReferences(plano.personajesDetalle || [], assetsMap);
const refs = await loadReferenceImages(slug, capDirName, resolvedRefs, plano.fondoId, fondos);
const fullPrompt = withEstilo(plano.promptImagen, estilo);
const imageBuffer = await generateSceneImage(
refs,
plano.promptImagen,
fullPrompt,
{ aspectRatio: '16:9' }
);
const imagenUrl = await savePlanoImage(slug, capDir.name, plano.id, imageBuffer);
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
planos[planoIdx] = { ...plano, imagenUrl };
await savePromptPlanos(capPath, planos);
savePromptsPlanos(slug, num, planos);
return NextResponse.json({ plano: planos[planoIdx] });
return NextResponse.json({ plano: { ...planos[planoIdx], referenceAssets: resolvedRefs } });
}
if (action === 'generate-all') {
@@ -267,15 +205,17 @@ export async function POST(
}
try {
const refs = await loadReferenceImages(slug, capDir.name, plano.referenceAssets || [], plano.fondoId);
const resolvedRefs = resolveReferences(plano.personajesDetalle || [], assetsMap);
const refs = await loadReferenceImages(slug, capDirName, resolvedRefs, plano.fondoId, fondos);
const fullPrompt = withEstilo(plano.promptImagen, estilo);
const imageBuffer = await generateSceneImage(
refs,
plano.promptImagen,
fullPrompt,
{ aspectRatio: '16:9' }
);
const imagenUrl = await savePlanoImage(slug, capDir.name, plano.id, imageBuffer);
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
planos[i] = { ...plano, imagenUrl };
await savePromptPlanos(capPath, planos);
savePromptsPlanos(slug, num, planos);
results.push({ id: plano.id, success: true });
} catch (err: any) {
console.error(`Error generando imagen para ${plano.id}:`, err);
@@ -283,7 +223,12 @@ export async function POST(
}
}
const enriched = await enrichWithImageStatus(planos, slug, capDir.name);
// Enrich with refs for the response
const planosWithRefs = planos.map(p => ({
...p,
referenceAssets: resolveReferences(p.personajesDetalle || [], assetsMap),
}));
const enriched = await enrichWithImageStatus(planosWithRefs, slug, capDirName);
const generated = enriched.filter(p => p.imagenUrl).length;
const withPrompt = enriched.filter(p => p.promptImagen).length;
return NextResponse.json({ planos: enriched, total: enriched.length, generated, withPrompt, results });
@@ -302,12 +247,29 @@ export async function POST(
const promptComposition: PromptComposition = composition;
const promptImagen = composePrompt(promptComposition);
savePromptComposition(slug, num, planoId, promptComposition, promptImagen);
planos[planoIdx] = { ...planos[planoIdx], promptComposition, promptImagen };
await savePromptPlanos(capPath, planos);
return NextResponse.json({ plano: planos[planoIdx] });
}
if (action === 'save-personajes') {
if (!planoId || !personajesDetalle) {
return NextResponse.json({ error: 'Se necesita planoId y personajesDetalle' }, { status: 400 });
}
const planoIdx = planos.findIndex(p => p.id === planoId);
if (planoIdx === -1) {
return NextResponse.json({ error: 'Plano no encontrado' }, { status: 404 });
}
updatePromptPlano(slug, num, planoId, { personajesDetalle });
planos[planoIdx] = { ...planos[planoIdx], personajesDetalle };
const resolvedRefs = resolveReferences(personajesDetalle, assetsMap);
return NextResponse.json({ plano: { ...planos[planoIdx], referenceAssets: resolvedRefs } });
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en imagenes:', error);

View File

@@ -1,18 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getPreEscaleta, savePreEscaleta } from '@/lib/db-service';
import type { PreEscaleta, PreEscaletaSegmento, PreEscaletaPlano, ActoGuion } from '@/types/project';
import { parseTimeRange } from '@/lib/prompts';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
return dirs.find(d =>
d.isDirectory() && d.name.startsWith(numero.padStart(2, '0'))
);
}
/** Build pre-escaleta from structured actos — pure computation, no LLM */
function buildPreEscaleta(actos: ActoGuion[], capituloTitulo: string): PreEscaleta {
const segmentos: PreEscaletaSegmento[] = [];
@@ -65,21 +55,8 @@ export async function GET(
) {
try {
const { slug, numero } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
return NextResponse.json(null);
}
const preEscaletaPath = path.join(capitulosDir, capDir.name, 'pre-escaleta.json');
try {
const content = await fs.readFile(preEscaletaPath, 'utf-8');
return NextResponse.json(JSON.parse(content));
} catch {
return NextResponse.json(null);
}
const preEscaleta = getPreEscaleta(slug, parseInt(numero));
return NextResponse.json(preEscaleta);
} catch (error: any) {
console.error('Error fetching pre-escaleta:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -92,17 +69,8 @@ export async function POST(
) {
try {
const { slug, numero } = await params;
const { action, guion } = await request.json();
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const capPath = path.join(capitulosDir, capDir.name);
const preEscaletaPath = path.join(capPath, 'pre-escaleta.json');
const num = parseInt(numero);
const { action, guion, preEscaleta } = await request.json();
if (action === 'generate') {
if (!guion?.actos || guion.actos.length === 0) {
@@ -112,15 +80,14 @@ export async function POST(
);
}
const preEscaleta = buildPreEscaleta(guion.actos, guion.titulo || '');
await fs.writeFile(preEscaletaPath, JSON.stringify(preEscaleta, null, 2));
return NextResponse.json(preEscaleta);
const result = buildPreEscaleta(guion.actos, guion.titulo || '');
savePreEscaleta(slug, num, result);
return NextResponse.json(result);
}
if (action === 'save') {
const { preEscaleta } = await request.json().catch(() => ({ preEscaleta: null }));
if (preEscaleta) {
await fs.writeFile(preEscaletaPath, JSON.stringify(preEscaleta, null, 2));
savePreEscaleta(slug, num, preEscaleta);
return NextResponse.json({ success: true });
}
}

View File

@@ -1,56 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { getPersonajes, getCharacterAssetsMap, getIdeaMotriz } from '@/lib/file-service';
import type { PersonajeEnPlano, PromptComposition, ReferenceAsset, FondoReferencia } from '@/types/project';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
const DEFAULT_ESTILO = 'Pixar-style 3D animation, vibrant colors, expressive characters';
async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
return dirs.find(d =>
d.isDirectory() && d.name.startsWith(numero.padStart(2, '0'))
);
}
/** Compose a final prompt string from sub-fields */
function composePrompt(comp: PromptComposition): string {
const parts = [
comp.tipoPlano,
comp.camara,
comp.optica,
comp.personajes,
comp.accion,
comp.luz,
comp.estilo,
comp.custom,
].filter(Boolean);
return parts.join('. ') + '.';
}
/** Build referenceAssets with 3 T-poses + emotion for each character in the plano */
function buildReferenceAssets(
detalle: PersonajeEnPlano[],
assetsMap: Record<string, any>
): ReferenceAsset[] {
const referenceAssets: ReferenceAsset[] = [];
for (const pd of detalle) {
const summary = assetsMap[pd.personajeId];
if (!summary) continue;
referenceAssets.push({
personajeId: pd.personajeId,
nombre: pd.nombre,
emocion: pd.emocion,
tposeFrontUrl: summary.tposeFrontUrl,
tposeSideUrl: summary.tposeSideUrl,
tposeBackUrl: summary.tposeBackUrl,
emotionUrl: summary.emotions[pd.emocion],
});
}
return referenceAssets;
}
import {
getPersonajes,
getEscaleta, getFondos, getPromptsPlanos, savePromptsPlanos,
} from '@/lib/db-service';
import { ensureString, composePrompt } from '@/lib/prompt-utils';
import type { PersonajeEnPlano, PromptComposition, FondoReferencia } from '@/types/project';
/** Map segmentoGuion string (e.g. "I.1 El Silencio") to fondoId */
function getFondoIdForPlano(plano: any, fondos: FondoReferencia[]): string | undefined {
@@ -60,29 +14,13 @@ function getFondoIdForPlano(plano: any, fondos: FondoReferencia[]): string | und
return fondo?.id;
}
/** Ensure a value is a string (LLM may return arrays/objects for some fields) */
function ensureString(val: any): string {
if (typeof val === 'string') return val;
if (val == null) return '';
if (Array.isArray(val)) {
return val.map(v => typeof v === 'object' ? Object.values(v).filter(Boolean).join(', ') : String(v)).join('. ');
}
if (typeof val === 'object') return Object.values(val).filter(Boolean).join(', ');
return String(val);
}
/** Enrich a plano from LLM output: inject estilo, compose promptImagen, add refs + fondoId */
/** Enrich a plano from LLM output: compose promptImagen (without estilo) + fondoId */
function enrichPlano(
plano: any,
estiloVisual: string,
assetsMap: Record<string, any>,
fondos: FondoReferencia[]
): any {
const detalle: PersonajeEnPlano[] = plano.personajesDetalle || [];
const referenceAssets = buildReferenceAssets(detalle, assetsMap);
const fondoId = getFondoIdForPlano(plano, fondos);
// Build promptComposition: LLM provides most fields, we inject estilo
const llmComp = plano.promptComposition || {};
const promptComposition: PromptComposition = {
personajes: ensureString(llmComp.personajes),
@@ -91,13 +29,12 @@ function enrichPlano(
camara: ensureString(llmComp.camara),
optica: ensureString(llmComp.optica),
tipoPlano: ensureString(llmComp.tipoPlano),
estilo: estiloVisual,
custom: ensureString(llmComp.custom),
};
const promptImagen = composePrompt(promptComposition);
return { ...plano, promptComposition, promptImagen, referenceAssets, fondoId };
return { ...plano, promptComposition, promptImagen, fondoId };
}
export async function GET(
@@ -106,22 +43,19 @@ export async function GET(
) {
try {
const { slug, numero } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
const num = parseInt(numero);
if (!capDir) {
const planos = getPromptsPlanos(slug, num);
if (planos.length === 0) {
return NextResponse.json(null);
}
const promptsPath = path.join(capitulosDir, capDir.name, 'prompts.md');
try {
const fileContent = await fs.readFile(promptsPath, 'utf-8');
const { data, content } = matter(fileContent);
return NextResponse.json({ ...data, contenidoMarkdown: content });
} catch {
return NextResponse.json(null);
}
const mdContent = generatePromptsMarkdown(planos);
return NextResponse.json({
capitulo: '',
totalPrompts: planos.length,
contenidoMarkdown: mdContent,
});
} catch (error: any) {
console.error('Error fetching prompts:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -134,98 +68,47 @@ export async function POST(
) {
try {
const { slug, numero } = await params;
const num = parseInt(numero);
const { action, planoId } = await request.json();
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const capPath = path.join(capitulosDir, capDir.name);
const promptsPath = path.join(capPath, 'prompts.md');
// Shared: read estiloVisual + fondos
const ideaMotriz = await getIdeaMotriz(slug);
const estiloVisual = ideaMotriz?.estiloVisual || DEFAULT_ESTILO;
let fondos: FondoReferencia[] = [];
try {
fondos = JSON.parse(await fs.readFile(path.join(capPath, 'fondos.json'), 'utf-8'));
} catch { /* no fondos yet */ }
const fondos = getFondos(slug, num);
if (action === 'generate') {
// Read planos from server-side JSON (saved by escaleta generation)
const planosPath = path.join(capPath, 'escaleta-planos.json');
let planos: any[] = [];
try {
const planosJson = await fs.readFile(planosPath, 'utf-8');
planos = JSON.parse(planosJson);
} catch {
const escaleta = getEscaleta(slug, num);
const planos = escaleta?.planos || [];
if (planos.length === 0) {
return NextResponse.json({ error: 'No se encontraron planos. Regenera la escaleta primero.' }, { status: 400 });
}
if (planos.length === 0) {
return NextResponse.json({ error: 'La escaleta no tiene planos. Regenera la escaleta.' }, { status: 400 });
}
// Read escaleta frontmatter for metadata
let escaletaMeta: Record<string, any> = {};
try {
const escaletaContent = await fs.readFile(path.join(capPath, 'escaleta.md'), 'utf-8');
const { data } = matter(escaletaContent);
escaletaMeta = data;
} catch { /* ok */ }
const personajes = await getPersonajes(slug);
const assetsMap = await getCharacterAssetsMap(slug, personajes);
const personajes = getPersonajes(slug);
const { generateImagePrompts } = await import('@/lib/llm-service');
const escaletaForLLM = { ...escaletaMeta, planos } as any;
const escaletaForLLM = { capitulo: escaleta?.capitulo, virtudGanada: escaleta?.virtudGanada, planos } as any;
const planosConPrompts = await generateImagePrompts(escaletaForLLM, personajes);
// Enrich each plano
const planosEnriched = planosConPrompts.map((plano: any) =>
enrichPlano(plano, estiloVisual, assetsMap, fondos)
enrichPlano(plano, fondos)
);
// Save enriched planos as JSON for downstream image generation
const promptsPlanosPath = path.join(capPath, 'prompts-planos.json');
await fs.writeFile(promptsPlanosPath, JSON.stringify(planosEnriched, null, 2));
savePromptsPlanos(slug, num, planosEnriched);
const mdContent = generatePromptsMarkdown(planosEnriched);
const frontmatter = {
capitulo: escaletaMeta.capitulo || '',
return NextResponse.json({
capitulo: escaleta?.capitulo || '',
totalPrompts: planosEnriched.length,
};
const fileContent = matter.stringify(mdContent, frontmatter);
await fs.writeFile(promptsPath, fileContent);
return NextResponse.json({ ...frontmatter, contenidoMarkdown: mdContent, planos: planosEnriched });
contenidoMarkdown: mdContent,
planos: planosEnriched,
});
}
if (action === 'generate-stream') {
const escaletaPlanosPath = path.join(capPath, 'escaleta-planos.json');
let streamPlanos: any[] = [];
try {
const planosJson = await fs.readFile(escaletaPlanosPath, 'utf-8');
streamPlanos = JSON.parse(planosJson);
} catch {
const escaleta = getEscaleta(slug, num);
const streamPlanos = escaleta?.planos || [];
if (streamPlanos.length === 0) {
return NextResponse.json({ error: 'No se encontraron planos. Regenera la escaleta primero.' }, { status: 400 });
}
if (streamPlanos.length === 0) {
return NextResponse.json({ error: 'La escaleta no tiene planos. Regenera la escaleta.' }, { status: 400 });
}
let escaletaMeta: Record<string, any> = {};
try {
const escaletaContent = await fs.readFile(path.join(capPath, 'escaleta.md'), 'utf-8');
const { data } = matter(escaletaContent);
escaletaMeta = data;
} catch { /* ok */ }
const personajes = await getPersonajes(slug);
const assetsMap = await getCharacterAssetsMap(slug, personajes);
const personajes = getPersonajes(slug);
const encoder = new TextEncoder();
const stream = new ReadableStream({
@@ -238,7 +121,7 @@ export async function POST(
try {
const { generateImagePrompts } = await import('@/lib/llm-service');
const escaletaForLLM = { ...escaletaMeta, planos: streamPlanos } as any;
const escaletaForLLM = { capitulo: escaleta?.capitulo, virtudGanada: escaleta?.virtudGanada, planos: streamPlanos } as any;
const planosConPrompts = await generateImagePrompts(escaletaForLLM, personajes,
(current, total, label) => {
send({ type: 'progress', current, total, label });
@@ -246,21 +129,18 @@ export async function POST(
);
const planosEnriched = planosConPrompts.map((plano: any) =>
enrichPlano(plano, estiloVisual, assetsMap, fondos)
enrichPlano(plano, fondos)
);
const promptsPlanosPath = path.join(capPath, 'prompts-planos.json');
await fs.writeFile(promptsPlanosPath, JSON.stringify(planosEnriched, null, 2));
savePromptsPlanos(slug, num, planosEnriched);
const mdContent = generatePromptsMarkdown(planosEnriched);
const frontmatter = {
capitulo: escaletaMeta.capitulo || '',
send({ type: 'done', result: {
capitulo: escaleta?.capitulo || '',
totalPrompts: planosEnriched.length,
};
const fileContent = matter.stringify(mdContent, frontmatter);
await fs.writeFile(promptsPath, fileContent);
send({ type: 'done', result: { ...frontmatter, contenidoMarkdown: mdContent, planos: planosEnriched } });
contenidoMarkdown: mdContent,
planos: planosEnriched,
}});
} catch (error: any) {
send({ type: 'error', message: error.message });
}
@@ -283,12 +163,9 @@ export async function POST(
return NextResponse.json({ error: 'Se necesita planoId' }, { status: 400 });
}
// Read escaleta planos
const planosPath = path.join(capPath, 'escaleta-planos.json');
let escaletaPlanos: any[] = [];
try {
escaletaPlanos = JSON.parse(await fs.readFile(planosPath, 'utf-8'));
} catch {
const escaleta = getEscaleta(slug, num);
const escaletaPlanos = escaleta?.planos || [];
if (escaletaPlanos.length === 0) {
return NextResponse.json({ error: 'No se encontraron planos. Regenera la escaleta.' }, { status: 400 });
}
@@ -297,10 +174,8 @@ export async function POST(
return NextResponse.json({ error: 'Plano no encontrado' }, { status: 404 });
}
const personajes = await getPersonajes(slug);
const assetsMap = await getCharacterAssetsMap(slug, personajes);
const personajes = getPersonajes(slug);
// Generate prompt for this single plano
const { generateImagePrompts } = await import('@/lib/llm-service');
const singleEscaleta = { planos: [targetPlano] } as any;
const result = await generateImagePrompts(singleEscaleta, personajes);
@@ -309,28 +184,19 @@ export async function POST(
return NextResponse.json({ error: 'No se generó prompt' }, { status: 500 });
}
// Enrich
const enrichedPlano = enrichPlano(
{ ...targetPlano, ...planoConPrompt, personajesDetalle: targetPlano.personajesDetalle || planoConPrompt.personajesDetalle },
estiloVisual,
assetsMap,
fondos
);
// Update prompts-planos.json: merge into existing or create
const promptsPlanosPath = path.join(capPath, 'prompts-planos.json');
let existingPlanos: any[] = [];
try {
existingPlanos = JSON.parse(await fs.readFile(promptsPlanosPath, 'utf-8'));
} catch { /* first time */ }
const existingPlanos = getPromptsPlanos(slug, num);
const existingIdx = existingPlanos.findIndex((p: any) => p.id === planoId);
if (existingIdx >= 0) {
existingPlanos[existingIdx] = enrichedPlano;
} else {
existingPlanos.push(enrichedPlano);
}
await fs.writeFile(promptsPlanosPath, JSON.stringify(existingPlanos, null, 2));
savePromptsPlanos(slug, num, existingPlanos);
return NextResponse.json({ plano: enrichedPlano });
}
@@ -365,7 +231,6 @@ function generatePromptsMarkdown(planos: any[]): string {
md += `> **Prompt final:** ${plano.promptImagen}\n\n`;
}
// Show composition sub-fields
const comp = plano.promptComposition;
if (comp) {
md += `**Composición:**\n`;
@@ -376,22 +241,6 @@ function generatePromptsMarkdown(planos: any[]): string {
md += `\n`;
}
// Reference assets
if (plano.referenceAssets?.length) {
md += `**Assets de referencia:**\n`;
for (const ref of plano.referenceAssets) {
const parts: string[] = [];
if (ref.tposeFrontUrl) parts.push('Front');
if (ref.tposeSideUrl) parts.push('Side');
if (ref.tposeBackUrl) parts.push('Back');
if (ref.emotionUrl) parts.push(`Emoción: ${ref.emocion}`);
const hasTpose = ref.tposeFrontUrl || ref.tposeSideUrl || ref.tposeBackUrl;
const missing = !hasTpose ? ' ⚠️ sin T-pose' : (!ref.emotionUrl ? ` ⚠️ sin emoción ${ref.emocion}` : '');
md += `- **${ref.nombre}**: ${parts.join(' + ')}${missing}\n`;
}
md += `\n`;
}
if (plano.fondoId) {
md += `**Fondo ref:** ${plano.fondoId}\n\n`;
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
return dirs.find(d =>
d.isDirectory() && d.name.startsWith(numero.padStart(2, '0'))
);
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string; planoId: string }> }
) {
try {
const { slug, numero, planoId } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const videoPath = path.join(PROJECTS_DIR, slug, 'capitulos', capDir.name, 'videos', `${planoId}.mp4`);
try {
const videoBuffer = await fs.readFile(videoPath);
return new NextResponse(new Uint8Array(videoBuffer), {
headers: {
'Content-Type': 'video/mp4',
'Cache-Control': 'no-store, must-revalidate',
'Access-Control-Allow-Origin': 'http://localhost:3001',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
} catch (err) {
return NextResponse.json(
{ error: 'Video no encontrado' },
{
status: 404,
headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001',
},
}
);
}
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
// Handle OPTIONS request for CORS preflight
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}

View File

@@ -0,0 +1,293 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateVideo, downloadVideo } from '@/lib/video-service';
import {
getPromptsPlanos, updatePromptPlano, getCapDirName,
getVideoVariations, saveVideoVariation, selectVideoVariation, deleteVideoVariation,
} from '@/lib/db-service';
import fs from 'fs/promises';
import path from 'path';
import { randomUUID } from 'crypto';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
/**
* GET: Retrieve videos data with variations
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
const num = parseInt(numero);
const planos = getPromptsPlanos(slug, num);
// Load variations for each plano
const planosWithVariations = planos.map(p => {
const variations = getVideoVariations(slug, num, p.id);
const selectedVariation = variations.find(v => v.isSelected);
return {
...p,
videoVariations: variations,
videoUrl: selectedVariation?.videoUrl || p.videoUrl,
promptVideo: selectedVariation?.promptVideo || p.promptVideo,
};
});
const generated = planosWithVariations.filter(p => p.videoVariations && p.videoVariations.length > 0).length;
const withPrompt = planos.filter(p => p.promptVideo).length;
return NextResponse.json({
planos: planosWithVariations,
total: planos.length,
generated,
withPrompt,
});
} catch (error: any) {
console.error('Error fetching videos:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* POST: Generate video for a plano, save video prompt
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
const num = parseInt(numero);
const capDirName = getCapDirName(slug, num);
if (!capDirName) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
}
const body = await request.json();
const { action, planoId, promptVideo } = body;
const planos = getPromptsPlanos(slug, num);
const plano = planos.find(p => p.id === planoId);
if (!plano) {
return NextResponse.json({ error: 'Plano no encontrado' }, { status: 404 });
}
// Action: save-prompt (save video prompt without generating)
if (action === 'save-prompt') {
if (!promptVideo) {
return NextResponse.json({ error: 'Se necesita promptVideo' }, { status: 400 });
}
updatePromptPlano(slug, num, planoId, { promptVideo });
return NextResponse.json({ plano: { ...plano, promptVideo } });
}
// Action: generate (generate new video variation)
if (action === 'generate') {
if (!plano.imagenUrl) {
return NextResponse.json({ error: 'El plano no tiene imagen generada' }, { status: 400 });
}
// Use custom prompt if provided, otherwise compose from plano data
const videoPrompt = promptVideo || plano.promptVideo || composeVideoPrompt(plano);
// Read image from disk and convert to base64 data URL
// This is needed because localhost URLs are not accessible by Seedance API
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const imagePath = path.join(capitulosDir, capDirName, 'imagenes', `${planoId}.png`);
let imageDataUrl: string;
try {
const imageBuffer = await fs.readFile(imagePath);
const base64Image = imageBuffer.toString('base64');
imageDataUrl = `data:image/png;base64,${base64Image}`;
console.log(`[Videos API] Image loaded from disk: ${imagePath.length} chars (base64)`);
} catch (err) {
return NextResponse.json({ error: 'No se pudo leer la imagen del plano' }, { status: 500 });
}
console.log(`[Videos API] Generating video for plano ${planoId} with prompt: "${videoPrompt}"`);
// Generate video using Seedance 1.5 Pro with base64 image
// generateAudio=true with prompt instruction for natural sounds only (no music)
const result = await generateVideo(
imageDataUrl,
videoPrompt,
plano.tiempoSegundos || 5,
false, // cameraFixed
true // generateAudio (natural sounds only via prompt)
);
console.log(`[Videos API] Video generated in ${result.generationTimeSeconds}s, cost: $${result.costUsd}`);
// Download video and save to disk
const videoBuffer = await downloadVideo(result.videoUrl);
const videosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'videos');
await fs.mkdir(videosDir, { recursive: true });
// Generate unique filename for this variation
const variationId = randomUUID();
const videoFileName = `${planoId}-${variationId}.mp4`;
const videoPath = path.join(videosDir, videoFileName);
await fs.writeFile(videoPath, videoBuffer);
console.log(`[Videos API] Video saved to: ${videoPath}`);
// Get existing variations to determine if this should be selected
const existingVariations = getVideoVariations(slug, num, planoId);
const isFirstVariation = existingVariations.length === 0;
// Save variation to DB
const videoUrl = `/api/projects/${slug}/capitulos/${capDirName}/videos/${videoFileName.replace('.mp4', '')}`;
saveVideoVariation(slug, num, {
id: variationId,
planoId,
videoUrl,
videoPath,
promptVideo: result.fullPrompt,
generationTimeSeconds: result.generationTimeSeconds,
costUsd: result.costUsd,
isSelected: isFirstVariation,
createdAt: new Date().toISOString(),
});
// Update legacy fields if this is the first variation
if (isFirstVariation) {
updatePromptPlano(slug, num, planoId, { videoUrl, promptVideo: result.fullPrompt });
}
// Return updated variations
const variations = getVideoVariations(slug, num, planoId);
const selectedVariation = variations.find(v => v.isSelected);
return NextResponse.json({
plano: {
...plano,
videoVariations: variations,
videoUrl: selectedVariation?.videoUrl,
promptVideo: selectedVariation?.promptVideo,
},
});
}
// Action: select-variation (select a specific variation)
if (action === 'select-variation') {
const { variationId } = body;
if (!variationId) {
return NextResponse.json({ error: 'Se necesita variationId' }, { status: 400 });
}
selectVideoVariation(slug, num, planoId, variationId);
// Update legacy fields to match selected variation
const variations = getVideoVariations(slug, num, planoId);
const selectedVariation = variations.find(v => v.isSelected);
if (selectedVariation) {
updatePromptPlano(slug, num, planoId, {
videoUrl: selectedVariation.videoUrl,
promptVideo: selectedVariation.promptVideo,
});
}
return NextResponse.json({
plano: {
...plano,
videoVariations: variations,
videoUrl: selectedVariation?.videoUrl,
promptVideo: selectedVariation?.promptVideo,
},
});
}
// Action: delete-variation
if (action === 'delete-variation') {
const { variationId } = body;
if (!variationId) {
return NextResponse.json({ error: 'Se necesita variationId' }, { status: 400 });
}
// Get variation to delete its file
const variations = getVideoVariations(slug, num, planoId);
const toDelete = variations.find(v => v.id === variationId);
if (toDelete?.videoPath) {
try {
await fs.unlink(toDelete.videoPath);
} catch (e) {
console.warn(`Could not delete video file: ${toDelete.videoPath}`);
}
}
deleteVideoVariation(variationId);
// Get remaining variations
const remainingVariations = getVideoVariations(slug, num, planoId);
// If deleted variation was selected and there are others, select the first one
if (toDelete?.isSelected && remainingVariations.length > 0) {
selectVideoVariation(slug, num, planoId, remainingVariations[0].id);
const updated = getVideoVariations(slug, num, planoId);
const selected = updated.find(v => v.isSelected);
if (selected) {
updatePromptPlano(slug, num, planoId, {
videoUrl: selected.videoUrl,
promptVideo: selected.promptVideo,
});
}
}
const finalVariations = getVideoVariations(slug, num, planoId);
const selectedVariation = finalVariations.find(v => v.isSelected);
return NextResponse.json({
plano: {
...plano,
videoVariations: finalVariations,
videoUrl: selectedVariation?.videoUrl,
promptVideo: selectedVariation?.promptVideo,
},
});
}
return NextResponse.json({ error: 'Acción no reconocida' }, { status: 400 });
} catch (error: any) {
console.error('Error in videos API:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* Compose video prompt from plano data (without technical parameters)
*/
function composeVideoPrompt(plano: any): string {
const parts: string[] = [];
// Main action description
parts.push(plano.accionDescripcion || 'Scene with characters');
// Add character names if available
if (plano.personajesDetalle && plano.personajesDetalle.length > 0) {
const names = plano.personajesDetalle.map((p: any) => p.nombre).join(' and ');
parts.push(`Characters: ${names}.`);
}
// Add sound indications as movement hints
if (plano.indicacionesSonido) {
parts.push(plano.indicacionesSonido);
}
// Add audio instruction for natural sounds only
parts.push('Natural ambient sounds and action sounds only, no background music.');
return parts.join(' ');
}
/**
* Compose full video prompt with technical parameters
*/
function composeFullVideoPrompt(plano: any): string {
const basePrompt = composeVideoPrompt(plano);
const duration = plano.tiempoSegundos || 5;
const cameraFixed = false;
return `${basePrompt} --duration ${duration} --camerafixed ${cameraFixed}`;
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCapitulos, createCapitulo, getIdeaMotriz, getPersonajes } from '@/lib/file-service';
import { getCapitulos, createCapitulo, getIdeaMotriz, getPersonajes } from '@/lib/db-service';
export async function GET(
request: NextRequest,

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getIdeaMotriz, saveIdeaMotriz } from '@/lib/file-service';
import { getIdeaMotriz, saveIdeaMotriz } from '@/lib/db-service';
export async function GET(
request: NextRequest,

View File

@@ -1,54 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getPersonajes } from '@/lib/file-service';
import { getPersonajes, getCharacterAssetsData, saveCharacterAssetsData, getIdeaMotriz, getVariationTemplates } from '@/lib/db-service';
import { generateCharacterAssetPrompts } from '@/lib/llm-service';
import { generateImage, generateVariation, saveImage, loadImage } from '@/lib/image-service';
import type { CharacterAsset, CharacterAssetsData } from '@/types/project';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
function getAssetsPath(slug: string, personajeId: string): string {
return path.join(PROJECTS_DIR, slug, 'personajes', personajeId, 'assets.json');
}
function ensureBaseFlag(data: CharacterAssetsData): boolean {
if (data.assets.some(a => a.isBase)) return false;
const base = data.assets.find(a => a.type === 'tpose' && a.variant === 'front');
if (base) {
base.isBase = true;
return true;
}
return false;
}
async function getData(slug: string, personajeId: string): Promise<CharacterAssetsData> {
try {
const content = await fs.readFile(getAssetsPath(slug, personajeId), 'utf-8');
const parsed = JSON.parse(content);
// Migrate old format (plain array) to new format
let data: CharacterAssetsData;
if (Array.isArray(parsed)) {
data = { assets: parsed, baseLocked: false };
} else {
data = parsed;
}
// Ensure isBase flag is set; persist fix if data was modified
const modified = ensureBaseFlag(data);
if (modified) {
await saveData(slug, personajeId, data);
}
return data;
} catch {
return { assets: [], baseLocked: false };
}
}
async function saveData(slug: string, personajeId: string, data: CharacterAssetsData): Promise<void> {
const assetsDir = path.join(PROJECTS_DIR, slug, 'personajes', personajeId);
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(getAssetsPath(slug, personajeId), JSON.stringify(data, null, 2));
}
import { resolveEstilo, withEstilo } from '@/lib/prompt-utils';
import type { CharacterAssetsData } from '@/types/project';
export async function GET(
request: NextRequest,
@@ -56,7 +11,7 @@ export async function GET(
) {
try {
const { slug, id } = await params;
const data = await getData(slug, id);
const data = getCharacterAssetsData(slug, id);
return NextResponse.json(data);
} catch (error: any) {
console.error('Error fetching assets:', error);
@@ -70,29 +25,47 @@ export async function POST(
) {
try {
const { slug, id } = await params;
const body = await request.json();
// Handle file upload (multipart/form-data)
const contentType = request.headers.get('content-type') || '';
let body: any;
let uploadedFile: Buffer | null = null;
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData();
body = { action: formData.get('action') as string };
const file = formData.get('file') as File | null;
if (file) {
uploadedFile = Buffer.from(await file.arrayBuffer());
}
} else {
body = await request.json();
}
const { action } = body;
// 1. Generate text prompts via LLM
if (action === 'generate-prompts') {
const personajes = await getPersonajes(slug);
const personajes = getPersonajes(slug);
const personaje = personajes.find(p => p.id === id);
if (!personaje) {
return NextResponse.json({ error: 'Personaje no encontrado' }, { status: 404 });
}
const assets = await generateCharacterAssetPrompts(personaje);
// Mark first tpose-front as the base
const base = assets.find(a => a.type === 'tpose' && a.variant === 'front');
if (base) base.isBase = true;
const data: CharacterAssetsData = { assets, baseLocked: false };
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
return NextResponse.json(data);
}
// Resolve estilo if requested
const estilo = body.useEstiloProyecto ? resolveEstilo(getIdeaMotriz(slug)) : '';
// 2. Generate base image (text-to-image)
if (action === 'generate-base-image') {
const { prompt: customPrompt } = body;
const data = await getData(slug, id);
const data = getCharacterAssetsData(slug, id);
let baseAsset = data.assets.find(a => a.isBase);
if (!baseAsset) {
baseAsset = data.assets.find(a => a.type === 'tpose' && a.variant === 'front');
@@ -102,27 +75,56 @@ export async function POST(
}
const prompt = customPrompt || baseAsset.prompt;
baseAsset.status = 'generating';
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
const imageBuffer = await generateImage(prompt);
const imageBuffer = await generateImage(withEstilo(prompt, estilo));
const imageUrl = await saveImage(slug, id, baseAsset.id, imageBuffer);
baseAsset.imageUrl = imageUrl;
baseAsset.prompt = prompt;
baseAsset.status = 'generated';
data.baseLocked = false;
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
return NextResponse.json(data);
}
// 2b. Upload a custom image as base
if (action === 'upload-base-image') {
if (!uploadedFile) {
return NextResponse.json({ error: 'No se proporcionó archivo' }, { status: 400 });
}
const data = getCharacterAssetsData(slug, id);
let baseAsset = data.assets.find(a => a.isBase);
if (!baseAsset) {
baseAsset = data.assets.find(a => a.type === 'tpose' && a.variant === 'front');
}
if (!baseAsset) {
return NextResponse.json({ error: 'No hay asset base definido. Genera los prompts primero.' }, { status: 400 });
}
const imageUrl = await saveImage(slug, id, baseAsset.id, uploadedFile);
baseAsset.imageUrl = imageUrl;
baseAsset.status = 'generated';
data.baseLocked = false;
saveCharacterAssetsData(slug, id, data);
return NextResponse.json(data);
}
// 3. Lock the base image as canonical reference
if (action === 'lock-base') {
const data = await getData(slug, id);
const data = getCharacterAssetsData(slug, id);
const baseAsset = data.assets.find(a => a.isBase);
if (!baseAsset || baseAsset.status !== 'generated') {
return NextResponse.json({ error: 'Primero genera la imagen base.' }, { status: 400 });
}
data.baseLocked = true;
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
return NextResponse.json(data);
}
// 3b. Unlock base to allow regeneration
if (action === 'unlock-base') {
const data = getCharacterAssetsData(slug, id);
data.baseLocked = false;
saveCharacterAssetsData(slug, id, data);
return NextResponse.json(data);
}
@@ -132,7 +134,7 @@ export async function POST(
if (!assetId) {
return NextResponse.json({ error: 'Se necesita assetId' }, { status: 400 });
}
const data = await getData(slug, id);
const data = getCharacterAssetsData(slug, id);
if (!data.baseLocked) {
return NextResponse.json({ error: 'Primero fija la imagen base.' }, { status: 400 });
}
@@ -146,19 +148,23 @@ export async function POST(
return NextResponse.json({ error: 'Imagen base no encontrada en disco' }, { status: 400 });
}
targetAsset.status = 'generating';
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
const imageBuffer = await generateVariation(refImage, targetAsset.prompt);
const templates = getVariationTemplates(slug);
const templateKey = `${targetAsset.type}-${targetAsset.variant}`;
const variationPrompt = templates[templateKey] || targetAsset.prompt;
const imageBuffer = await generateVariation(refImage, withEstilo(variationPrompt, estilo));
const imageUrl = await saveImage(slug, id, targetAsset.id, imageBuffer);
targetAsset.imageUrl = imageUrl;
targetAsset.status = 'generated';
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
return NextResponse.json(data);
}
// 5. Generate all remaining variations
if (action === 'generate-all-variations') {
const data = await getData(slug, id);
const data = getCharacterAssetsData(slug, id);
if (!data.baseLocked) {
return NextResponse.json({ error: 'Primero fija la imagen base.' }, { status: 400 });
}
@@ -171,20 +177,23 @@ export async function POST(
return NextResponse.json({ error: 'Imagen base no encontrada en disco' }, { status: 400 });
}
const templates = getVariationTemplates(slug);
const pending = data.assets.filter(a => !a.isBase && a.status !== 'generated');
for (const asset of pending) {
try {
asset.status = 'generating';
await saveData(slug, id, data);
const imageBuffer = await generateVariation(refImage, asset.prompt);
saveCharacterAssetsData(slug, id, data);
const templateKey = `${asset.type}-${asset.variant}`;
const variationPrompt = templates[templateKey] || asset.prompt;
const imageBuffer = await generateVariation(refImage, withEstilo(variationPrompt, estilo));
const imageUrl = await saveImage(slug, id, asset.id, imageBuffer);
asset.imageUrl = imageUrl;
asset.status = 'generated';
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
} catch (err: any) {
console.error(`Error generando variación ${asset.id}:`, err);
asset.status = 'prompt_ready';
await saveData(slug, id, data);
saveCharacterAssetsData(slug, id, data);
}
}
return NextResponse.json(data);

View File

@@ -16,7 +16,7 @@ export async function GET(
return new NextResponse(new Uint8Array(imageBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-store, must-revalidate',
},
});
} catch (error: any) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPersonajes, getCharacterAssetsMap } from '@/lib/file-service';
import { getPersonajes, getCharacterAssetsMap } from '@/lib/db-service';
export async function GET(
_request: NextRequest,

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPersonajes, savePersonajes, getIdeaMotriz } from '@/lib/file-service';
import { getPersonajes, savePersonajes, getIdeaMotriz } from '@/lib/db-service';
export async function GET(
request: NextRequest,

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getProject, updateProject, deleteProject } from '@/lib/file-service';
import { getProject, updateProject, deleteProject } from '@/lib/db-service';
export async function GET(
request: NextRequest,

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getVariationTemplates, saveVariationTemplates } from '@/lib/db-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const templates = getVariationTemplates(slug);
return NextResponse.json(templates);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const { templates } = await request.json();
saveVariationTemplates(slug, templates);
const updated = getVariationTemplates(slug);
return NextResponse.json(updated);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { listProjects, createProject } from '@/lib/file-service';
import { listProjects, createProject } from '@/lib/db-service';
export async function GET() {
try {

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPromptsPlanos, getVideoVariations } from '@/lib/db-service';
interface PlanoVideoData {
id: string;
numeroPlano: string;
tiempoSegundos: number;
videoPath: string;
accionDescripcion: string;
}
interface ChapterVideoData {
projectSlug: string;
capituloNumero: number;
titulo: string;
duracionTotalSegundos: number;
planos: PlanoVideoData[];
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
const capituloNumero = parseInt(numero);
console.log(`[Remotion API] Loading chapter data for ${slug} chapter ${capituloNumero}`);
const planos = getPromptsPlanos(slug, capituloNumero);
const planosWithVideos: PlanoVideoData[] = planos
.map(plano => {
const variations = getVideoVariations(slug, capituloNumero, plano.id);
const selected = variations.find(v => v.isSelected);
if (!selected?.videoPath) return null;
// Convert file path to HTTP URL for browser playback
// Format: /api/projects/{slug}/capitulos/{numero}/videos/{planoId}-{variationId}
const videoUrl = `http://localhost:3000/api/projects/${slug}/capitulos/${String(capituloNumero).padStart(2, '0')}/videos/${plano.id}-${selected.id}`;
return {
id: plano.id,
numeroPlano: plano.numeroPlano,
tiempoSegundos: plano.tiempoSegundos,
videoPath: videoUrl,
accionDescripcion: plano.accionDescripcion,
};
})
.filter(Boolean) as PlanoVideoData[];
const chapterData: ChapterVideoData = {
projectSlug: slug,
capituloNumero,
titulo: `Capítulo ${numero}`,
duracionTotalSegundos: planosWithVideos.reduce((sum, p) => sum + p.tiempoSegundos, 0),
planos: planosWithVideos,
};
console.log(`[Remotion API] Loaded ${chapterData.planos.length} planos with videos`);
// Return with CORS headers for Remotion Studio
return NextResponse.json(chapterData, {
headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
} catch (error: any) {
console.error('[Remotion API] Error loading chapter data:', error);
return NextResponse.json(
{ error: error.message },
{
status: 500,
headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001',
},
}
);
}
}
// Handle OPTIONS request for CORS preflight
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { renderChapterVideo, RenderProgress } from '@/lib/remotion-service';
// Simple in-memory queue (in production, use Redis or DB)
const renderQueue = new Map<string, RenderProgress>();
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { projectSlug, capituloNumero, ...options } = body;
if (!projectSlug || !capituloNumero) {
return NextResponse.json(
{ error: 'Missing projectSlug or capituloNumero' },
{ status: 400 }
);
}
const jobId = `${projectSlug}-${capituloNumero}-${Date.now()}`;
// Initialize job in queue
renderQueue.set(jobId, {
phase: 'loading',
progress: 0,
message: 'Iniciando...',
});
// Start render in background (non-blocking)
renderChapterVideo(
{ projectSlug, capituloNumero, ...options },
(progress) => {
renderQueue.set(jobId, progress);
}
).catch((error) => {
renderQueue.set(jobId, {
phase: 'error',
progress: 0,
message: error.message,
});
});
return NextResponse.json({ jobId });
} catch (error: any) {
console.error('Render API error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const jobId = searchParams.get('jobId');
if (!jobId) {
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
}
const progress = renderQueue.get(jobId);
if (!progress) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
}
return NextResponse.json(progress);
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useRef, useCallback, use } from 'react';
import Link from 'next/link';
import { ArrowLeft, Sparkles, FileText, List, Image, Video, ChevronRight, Edit3, Save, Eye, LayoutGrid, AlertTriangle, Palette, RefreshCw } from 'lucide-react';
import { ArrowLeft, Sparkles, FileText, List, Image, Video, ChevronRight, Edit3, Save, Eye, LayoutGrid, AlertTriangle, Palette, RefreshCw, Film } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Toast } from '@/components/ui/Toast';
@@ -12,10 +12,11 @@ import { PlanoCard } from '@/components/capitulo/PlanoCard';
import { PlanoAssetCard } from '@/components/capitulo/PlanoAssetCard';
import { FondoCard } from '@/components/capitulo/FondoCard';
import { GuionFieldsEditor } from '@/components/capitulo/GuionFieldsEditor';
import { VideoVariationsCard } from '@/components/capitulo/VideoVariationsCard';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { GuionConductor, Escaleta, PlanoEscaleta, PreEscaleta, PlanoConPrompt, FondoReferencia, PromptComposition } from '@/types/project';
import type { CharacterAssetsSummary } from '@/lib/file-service';
import type { GuionConductor, Escaleta, PlanoEscaleta, PreEscaleta, PlanoConPrompt, FondoReferencia, PromptComposition, PersonajeEnPlano } from '@/types/project';
import type { CharacterAssetsSummary } from '@/lib/db-service';
const CAPITULO_STEPS = [
{ id: 'guion', name: 'Guión Conductor', description: 'Estructura narrativa' },
@@ -46,13 +47,21 @@ export default function CapituloPage({
const [escaletaDraft, setEscaletaDraft] = useState('');
const [escaletaView, setEscaletaView] = useState<'cards' | 'markdown'>('cards');
const [assetsMap, setAssetsMap] = useState<Record<string, CharacterAssetsSummary>>({});
const [imagenesData, setImagenesData] = useState<{ planos: PlanoConPrompt[]; total: number; generated: number; withPrompt?: number } | null>(null);
const [personajesList, setPersonajesList] = useState<{ id: string; nombre: string }[]>([]);
const [imagenesData, setImagenesData] = useState<{ planos: PlanoConPrompt[]; total: number; generated: number; withPrompt?: number; estiloVisual?: string } | null>(null);
const [generatingPlano, setGeneratingPlano] = useState<string | null>(null);
const [generatingPromptSingle, setGeneratingPromptSingle] = useState<string | null>(null);
const [fondosData, setFondosData] = useState<{ fondos: FondoReferencia[]; total: number; generated: number } | null>(null);
const [generatingFondo, setGeneratingFondo] = useState<string | null>(null);
const [generatingAllFondos, setGeneratingAllFondos] = useState(false);
const [fondosVersion, setFondosVersion] = useState(0);
const [imgVersion, setImgVersion] = useState(() => Date.now());
const [videosData, setVideosData] = useState<{ planos: PlanoConPrompt[]; total: number; generated: number; withPrompt?: number } | null>(null);
const [generatingVideo, setGeneratingVideo] = useState<string | null>(null);
// Remotion render state
const [renderJobId, setRenderJobId] = useState<string | null>(null);
const [renderProgress, setRenderProgress] = useState<any>(null);
// Progress modal state
const [progress, setProgress] = useState<{
@@ -99,7 +108,7 @@ export default function CapituloPage({
const fetchData = async () => {
setLoading(true);
try {
const [guionRes, preEscaletaRes, escaletaRes, promptsRes, assetsRes, imagenesRes, fondosRes] = await Promise.all([
const [guionRes, preEscaletaRes, escaletaRes, promptsRes, assetsRes, imagenesRes, fondosRes, personajesRes, videosRes] = await Promise.all([
fetch(`/api/projects/${slug}/capitulos/${numero}/guion`),
fetch(`/api/projects/${slug}/capitulos/${numero}/pre-escaleta`),
fetch(`/api/projects/${slug}/capitulos/${numero}/escaleta`),
@@ -107,6 +116,8 @@ export default function CapituloPage({
fetch(`/api/projects/${slug}/personajes/assets-summary`),
fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`),
fetch(`/api/projects/${slug}/capitulos/${numero}/fondos`),
fetch(`/api/projects/${slug}/personajes`),
fetch(`/api/projects/${slug}/capitulos/${numero}/videos`),
]);
if (guionRes.ok) {
@@ -137,6 +148,15 @@ export default function CapituloPage({
const fondosDataRes = await fondosRes.json();
if (fondosDataRes) setFondosData(fondosDataRes);
}
if (personajesRes.ok) {
const pList = await personajesRes.json();
if (Array.isArray(pList)) setPersonajesList(pList.map((p: any) => ({ id: p.id, nombre: p.nombre })));
}
if (videosRes.ok) {
const videosDataRes = await videosRes.json();
if (videosDataRes) setVideosData(videosDataRes);
}
setImgVersion(Date.now());
} catch (error) {
console.error('Error fetching data:', error);
} finally {
@@ -394,7 +414,7 @@ export default function CapituloPage({
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error generando prompt', 'error'); closeProgress(); return; }
if (imagenesData) {
const updatedPlanos = imagenesData.planos.map(p => p.id === planoId ? data.plano : p);
const updatedPlanos = imagenesData.planos.map(p => p.id === planoId ? { ...p, ...data.plano } : p);
const withPrompt = updatedPlanos.filter((p: PlanoConPrompt) => p.promptImagen).length;
setImagenesData({ ...imagenesData, planos: updatedPlanos, withPrompt });
}
@@ -424,6 +444,7 @@ export default function CapituloPage({
const generated = updatedPlanos.filter((p: PlanoConPrompt) => p.imagenUrl).length;
setImagenesData({ ...imagenesData, planos: updatedPlanos, generated });
}
setImgVersion(Date.now());
addProgressLog(`Imagen generada para ${planoId}`, 'success');
finishProgress(1, 1);
showToast(`Imagen generada para plano ${planoId}`, 'success');
@@ -433,9 +454,11 @@ export default function CapituloPage({
} finally { setGeneratingPlano(null); }
};
const handleGenerateAllImages = async () => {
const handleGenerateAllImages = async (onlyPending = false) => {
if (!imagenesData) return;
const pending = imagenesData.planos.filter(p => p.promptImagen);
const pending = onlyPending
? imagenesData.planos.filter(p => p.promptImagen && !p.imagenUrl)
: imagenesData.planos.filter(p => p.promptImagen);
if (pending.length === 0) return;
startProgress('Generando Imágenes', pending.length, `Imagen 1/${pending.length}...`);
@@ -625,6 +648,229 @@ export default function CapituloPage({
}
};
const handleSavePersonajes = async (planoId: string, personajesDetalle: PersonajeEnPlano[]) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save-personajes', planoId, personajesDetalle }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error guardando personajes', 'error');
return;
}
if (imagenesData) {
const updatedPlanos = imagenesData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setImagenesData({ ...imagenesData, planos: updatedPlanos });
}
showToast('Personajes actualizados', 'success');
} catch (error) {
console.error('Error saving personajes:', error);
showToast('Error al guardar personajes', 'error');
}
};
const handleGeneratePlanoVideo = async (planoId: string, customPrompt?: string) => {
setGeneratingVideo(planoId);
startProgress('Generando Video', 0, `Generando video para plano ${planoId}...`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', planoId, promptVideo: customPrompt }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error generando video', 'error'); closeProgress(); return; }
if (videosData) {
const updatedPlanos = videosData.planos.map(p => p.id === planoId ? data.plano : p);
const generated = updatedPlanos.filter(p => p.videoVariations && p.videoVariations.length > 0).length;
setVideosData({ ...videosData, planos: updatedPlanos, generated });
}
addProgressLog(`Video generado para ${planoId}`, 'success');
finishProgress(1, 1);
showToast(`Video generado para plano ${planoId}`, 'success');
} catch (error: any) {
if (error.name !== 'AbortError') { showToast('Error al generar video', 'error'); }
closeProgress();
} finally { setGeneratingVideo(null); }
};
const handleGenerateAllVideos = async (onlyPending = false) => {
if (!videosData) return;
const pending = onlyPending
? videosData.planos.filter(p => p.imagenUrl && (!p.videoVariations || p.videoVariations.length === 0))
: videosData.planos.filter(p => p.imagenUrl);
if (pending.length === 0) return;
startProgress('Generando Videos', pending.length, `Video 1/${pending.length}...`);
let successCount = 0;
let errorCount = 0;
let localPlanos = [...videosData.planos];
for (let i = 0; i < pending.length; i++) {
if (abortRef.current?.signal.aborted) break;
const plano = pending[i];
updateProgress(i, pending.length, `Video ${i + 1}/${pending.length} — ${plano.numeroPlano || plano.id}`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', planoId: plano.id }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (res.ok) {
localPlanos = localPlanos.map(p => p.id === plano.id ? data.plano : p);
const generated = localPlanos.filter(p => p.videoVariations && p.videoVariations.length > 0).length;
setVideosData({ ...videosData, planos: localPlanos, generated });
addProgressLog(`${plano.numeroPlano || plano.id}`, 'success');
successCount++;
} else {
addProgressLog(`${plano.numeroPlano || plano.id}`, 'error', data.error);
errorCount++;
}
} catch (error: any) {
if (error.name === 'AbortError') break;
addProgressLog(`${plano.numeroPlano || plano.id}`, 'error', 'Error de conexión');
errorCount++;
}
}
finishProgress(pending.length, pending.length);
if (errorCount > 0) {
showToast(`${successCount} variaciones generadas, ${errorCount} con error`, 'error');
} else {
showToast(`${successCount} variaciones generadas correctamente`, 'success');
}
};
const handleSaveVideoPrompt = async (planoId: string, promptVideo: string) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save-prompt', planoId, promptVideo }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error guardando prompt de video', 'error');
return;
}
if (videosData) {
const updatedPlanos = videosData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setVideosData({ ...videosData, planos: updatedPlanos });
}
showToast('Prompt de video guardado', 'success');
} catch (error) {
console.error('Error saving video prompt:', error);
showToast('Error al guardar prompt de video', 'error');
}
};
const handleSelectVideoVariation = async (planoId: string, variationId: string) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'select-variation', planoId, variationId }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error seleccionando variación', 'error');
return;
}
if (videosData) {
const updatedPlanos = videosData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setVideosData({ ...videosData, planos: updatedPlanos });
}
showToast('Variación seleccionada', 'success');
} catch (error) {
console.error('Error selecting variation:', error);
showToast('Error al seleccionar variación', 'error');
}
};
const handleDeleteVideoVariation = async (planoId: string, variationId: string) => {
if (!confirm('¿Eliminar esta variación de video?')) return;
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete-variation', planoId, variationId }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error eliminando variación', 'error');
return;
}
if (videosData) {
const updatedPlanos = videosData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setVideosData({ ...videosData, planos: updatedPlanos });
}
showToast('Variación eliminada', 'success');
} catch (error) {
console.error('Error deleting variation:', error);
showToast('Error al eliminar variación', 'error');
}
};
const handleStartRender = async () => {
try {
const res = await fetch('/api/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectSlug: slug,
capituloNumero: parseInt(numero),
includeIntro: true,
transitionType: 'crossfade',
quality: 90,
}),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error iniciando render', 'error');
return;
}
const { jobId } = data;
setRenderJobId(jobId);
// Poll for progress
const pollInterval = setInterval(async () => {
const progressRes = await fetch(`/api/render?jobId=${jobId}`);
const progress = await progressRes.json();
setRenderProgress(progress);
if (progress.phase === 'complete') {
clearInterval(pollInterval);
setRenderJobId(null);
showToast(`Video renderizado: ${progress.outputPath}`, 'success');
} else if (progress.phase === 'error') {
clearInterval(pollInterval);
setRenderJobId(null);
showToast(`Error: ${progress.message}`, 'error');
}
}, 2000);
} catch (error) {
console.error('Error starting render:', error);
showToast('Error al iniciar renderizado', 'error');
}
};
// Build fondoId → imagenUrl lookup
const fondoImagenMap: Record<string, string> = {};
if (fondosData) {
@@ -1076,15 +1322,34 @@ export default function CapituloPage({
<Sparkles className="w-3.5 h-3.5" />
Regenerar prompts
</Button>
<Button
size="sm"
onClick={handleGenerateAllImages}
loading={generating}
disabled={!!generatingPlano || (imagenesData.withPrompt ?? 0) === 0}
>
<Image className="w-3.5 h-3.5" />
{imagenesData.generated === imagenesData.total && imagenesData.generated > 0 ? 'Regenerar todas' : 'Generar todas'}
</Button>
{(() => {
const pendingCount = imagenesData.planos.filter(p => p.promptImagen && !p.imagenUrl).length;
return (
<>
{pendingCount > 0 && (
<Button
size="sm"
onClick={() => handleGenerateAllImages(true)}
loading={generating}
disabled={!!generatingPlano}
>
<Image className="w-3.5 h-3.5" />
Generar pendientes ({pendingCount})
</Button>
)}
<Button
size="sm"
variant={pendingCount > 0 ? 'outline' : 'primary'}
onClick={() => handleGenerateAllImages(false)}
loading={generating}
disabled={!!generatingPlano || (imagenesData.withPrompt ?? 0) === 0}
>
<Image className="w-3.5 h-3.5" />
Regenerar todas
</Button>
</>
);
})()}
</div>
</div>
@@ -1138,32 +1403,40 @@ export default function CapituloPage({
{/* Plano cards with prompts + generate */}
<div className="space-y-4">
{imagenesData.planos.map((plano, i) => (
<PlanoAssetCard
key={plano.id}
plano={plano}
index={i}
slug={slug}
capNumero={numero}
fondoImagenUrl={plano.fondoId ? fondoImagenMap[plano.fondoId] : undefined}
onGeneratePrompt={handleGenerateSinglePrompt}
onGenerateImage={handleGeneratePlanoImage}
onSaveComposition={handleSaveComposition}
generatingPrompt={generatingPromptSingle}
generatingImage={generatingPlano}
/>
<div key={plano.id} id={`plano-${plano.id}`}>
<PlanoAssetCard
plano={plano}
index={i}
slug={slug}
capNumero={numero}
estiloVisual={imagenesData.estiloVisual || ''}
fondoImagenUrl={plano.fondoId ? fondoImagenMap[plano.fondoId] : undefined}
imgVersion={imgVersion}
personajes={personajesList}
onGeneratePrompt={handleGenerateSinglePrompt}
onGenerateImage={handleGeneratePlanoImage}
onSaveComposition={handleSaveComposition}
onSavePersonajes={handleSavePersonajes}
generatingPrompt={generatingPromptSingle}
generatingImage={generatingPlano}
/>
</div>
))}
</div>
{/* Fixed vertical plano navigator */}
<PlanoNavigator planos={imagenesData.planos} />
</>
)}
</div>
)}
{/* Step 3: Assets (galería de imágenes generadas) */}
{/* Step 3: Assets (imágenes y videos) */}
{currentStep === 3 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-stone-900">Assets</h2>
<p className="mt-1 text-stone-500 text-sm">Galería de imágenes generadas para el capítulo.</p>
<p className="mt-1 text-stone-500 text-sm">Imágenes y videos generados para el capítulo.</p>
</div>
{!imagenesData || imagenesData.generated === 0 ? (
@@ -1178,30 +1451,104 @@ export default function CapituloPage({
</div>
) : (
<>
{/* Stats header with bulk generation */}
<div className="bg-white rounded-lg border border-stone-200 px-5 py-3 flex items-center justify-between">
<span className="text-sm font-medium text-stone-700">
{imagenesData.generated}/{imagenesData.total} imágenes generadas
</span>
<div className="flex items-baseline gap-3">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wide">Progreso</span>
<span className="text-sm font-medium text-stone-700">
{imagenesData.generated}/{imagenesData.total} imágenes · {videosData?.generated || 0}/{imagenesData.generated} videos
{videosData && videosData.planos.length > 0 && (() => {
const totalTime = videosData.planos
.flatMap(p => p.videoVariations || [])
.reduce((sum, v) => sum + (v.generationTimeSeconds || 0), 0);
const totalCost = videosData.planos
.flatMap(p => p.videoVariations || [])
.reduce((sum, v) => sum + (v.costUsd || 0), 0);
return totalTime > 0 || totalCost > 0 ? (
<span className="text-stone-400 font-normal ml-2">
({totalTime > 0 && `${totalTime.toFixed(0)}s`}
{totalTime > 0 && totalCost > 0 && ' · '}
{totalCost > 0 && `$${totalCost.toFixed(2)}`})
</span>
) : null;
})()}
</span>
</div>
<div className="flex items-center gap-2">
{videosData && (() => {
const pendingCount = videosData.planos.filter(p => p.imagenUrl && (!p.videoVariations || p.videoVariations.length === 0)).length;
return (
<>
{pendingCount > 0 && (
<Button
size="sm"
onClick={() => handleGenerateAllVideos(true)}
loading={generating}
disabled={!!generatingVideo}
>
<Video className="w-3.5 h-3.5" />
Generar videos pendientes ({pendingCount})
</Button>
)}
<Button
size="sm"
variant={pendingCount > 0 ? 'outline' : 'primary'}
onClick={() => handleGenerateAllVideos(false)}
loading={generating}
disabled={!!generatingVideo || imagenesData.generated === 0}
>
<Video className="w-3.5 h-3.5" />
Generar variaciones en todos
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleStartRender}
disabled={!videosData || videosData.generated === 0 || !!renderJobId}
loading={renderProgress?.phase === 'rendering' || renderProgress?.phase === 'bundling'}
>
<Film className="w-3.5 h-3.5" />
Renderizar Video Final
</Button>
</>
);
})()}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{imagenesData.planos.filter(p => p.imagenUrl).map((plano, i) => (
<div key={plano.id} className="bg-white rounded-lg border border-stone-200 overflow-hidden hover:border-stone-300 transition-colors">
<img
src={plano.imagenUrl!}
alt={`Plano ${plano.numeroPlano}`}
className="w-full object-cover"
style={{ aspectRatio: '16/9' }}
/>
<div className="px-3 py-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-stone-700">{plano.numeroPlano}</span>
<span className="text-[10px] text-stone-400">{plano.tiempoSegundos}s</span>
</div>
<p className="text-[11px] text-stone-500 mt-0.5 line-clamp-2">{plano.accionDescripcion}</p>
</div>
{/* Render progress indicator */}
{renderProgress && renderProgress.phase !== 'complete' && (
<div className="bg-indigo-50 border border-indigo-200 rounded-lg px-5 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-indigo-900">{renderProgress.message}</span>
<span className="text-sm font-semibold text-indigo-700">{renderProgress.progress}%</span>
</div>
))}
<div className="w-full bg-indigo-100 rounded-full h-2 overflow-hidden">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${renderProgress.progress}%` }}
/>
</div>
</div>
)}
{/* Video variations gallery */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{imagenesData.planos.filter(p => p.imagenUrl).map((plano) => {
const videoPlano = videosData?.planos.find(vp => vp.id === plano.id);
const mergedPlano = { ...plano, videoVariations: videoPlano?.videoVariations };
return (
<VideoVariationsCard
key={plano.id}
plano={mergedPlano}
onGenerateVariation={handleGeneratePlanoVideo}
onSelectVariation={handleSelectVideoVariation}
onDeleteVariation={handleDeleteVideoVariation}
generatingVideo={generatingVideo}
/>
);
})}
</div>
</>
)}
@@ -1237,3 +1584,84 @@ export default function CapituloPage({
</div>
);
}
/** Fixed vertical sidebar for navigating between planos */
function PlanoNavigator({ planos }: { planos: PlanoConPrompt[] }) {
const [activeId, setActiveId] = useState<string | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id.replace('plano-', ''));
break;
}
}
},
{ rootMargin: '-80px 0px -60% 0px', threshold: 0.1 }
);
for (const p of planos) {
const el = document.getElementById(`plano-${p.id}`);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [planos]);
const scrollTo = (id: string) => {
const el = document.getElementById(`plano-${id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Group planos by segment prefix (e.g. "1.1" from "1.1.3")
const groups: { segment: string; items: PlanoConPrompt[] }[] = [];
for (const p of planos) {
const parts = (p.numeroPlano || '').split('.');
const seg = parts.length >= 2 ? parts.slice(0, 2).join('.') : parts[0] || '?';
const last = groups[groups.length - 1];
if (last && last.segment === seg) {
last.items.push(p);
} else {
groups.push({ segment: seg, items: [p] });
}
}
return (
<nav className="fixed right-3 top-1/2 -translate-y-1/2 z-20 flex flex-col items-end gap-0.5 max-h-[70vh] overflow-y-auto py-2 pr-1 scrollbar-thin">
{groups.map((g) => (
<div key={g.segment} className="flex flex-col items-end">
<span className="text-[8px] font-semibold text-stone-400 uppercase tracking-wider mr-1 mb-0.5 mt-1.5 first:mt-0">
{g.segment}
</span>
{g.items.map((p) => {
const isActive = activeId === p.id;
const hasImage = !!p.imagenUrl;
const hasPrompt = !!p.promptImagen;
const planoNum = (p.numeroPlano || '').split('.').pop() || '?';
return (
<button
key={p.id}
onClick={() => scrollTo(p.id)}
title={`${p.numeroPlano} — ${p.segmentoGuion || ''}`}
className={`
flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium transition-all
${isActive
? 'bg-indigo-100 text-indigo-700 scale-110'
: 'text-stone-400 hover:text-stone-600 hover:bg-stone-100'
}
`}
>
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
hasImage ? 'bg-emerald-400' : hasPrompt ? 'bg-indigo-400' : 'bg-stone-300'
}`}
/>
{planoNum}
</button>
);
})}
</div>
))}
</nav>
);
}

View File

@@ -62,6 +62,7 @@ export default function ProjectPage({ params }: { params: Promise<{ slug: string
const [editingPersonaje, setEditingPersonaje] = useState<Personaje | null>(null);
const [assetsPersonaje, setAssetsPersonaje] = useState<Personaje | null>(null);
const [editingIdeaMotriz, setEditingIdeaMotriz] = useState(false);
const [assetsVersion, setAssetsVersion] = useState(0);
const onMejorarPersonajes = async () => {
await handleMejorarPersonajes(personajeFeedback);
@@ -133,21 +134,19 @@ export default function ProjectPage({ params }: { params: Promise<{ slug: string
currentData={ideaMotriz}
onGenerate={handleGenerateIdeaMotriz}
onRefine={handleRefineIdeaMotriz}
onSave={handleSaveIdeaMotriz}
onEdit={() => setEditingIdeaMotriz(true)}
isLoading={generating}
>
{ideaMotriz && (
<>
<div className="flex justify-end mb-4">
<Button variant="outline" onClick={() => setEditingIdeaMotriz(true)}>
Editar campos
</Button>
</div>
<IdeaMotrizView data={ideaMotriz} />
{editingIdeaMotriz && (
<IdeaMotrizEditor
ideaMotriz={ideaMotriz}
onSave={(updated) => setIdeaMotriz(updated)}
onSave={(updated) => {
setIdeaMotriz(updated);
handleSaveIdeaMotriz(updated);
}}
onClose={() => setEditingIdeaMotriz(false)}
/>
)}
@@ -265,6 +264,7 @@ export default function ProjectPage({ params }: { params: Promise<{ slug: string
slug={slug}
onSelectPersonaje={(p) => setEditingPersonaje(p)}
onOpenAssets={(p) => setAssetsPersonaje(p)}
refreshKey={assetsVersion}
/>
{editingPersonaje && (
@@ -280,7 +280,10 @@ export default function ProjectPage({ params }: { params: Promise<{ slug: string
<CharacterAssetsPanel
personaje={assetsPersonaje}
slug={slug}
onClose={() => setAssetsPersonaje(null)}
onClose={() => {
setAssetsPersonaje(null);
setAssetsVersion(v => v + 1);
}}
/>
)}

View File

@@ -2,23 +2,37 @@
import { useState } from 'react';
import Link from 'next/link';
import { Clock, Image, AlertCircle, Sparkles, FileText, Save, ChevronDown, ChevronUp, Code } from 'lucide-react';
import { Clock, Image, AlertCircle, Sparkles, FileText, Save, ChevronDown, ChevronUp, Code, Edit3, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import type { PlanoConPrompt, ReferenceAsset, PromptComposition } from '@/types/project';
import { ensureString, composePrompt, withEstilo } from '@/lib/prompt-utils';
import type { PlanoConPrompt, ReferenceAsset, PromptComposition, PersonajeEnPlano, EmotionVariant } from '@/types/project';
interface PlanoAssetCardProps {
plano: PlanoConPrompt;
index: number;
slug: string;
capNumero: string;
estiloVisual: string;
fondoImagenUrl?: string;
imgVersion?: number;
personajes?: { id: string; nombre: string }[];
onGeneratePrompt: (planoId: string) => Promise<void>;
onGenerateImage: (planoId: string) => Promise<void>;
onSaveComposition: (planoId: string, composition: PromptComposition) => Promise<void>;
onSavePersonajes?: (planoId: string, personajesDetalle: PersonajeEnPlano[]) => Promise<void>;
generatingPrompt: string | null;
generatingImage: string | null;
}
const EMOTIONS: { value: EmotionVariant; label: string }[] = [
{ value: 'neutral', label: 'Neutral' },
{ value: 'alegria', label: 'Alegría' },
{ value: 'tristeza', label: 'Tristeza' },
{ value: 'sorpresa', label: 'Sorpresa' },
{ value: 'miedo', label: 'Miedo' },
{ value: 'enfado', label: 'Enfado' },
];
const COMPOSITION_FIELDS: { key: keyof PromptComposition; label: string; rows: number }[] = [
{ key: 'tipoPlano', label: 'Tipo de plano', rows: 1 },
{ key: 'camara', label: 'Cámara', rows: 1 },
@@ -26,32 +40,9 @@ const COMPOSITION_FIELDS: { key: keyof PromptComposition; label: string; rows: n
{ key: 'personajes', label: 'Personajes', rows: 2 },
{ key: 'accion', label: 'Acción', rows: 2 },
{ key: 'luz', label: 'Luz', rows: 1 },
{ key: 'estilo', label: 'Estilo', rows: 2 },
{ key: 'custom', label: 'Input personalizado', rows: 2 },
];
/** Ensure a composition field value is a string (handles legacy data with objects/arrays) */
function fieldToString(val: any): string {
if (typeof val === 'string') return val;
if (val == null) return '';
if (Array.isArray(val)) return val.map(v => typeof v === 'object' ? Object.values(v).filter(Boolean).join(', ') : String(v)).join('. ');
if (typeof val === 'object') return Object.values(val).filter(Boolean).join(', ');
return String(val);
}
function composePromptLocal(comp: PromptComposition): string {
const parts = [
comp.tipoPlano,
comp.camara,
comp.optica,
comp.personajes,
comp.accion,
comp.luz,
comp.estilo,
comp.custom,
].map(fieldToString).filter(Boolean);
return parts.join('. ') + '.';
}
/** Build the full Gemini instruction text (same logic as generateSceneImage in image-service.ts) */
function buildGeminiInstruction(promptImagen: string, hasRefs: boolean): string {
@@ -110,10 +101,11 @@ function buildGeminiRequestJson(
}
export function PlanoAssetCard({
plano, index, slug, capNumero, fondoImagenUrl,
onGeneratePrompt, onGenerateImage, onSaveComposition,
plano, index, slug, capNumero, estiloVisual, fondoImagenUrl, imgVersion,
personajes, onGeneratePrompt, onGenerateImage, onSaveComposition, onSavePersonajes,
generatingPrompt, generatingImage,
}: PlanoAssetCardProps) {
const v = imgVersion || 0;
const refs = plano.referenceAssets || [];
const hasPrompt = !!plano.promptImagen;
const hasImage = !!plano.imagenUrl;
@@ -127,6 +119,8 @@ export function PlanoAssetCard({
const [editComp, setEditComp] = useState<PromptComposition | null>(null);
const [saving, setSaving] = useState(false);
const [showJson, setShowJson] = useState(false);
const [editPersonajes, setEditPersonajes] = useState<PersonajeEnPlano[] | null>(null);
const [savingPersonajes, setSavingPersonajes] = useState(false);
const isEditing = editComp !== null;
const currentComp = editComp || plano.promptComposition;
@@ -136,19 +130,18 @@ export function PlanoAssetCard({
if (plano.promptComposition) {
const c = plano.promptComposition;
setEditComp({
personajes: fieldToString(c.personajes),
accion: fieldToString(c.accion),
luz: fieldToString(c.luz),
camara: fieldToString(c.camara),
optica: fieldToString(c.optica),
tipoPlano: fieldToString(c.tipoPlano),
estilo: fieldToString(c.estilo),
custom: fieldToString(c.custom),
personajes: ensureString(c.personajes),
accion: ensureString(c.accion),
luz: ensureString(c.luz),
camara: ensureString(c.camara),
optica: ensureString(c.optica),
tipoPlano: ensureString(c.tipoPlano),
custom: ensureString(c.custom),
});
} else {
setEditComp({
personajes: '', accion: '', luz: '', camara: '', optica: '',
tipoPlano: '', estilo: '', custom: '',
tipoPlano: '', custom: '',
});
}
setExpanded(true);
@@ -224,26 +217,125 @@ export function PlanoAssetCard({
) : (
<>
{/* Reference images row */}
{(refs.length > 0 || fondoImagenUrl) && (
{(refs.length > 0 || fondoImagenUrl || onSavePersonajes || editPersonajes) && (
<div className="px-4 py-3 border-b border-stone-100">
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wide">Referencias</span>
<div className="mt-2 flex flex-wrap gap-3">
{/* Fondo thumbnail */}
{fondoImagenUrl && (
<div className="text-center">
<img
src={fondoImagenUrl}
alt="Fondo"
className="w-20 h-12 rounded border border-indigo-200 object-cover bg-stone-50"
/>
<span className="text-[10px] text-indigo-500 mt-0.5 block font-medium">Fondo</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wide">Referencias</span>
{onSavePersonajes && !editPersonajes && (
<button
onClick={() => setEditPersonajes(plano.personajesDetalle || [])}
className="flex items-center gap-1 text-[10px] font-medium text-indigo-500 hover:text-indigo-700 transition-colors"
>
<Edit3 className="w-3 h-3" />
Editar
</button>
)}
{/* Character refs */}
{refs.map((ref) => (
<ReferenceGroup key={ref.personajeId} ref_={ref} />
))}
</div>
{/* Edit mode */}
{editPersonajes && (
<div className="mt-2 space-y-2">
{editPersonajes.map((pd, i) => (
<div key={pd.personajeId} className="flex items-center gap-2 bg-stone-50 rounded px-2.5 py-1.5">
<span className="text-xs font-medium text-stone-700 min-w-[80px]">{pd.nombre}</span>
<select
value={pd.emocion}
onChange={(e) => {
const updated = [...editPersonajes];
updated[i] = { ...updated[i], emocion: e.target.value as EmotionVariant };
setEditPersonajes(updated);
}}
className="text-xs border border-stone-200 rounded px-1.5 py-1 bg-white text-stone-700 focus:outline-none focus:ring-1 focus:ring-indigo-400"
>
{EMOTIONS.map(em => (
<option key={em.value} value={em.value}>{em.label}</option>
))}
</select>
<button
onClick={() => setEditPersonajes(editPersonajes.filter((_, j) => j !== i))}
className="ml-auto p-0.5 text-stone-400 hover:text-red-500 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
{/* Add character */}
{(() => {
const usedIds = new Set(editPersonajes.map(p => p.personajeId));
const available = (personajes || []).filter(p => !usedIds.has(p.id));
if (available.length === 0) {
return editPersonajes.length === 0 ? (
<p className="text-xs text-stone-400 italic py-1">No hay personajes definidos en el proyecto.</p>
) : null;
}
return (
<select
value=""
onChange={(e) => {
const sel = available.find(p => p.id === e.target.value);
if (sel) {
setEditPersonajes([...editPersonajes, { personajeId: sel.id, nombre: sel.nombre, emocion: 'neutral' }]);
}
}}
className="text-xs border border-dashed border-stone-300 rounded px-2 py-1.5 bg-white text-stone-500 w-full focus:outline-none focus:ring-1 focus:ring-indigo-400"
>
<option value="">+ Añadir personaje...</option>
{available.map(p => (
<option key={p.id} value={p.id}>{p.nombre}</option>
))}
</select>
);
})()}
{/* Save / Cancel */}
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
variant="primary"
onClick={async () => {
setSavingPersonajes(true);
try { await onSavePersonajes!(plano.id, editPersonajes); setEditPersonajes(null); }
finally { setSavingPersonajes(false); }
}}
loading={savingPersonajes}
>
<Save className="w-3 h-3" />
Guardar
</Button>
<button
onClick={() => setEditPersonajes(null)}
className="text-[10px] font-medium text-stone-400 hover:text-stone-600 transition-colors"
>
Cancelar
</button>
</div>
</div>
)}
{/* View mode */}
{!editPersonajes && (
<div className="mt-2 flex flex-wrap gap-3">
{/* Fondo thumbnail */}
{fondoImagenUrl && (
<div className="text-center">
<img
src={`${fondoImagenUrl}${fondoImagenUrl.includes('?') ? '&' : '?'}v=${v}`}
alt="Fondo"
className="w-20 h-12 rounded border border-indigo-200 object-cover bg-stone-50"
/>
<span className="text-[10px] text-indigo-500 mt-0.5 block font-medium">Fondo</span>
</div>
)}
{/* Character refs */}
{refs.map((ref) => (
<ReferenceGroup key={ref.personajeId} ref_={ref} v={v} />
))}
{refs.length === 0 && !fondoImagenUrl && (
<p className="text-xs text-stone-400 italic">Sin referencias de personajes</p>
)}
</div>
)}
</div>
)}
@@ -258,6 +350,18 @@ export function PlanoAssetCard({
Composición
</button>
<div className="flex items-center gap-1.5">
{!isEditing && (
<Button
size="sm"
variant="ghost"
onClick={() => onGeneratePrompt(plano.id)}
loading={isGeneratingPrompt}
disabled={isBusy}
>
<FileText className="w-3 h-3" />
Regenerar prompt
</Button>
)}
{!isEditing && hasComposition && (
<button
onClick={handleStartEdit}
@@ -297,14 +401,14 @@ export function PlanoAssetCard({
<label className="text-[10px] font-medium text-stone-500 block mb-0.5">{label}</label>
{isEditing ? (
<textarea
value={fieldToString(editComp![key])}
value={ensureString(editComp![key])}
onChange={(e) => handleFieldChange(key, e.target.value)}
rows={rows}
className="w-full px-2 py-1.5 text-xs border border-stone-200 rounded bg-white text-stone-700 focus:outline-none focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400 resize-y font-mono transition-colors"
/>
) : (
<p className="text-xs text-stone-600 bg-stone-50 rounded px-2 py-1.5 font-mono min-h-[28px]">
{fieldToString(currentComp[key]) || <span className="text-stone-300 italic">vacío</span>}
{ensureString(currentComp[key]) || <span className="text-stone-300 italic">vacío</span>}
</p>
)}
</div>
@@ -314,17 +418,20 @@ export function PlanoAssetCard({
{/* Final composed prompt (always visible) */}
<div>
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wide">Prompt final</span>
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wide">Prompt final (con estilo)</span>
<p className="mt-1 text-xs text-stone-600 leading-relaxed bg-stone-50 rounded p-2.5 font-mono">
{isEditing && editComp ? composePromptLocal(editComp) : plano.promptImagen}
{withEstilo(isEditing && editComp ? composePrompt(editComp) : (plano.promptImagen || ''), estiloVisual)}
</p>
</div>
{/* Full Gemini instruction (with reference wrapper) */}
{(() => {
const currentPrompt = isEditing && editComp ? composePromptLocal(editComp) : plano.promptImagen;
const currentPrompt = withEstilo(
isEditing && editComp ? composePrompt(editComp) : (plano.promptImagen || ''),
estiloVisual
);
const hasRefs = refs.length > 0 || !!fondoImagenUrl;
const fullInstruction = buildGeminiInstruction(currentPrompt || '', hasRefs);
const fullInstruction = buildGeminiInstruction(currentPrompt, hasRefs);
return hasRefs ? (
<div className="mt-2">
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wide">Instrucción enviada a Gemini</span>
@@ -350,7 +457,7 @@ export function PlanoAssetCard({
<pre className="text-[10px] text-stone-500 leading-relaxed bg-stone-900 text-stone-300 rounded p-3 font-mono overflow-x-auto max-h-80 overflow-y-auto">
{JSON.stringify(
buildGeminiRequestJson(
isEditing && editComp ? composePromptLocal(editComp) : (plano.promptImagen || ''),
withEstilo(isEditing && editComp ? composePrompt(editComp) : (plano.promptImagen || ''), estiloVisual),
refs,
fondoImagenUrl
),
@@ -380,7 +487,7 @@ export function PlanoAssetCard({
</Button>
</div>
<img
src={`${plano.imagenUrl}?v=${Date.now()}`}
src={`${plano.imagenUrl}?v=${v}`}
alt={`Plano ${plano.numeroPlano}`}
className="w-full rounded-lg border border-stone-200 object-cover"
style={{ aspectRatio: '16/9' }}
@@ -426,7 +533,8 @@ export function PlanoAssetCard({
);
}
function ReferenceGroup({ ref_ }: { ref_: ReferenceAsset }) {
function ReferenceGroup({ ref_, v }: { ref_: ReferenceAsset; v: number }) {
const bust = (url: string) => `${url}${url.includes('?') ? '&' : '?'}v=${v}`;
const tposes = [
{ url: ref_.tposeFrontUrl || (ref_ as any).tposeUrl, label: 'Front' },
{ url: ref_.tposeSideUrl, label: 'Side' },
@@ -440,7 +548,7 @@ function ReferenceGroup({ ref_ }: { ref_: ReferenceAsset }) {
tposes.map(({ url, label }) => (
<div key={label} className="text-center">
<img
src={url}
src={bust(url)}
alt={`${ref_.nombre} ${label}`}
className="w-12 h-12 rounded border border-stone-200 object-cover bg-stone-50"
/>
@@ -459,7 +567,7 @@ function ReferenceGroup({ ref_ }: { ref_: ReferenceAsset }) {
<div className="text-center">
{ref_.emotionUrl ? (
<img
src={ref_.emotionUrl}
src={bust(ref_.emotionUrl)}
alt={`${ref_.nombre} ${ref_.emocion}`}
className="w-12 h-12 rounded border border-stone-200 object-cover bg-stone-50"
/>

View File

@@ -3,7 +3,7 @@
import Link from 'next/link';
import { Clock, Volume2, AlertCircle } from 'lucide-react';
import type { PlanoEscaleta, PersonajeEnPlano } from '@/types/project';
import type { CharacterAssetsSummary } from '@/lib/file-service';
import type { CharacterAssetsSummary } from '@/lib/db-service';
const EMOTION_LABELS: Record<string, string> = {
alegria: 'Alegría',

View File

@@ -0,0 +1,265 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Video, Edit3, Save, Trash2, Check } from 'lucide-react';
import type { PlanoConPrompt, VideoVariation } from '@/types/project';
interface VideoVariationsCardProps {
plano: PlanoConPrompt;
onGenerateVariation: (planoId: string, customPrompt?: string) => void;
onSelectVariation: (planoId: string, variationId: string) => void;
onDeleteVariation: (planoId: string, variationId: string) => void;
generatingVideo: string | null;
}
export function VideoVariationsCard({
plano,
onGenerateVariation,
onSelectVariation,
onDeleteVariation,
generatingVideo,
}: VideoVariationsCardProps) {
const [editingPrompt, setEditingPrompt] = useState(false);
const [customPrompt, setCustomPrompt] = useState('');
const variations = plano.videoVariations || [];
const selectedVariation = variations.find(v => v.isSelected);
const hasVariations = variations.length > 0;
const isGenerating = generatingVideo === plano.id;
// Compose default prompt preview
const defaultPrompt = composePromptPreview(plano);
const handleGenerate = () => {
if (editingPrompt && customPrompt.trim()) {
onGenerateVariation(plano.id, customPrompt);
setEditingPrompt(false);
setCustomPrompt('');
} else {
onGenerateVariation(plano.id);
}
};
return (
<div className="bg-white rounded-lg border border-stone-200 overflow-hidden">
{/* Image thumbnail */}
<div className="relative">
<img
src={plano.imagenUrl!}
alt={`Plano ${plano.numeroPlano}`}
className="w-full object-cover"
style={{ aspectRatio: '16/9' }}
/>
{hasVariations && (
<div className="absolute top-2 right-2 bg-emerald-500 text-white text-[10px] font-semibold px-2 py-0.5 rounded">
{variations.length} VIDEO{variations.length > 1 ? 'S' : ''}
</div>
)}
</div>
{/* Info section */}
<div className="px-3 py-2 border-b border-stone-100">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-stone-700">{plano.numeroPlano}</span>
<span className="text-[10px] text-stone-400">{plano.tiempoSegundos}s</span>
</div>
<p className="text-[11px] text-stone-500 line-clamp-2 mt-1">{plano.accionDescripcion}</p>
</div>
{/* Prompt editor/preview */}
<div className="px-3 py-2 border-b border-stone-100">
{editingPrompt ? (
<div className="space-y-2">
<textarea
value={customPrompt || defaultPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
className="w-full text-xs border border-stone-200 rounded px-2 py-1.5 bg-white text-stone-700 focus:outline-none focus:ring-1 focus:ring-indigo-400 resize-y min-h-[60px]"
placeholder="Prompt personalizado..."
/>
<div className="flex gap-1.5">
<Button
size="sm"
onClick={handleGenerate}
loading={isGenerating}
disabled={!!generatingVideo}
className="flex-1"
>
<Video className="w-3 h-3" />
Generar
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingPrompt(false);
setCustomPrompt('');
}}
disabled={isGenerating}
>
Cancelar
</Button>
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-[10px] text-stone-400 bg-stone-50 rounded px-2 py-1.5 border border-stone-200">
<span className="font-semibold">Prompt: </span>
<span className="line-clamp-2">{selectedVariation?.promptVideo || defaultPrompt}</span>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => setEditingPrompt(true)}
disabled={!!generatingVideo}
className="flex-1"
>
<Edit3 className="w-3 h-3" />
Editar y generar
</Button>
{hasVariations && (
<Button
size="sm"
onClick={() => onGenerateVariation(plano.id)}
loading={isGenerating}
disabled={!!generatingVideo}
className="flex-1"
>
<Video className="w-3.5 h-3.5" />
Nueva variación
</Button>
)}
{!hasVariations && (
<Button
size="sm"
onClick={() => onGenerateVariation(plano.id)}
loading={isGenerating}
disabled={!!generatingVideo}
className="flex-1"
>
<Video className="w-3.5 h-3.5" />
Generar
</Button>
)}
</div>
</div>
)}
</div>
{/* Video variations gallery */}
{hasVariations && (
<div className="px-3 py-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-stone-500 uppercase tracking-wide">Variaciones</span>
{selectedVariation && (
<div className="text-[9px] text-stone-400">
{selectedVariation.generationTimeSeconds && (
<span className="mr-2">{selectedVariation.generationTimeSeconds.toFixed(1)}s</span>
)}
{selectedVariation.costUsd !== undefined && (
<span>${selectedVariation.costUsd.toFixed(3)}</span>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{variations.map((variation) => {
const isSelected = variation.isSelected;
return (
<div
key={variation.id}
className={`relative rounded border-2 overflow-hidden transition-all ${
isSelected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-stone-200 hover:border-stone-300'
}`}
>
<video
src={variation.videoUrl}
className="w-full cursor-pointer"
style={{ aspectRatio: '16/9' }}
onClick={() => !isSelected && onSelectVariation(plano.id, variation.id)}
onMouseEnter={(e) => (e.currentTarget as HTMLVideoElement).play()}
onMouseLeave={(e) => {
const vid = e.currentTarget as HTMLVideoElement;
vid.pause();
vid.currentTime = 0;
}}
/>
{isSelected && (
<div className="absolute top-1 left-1 bg-indigo-500 text-white rounded-full p-0.5">
<Check className="w-3 h-3" />
</div>
)}
<button
onClick={() => onDeleteVariation(plano.id, variation.id)}
className="absolute top-1 right-1 bg-red-500 hover:bg-red-600 text-white rounded p-0.5 transition-colors"
title="Eliminar variación"
>
<Trash2 className="w-3 h-3" />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1">
<div className="flex items-center justify-between text-[8px] text-white">
{variation.generationTimeSeconds && (
<span>{variation.generationTimeSeconds.toFixed(1)}s</span>
)}
{variation.costUsd !== undefined && (
<span>${variation.costUsd.toFixed(3)}</span>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Selected video player */}
{selectedVariation && (
<div className="pt-2 border-t border-stone-100">
<video
src={selectedVariation.videoUrl}
controls
className="w-full rounded border border-stone-200"
style={{ aspectRatio: '16/9' }}
/>
<a
href={selectedVariation.videoUrl}
download={`${plano.numeroPlano || plano.id}.mp4`}
className="block mt-2"
>
<Button size="sm" variant="outline" className="w-full">
Descargar seleccionado
</Button>
</a>
</div>
)}
</div>
)}
</div>
);
}
/**
* Compose preview prompt from plano data with technical parameters
*/
function composePromptPreview(plano: PlanoConPrompt): string {
const parts: string[] = [];
parts.push(plano.accionDescripcion || 'Scene with characters');
if (plano.personajesDetalle && plano.personajesDetalle.length > 0) {
const names = plano.personajesDetalle.map((p) => p.nombre).join(' and ');
parts.push(`Characters: ${names}.`);
}
if (plano.indicacionesSonido) {
parts.push(plano.indicacionesSonido);
}
// Add audio instruction for natural sounds only
parts.push('Natural ambient sounds and action sounds only, no background music.');
const duration = plano.tiempoSegundos || 5;
const cameraFixed = false;
return `${parts.join(' ')} --duration ${duration} --camerafixed ${cameraFixed}`;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Sparkles, Copy, Check, X, Lock, Unlock, Play, RefreshCw, Loader2 } from 'lucide-react';
import { Sparkles, Copy, Check, X, Lock, Unlock, Play, RefreshCw, Loader2, Upload, ChevronDown, ChevronRight, Save, Eye } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Input';
import type { Personaje, CharacterAsset, CharacterAssetsData } from '@/types/project';
@@ -22,31 +22,68 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [basePromptEdit, setBasePromptEdit] = useState('');
const [imgVersion, setImgVersion] = useState(0);
const [imgVersion, setImgVersion] = useState(() => Date.now());
const [useEstiloProyecto, setUseEstiloProyecto] = useState(false);
const abortRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [templates, setTemplates] = useState<Record<string, string>>({});
const [editTemplates, setEditTemplates] = useState<Record<string, string>>({});
const [showTemplates, setShowTemplates] = useState(false);
const [savingTemplates, setSavingTemplates] = useState(false);
const [previewAssetId, setPreviewAssetId] = useState<string | null>(null);
const url = API_BASE(slug, personaje.id);
const templatesUrl = `/api/projects/${slug}/variation-templates`;
const fetchData = useCallback(async () => {
setFetching(true);
try {
const res = await fetch(url);
if (res.ok) {
const d = await res.json();
const [assetsRes, templatesRes] = await Promise.all([
fetch(url),
fetch(templatesUrl),
]);
if (assetsRes.ok) {
const d = await assetsRes.json();
setData(d);
setImgVersion(v => v + 1);
setImgVersion(Date.now());
const base = d.assets?.find((a: CharacterAsset) => a.isBase);
if (base) setBasePromptEdit(base.prompt || '');
}
if (templatesRes.ok) {
const t = await templatesRes.json();
setTemplates(t);
setEditTemplates(t);
}
} catch (e) {
console.error('Error fetching assets:', e);
} finally {
setFetching(false);
}
}, [url]);
}, [url, templatesUrl]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleSaveTemplates = async () => {
setSavingTemplates(true);
try {
const res = await fetch(templatesUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templates: editTemplates }),
});
if (res.ok) {
const t = await res.json();
setTemplates(t);
setEditTemplates(t);
setShowTemplates(false);
}
} catch (e) {
console.error('Error saving templates:', e);
} finally {
setSavingTemplates(false);
}
};
// Cleanup: abort batch generation on unmount
useEffect(() => {
return () => { abortRef.current = true; };
@@ -65,7 +102,7 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
const d = await res.json();
if (!res.ok) { setError(d.error || `Error ${res.status}`); return null; }
setData(d);
setImgVersion(v => v + 1);
setImgVersion(Date.now());
const base = d.assets?.find((a: CharacterAsset) => a.isBase);
if (base) setBasePromptEdit(base.prompt || '');
return d;
@@ -84,17 +121,48 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
for (const asset of pending) {
if (abortRef.current) break;
const result = await postAction('generate-variation', { assetId: asset.id });
const result = await postAction('generate-variation', { assetId: asset.id, useEstiloProyecto });
if (!result) break;
}
};
const handleUploadBase = async (file: File) => {
setLoading('upload-base-image');
setError(null);
try {
const formData = new FormData();
formData.append('action', 'upload-base-image');
formData.append('file', file);
const res = await fetch(url, { method: 'POST', body: formData });
const d = await res.json();
if (!res.ok) { setError(d.error || `Error ${res.status}`); return; }
setData(d);
setImgVersion(Date.now());
const base = d.assets?.find((a: CharacterAsset) => a.isBase);
if (base) setBasePromptEdit(base.prompt || '');
} catch (e: any) {
setError(e.message || 'Error de conexión');
} finally {
setLoading(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const copyPrompt = (asset: CharacterAsset) => {
navigator.clipboard.writeText(asset.prompt);
setCopiedId(asset.id);
setTimeout(() => setCopiedId(null), 2000);
};
const buildFullPrompt = (asset: CharacterAsset): string => {
const templateKey = `${asset.type}-${asset.variant}`;
const variationPrompt = templates[templateKey] || asset.prompt;
const withEstiloStr = useEstiloProyecto
? `[estiloVisual del proyecto]. ${variationPrompt}`
: variationPrompt;
return `Using the character shown in the reference image as the exact visual reference (same colors, proportions, features, accessories, and art style), generate the following:\n\n${withEstiloStr}`;
};
const { assets, baseLocked } = data;
const baseAsset = assets.find(a => a.isBase);
const otherAssets = assets.filter(a => !a.isBase);
@@ -110,7 +178,7 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
return (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl shadow-black/10 max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="bg-white rounded-lg shadow-xl shadow-black/10 max-w-4xl w-full max-h-[90vh] overflow-hidden relative">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-100">
<div className="flex items-center gap-3">
@@ -125,19 +193,30 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
</button>
</div>
{/* Stepper */}
{/* Estilo toggle + Stepper */}
{assets.length > 0 && (
<div className="px-5 py-2 bg-stone-50/50 border-b border-stone-100 flex items-center gap-6 text-[11px]">
{['Prompts', 'Imagen base', 'Fijar base', 'Variaciones'].map((label, i) => (
<div key={label} className={`flex items-center gap-1.5 ${
i === step ? 'text-indigo-600 font-semibold' : i < step ? 'text-emerald-600' : 'text-stone-300'
}`}>
<span className={`w-4 h-4 rounded-full text-[10px] flex items-center justify-center font-bold ${
i < step ? 'bg-emerald-100 text-emerald-600' : i === step ? 'bg-indigo-100 text-indigo-600' : 'bg-stone-100 text-stone-400'
}`}>{i < step ? '✓' : i + 1}</span>
{label}
</div>
))}
<div className="px-5 py-2 bg-stone-50/50 border-b border-stone-100 flex items-center justify-between text-[11px]">
<div className="flex items-center gap-6">
{['Prompts', 'Imagen base', 'Fijar base', 'Variaciones'].map((label, i) => (
<div key={label} className={`flex items-center gap-1.5 ${
i === step ? 'text-indigo-600 font-semibold' : i < step ? 'text-emerald-600' : 'text-stone-300'
}`}>
<span className={`w-4 h-4 rounded-full text-[10px] flex items-center justify-center font-bold ${
i < step ? 'bg-emerald-100 text-emerald-600' : i === step ? 'bg-indigo-100 text-indigo-600' : 'bg-stone-100 text-stone-400'
}`}>{i < step ? '✓' : i + 1}</span>
{label}
</div>
))}
</div>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={useEstiloProyecto}
onChange={(e) => setUseEstiloProyecto(e.target.checked)}
className="w-3.5 h-3.5 rounded border-stone-300 text-indigo-600 focus:ring-indigo-500/20"
/>
<span className="text-stone-500">Aplicar estilo del proyecto</span>
</label>
</div>
)}
@@ -215,15 +294,34 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
className="text-xs"
/>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUploadBase(file);
}}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => postAction('generate-base-image', { prompt: basePromptEdit })}
onClick={() => postAction('generate-base-image', { prompt: basePromptEdit, useEstiloProyecto })}
loading={loading === 'generate-base-image'}
>
{baseAsset?.imageUrl ? <RefreshCw className="w-3.5 h-3.5" /> : <Sparkles className="w-3.5 h-3.5" />}
{baseAsset?.imageUrl ? 'Regenerar' : 'Generar imagen base'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
loading={loading === 'upload-base-image'}
>
<Upload className="w-3.5 h-3.5" />
Subir imagen
</Button>
{baseAsset?.imageUrl && baseAsset.status === 'generated' && (
<Button
size="sm"
@@ -239,9 +337,20 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
</>
)}
{baseLocked && (
<p className="text-xs text-stone-500">
Imagen fijada como referencia. Las variaciones se generarán a partir de esta imagen.
</p>
<div className="space-y-2">
<p className="text-xs text-stone-500">
Imagen fijada como referencia. Las variaciones se generarán a partir de esta imagen.
</p>
<Button
size="sm"
variant="ghost"
onClick={() => postAction('unlock-base')}
loading={loading === 'unlock-base'}
>
<Unlock className="w-3.5 h-3.5" />
Desfijar para regenerar
</Button>
</div>
)}
</div>
</div>
@@ -252,17 +361,80 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
<>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-stone-700">Variaciones</span>
<Button
size="sm"
onClick={generateAllVariations}
loading={!!loading}
disabled={otherAssets.every(a => a.status === 'generated')}
>
<Play className="w-3.5 h-3.5" />
Generar todas las variaciones
</Button>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowTemplates(!showTemplates); setEditTemplates({ ...templates }); }}
className="flex items-center gap-1 text-[11px] text-stone-400 hover:text-indigo-600 transition-colors"
>
{showTemplates ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Plantillas
</button>
<Button
size="sm"
onClick={generateAllVariations}
loading={!!loading}
disabled={otherAssets.every(a => a.status === 'generated')}
>
<Play className="w-3.5 h-3.5" />
Generar todas las variaciones
</Button>
</div>
</div>
{/* Variation Templates Editor */}
{showTemplates && (
<div className="border border-indigo-200 rounded-lg p-4 bg-indigo-50/30 space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-xs font-semibold text-stone-700">Plantillas de variación</span>
<p className="text-[10px] text-stone-400 mt-0.5">Prompts compartidos para todos los personajes del proyecto</p>
</div>
<Button size="sm" variant="secondary" onClick={handleSaveTemplates} loading={savingTemplates}>
<Save className="w-3 h-3" />
Guardar
</Button>
</div>
<div>
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wider block mb-2">T-Pose</span>
<div className="space-y-2">
{['tpose-side', 'tpose-back'].map(key => (
<div key={key}>
<label className="text-[10px] font-medium text-stone-500 mb-0.5 block">
{key === 'tpose-side' ? 'Lateral' : 'Trasera'}
</label>
<Textarea
value={editTemplates[key] || ''}
onChange={(e) => setEditTemplates(prev => ({ ...prev, [key]: e.target.value }))}
rows={2}
className="text-xs"
/>
</div>
))}
</div>
</div>
<div>
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wider block mb-2">Emociones</span>
<div className="grid grid-cols-2 gap-2">
{['emotion-alegria', 'emotion-tristeza', 'emotion-sorpresa', 'emotion-miedo', 'emotion-enfado', 'emotion-neutral'].map(key => (
<div key={key}>
<label className="text-[10px] font-medium text-stone-500 mb-0.5 block capitalize">
{key.replace('emotion-', '')}
</label>
<Textarea
value={editTemplates[key] || ''}
onChange={(e) => setEditTemplates(prev => ({ ...prev, [key]: e.target.value }))}
rows={2}
className="text-xs"
/>
</div>
))}
</div>
</div>
</div>
)}
{/* Other T-Poses */}
{tposeOthers.length > 0 && (
<div>
@@ -275,9 +447,11 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
imgVersion={imgVersion}
copiedId={copiedId}
onCopy={copyPrompt}
onGenerate={() => postAction('generate-variation', { assetId: asset.id })}
onGenerate={() => postAction('generate-variation', { assetId: asset.id, useEstiloProyecto })}
generating={loading === asset.id}
canGenerate={baseLocked && !loading}
templatePrompt={templates[`${asset.type}-${asset.variant}`]}
onPreview={() => setPreviewAssetId(asset.id)}
/>
))}
</div>
@@ -296,9 +470,11 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
imgVersion={imgVersion}
copiedId={copiedId}
onCopy={copyPrompt}
onGenerate={() => postAction('generate-variation', { assetId: asset.id })}
onGenerate={() => postAction('generate-variation', { assetId: asset.id, useEstiloProyecto })}
generating={loading === asset.id}
canGenerate={baseLocked && !loading}
templatePrompt={templates[`${asset.type}-${asset.variant}`]}
onPreview={() => setPreviewAssetId(asset.id)}
/>
))}
</div>
@@ -322,6 +498,8 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
copiedId={copiedId}
onCopy={copyPrompt}
canGenerate={false}
templatePrompt={templates[`${asset.type}-${asset.variant}`]}
onPreview={() => setPreviewAssetId(asset.id)}
/>
))}
</div>
@@ -343,6 +521,47 @@ export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAsse
</div>
)}
</div>
{/* Prompt Preview Popup */}
{previewAssetId && (() => {
const asset = assets.find(a => a.id === previewAssetId);
if (!asset) return null;
const fullPrompt = buildFullPrompt(asset);
return (
<div className="absolute inset-0 bg-black/30 flex items-center justify-center z-10 p-8" onClick={() => setPreviewAssetId(null)}>
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full p-5 space-y-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-stone-700">Prompt literal — {asset.label}</span>
<button onClick={() => setPreviewAssetId(null)} className="p-1 text-stone-400 hover:text-stone-600">
<X className="w-3.5 h-3.5" />
</button>
</div>
{baseAsset?.imageUrl && (
<div className="flex items-center gap-3 p-2 bg-stone-50 border border-stone-200 rounded">
<img
src={`${baseAsset.imageUrl}?v=${imgVersion}`}
alt="Referencia"
className="w-16 h-16 rounded border border-stone-200 object-cover flex-shrink-0"
/>
<div>
<span className="text-[10px] font-medium text-stone-500">Imagen de referencia (inlineData)</span>
<p className="text-[9px] text-stone-400 mt-0.5">Esta imagen se envía junto con el prompt como referencia visual</p>
</div>
</div>
)}
<pre className="text-[11px] text-stone-600 bg-stone-50 border border-stone-200 rounded p-3 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto font-mono">{fullPrompt}</pre>
<div className="flex justify-end">
<button
onClick={() => { navigator.clipboard.writeText(fullPrompt); }}
className="text-[10px] text-indigo-500 hover:text-indigo-700 font-medium"
>
Copiar prompt
</button>
</div>
</div>
</div>
);
})()}
</div>
</div>
);
@@ -356,6 +575,8 @@ function AssetCard({
onGenerate,
generating,
canGenerate,
templatePrompt,
onPreview,
}: {
asset: CharacterAsset;
imgVersion: number;
@@ -364,8 +585,11 @@ function AssetCard({
onGenerate?: () => void;
generating?: boolean;
canGenerate?: boolean;
templatePrompt?: string;
onPreview?: () => void;
}) {
const isCopied = copiedId === asset.id;
const displayPrompt = templatePrompt || asset.prompt;
const statusColors: Record<string, string> = {
pending: 'bg-stone-300',
@@ -418,11 +642,20 @@ function AssetCard({
</div>
)}
{asset.prompt && (
{displayPrompt && (
<div className="flex gap-1">
<p className="text-[9px] text-stone-400 leading-tight flex-1 line-clamp-2">{asset.prompt}</p>
<p className="text-[9px] text-stone-400 leading-tight flex-1 line-clamp-2">{displayPrompt}</p>
{onPreview && (
<button
onClick={onPreview}
className="flex-shrink-0 p-0.5 text-stone-300 hover:text-indigo-500 transition-colors"
title="Ver prompt completo"
>
<Eye className="w-2.5 h-2.5" />
</button>
)}
<button
onClick={() => onCopy(asset)}
onClick={() => onCopy({ ...asset, prompt: displayPrompt })}
className="flex-shrink-0 p-0.5 text-stone-300 hover:text-indigo-500 transition-colors"
title="Copiar prompt"
>

View File

@@ -10,10 +10,12 @@ interface PersonajesViewProps {
onSelectPersonaje?: (personaje: Personaje) => void;
onOpenAssets?: (personaje: Personaje) => void;
selectedId?: string;
refreshKey?: number;
}
export function PersonajesView({ personajes, slug, onSelectPersonaje, onOpenAssets, selectedId }: PersonajesViewProps) {
export function PersonajesView({ personajes, slug, onSelectPersonaje, onOpenAssets, selectedId, refreshKey }: PersonajesViewProps) {
const [baseImages, setBaseImages] = useState<Record<string, string>>({});
const [imgVersion, setImgVersion] = useState(() => Date.now());
useEffect(() => {
let cancelled = false;
@@ -30,11 +32,14 @@ export function PersonajesView({ personajes, slug, onSelectPersonaje, onOpenAsse
} catch { /* ignore */ }
})
);
if (!cancelled) setBaseImages(images);
if (!cancelled) {
setBaseImages(images);
setImgVersion(Date.now());
}
}
if (personajes.length > 0) fetchBaseImages();
return () => { cancelled = true; };
}, [personajes, slug]);
}, [personajes, slug, refreshKey]);
const getTipoStyle = (tipo: string) => {
switch (tipo) {
@@ -62,7 +67,7 @@ export function PersonajesView({ personajes, slug, onSelectPersonaje, onOpenAsse
{baseImages[personaje.id] ? (
<div className="w-20 h-20 flex-shrink-0 rounded-lg border border-stone-200 overflow-hidden bg-stone-50">
<img
src={baseImages[personaje.id]}
src={`${baseImages[personaje.id]}${baseImages[personaje.id].includes('?') ? '&' : '?'}v=${imgVersion}`}
alt={personaje.nombre}
className="w-full h-full object-cover"
/>

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { Sparkles, RefreshCw, Save, Edit3 } from 'lucide-react';
import { Sparkles, RefreshCw, Edit3 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Input';
@@ -12,7 +12,7 @@ interface GenerationPanelProps {
currentData: any;
onGenerate: (input: string) => Promise<void>;
onRefine: (feedback: string) => Promise<void>;
onSave: (data: any) => Promise<void>;
onEdit?: () => void;
isLoading: boolean;
children: React.ReactNode;
}
@@ -24,7 +24,7 @@ export function GenerationPanel({
currentData,
onGenerate,
onRefine,
onSave,
onEdit,
isLoading,
children,
}: GenerationPanelProps) {
@@ -76,20 +76,16 @@ export function GenerationPanel({
<div className="bg-white rounded-lg border border-stone-200 overflow-hidden">
<div className="px-5 py-3 border-b border-stone-100 flex items-center justify-between">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wide">Resultado</span>
<div className="flex gap-1.5">
{onEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => setMode(mode === 'edit' ? 'refine' : 'edit')}
onClick={onEdit}
>
<Edit3 className="w-3.5 h-3.5" />
{mode === 'edit' ? 'Cerrar' : 'Editar'}
Editar
</Button>
<Button variant="outline" size="sm" onClick={() => onSave(currentData)} loading={isLoading}>
<Save className="w-3.5 h-3.5" />
Guardar
</Button>
</div>
)}
</div>
<div className="p-5">
{children}

View File

@@ -18,7 +18,7 @@ interface UseProjectDataReturn {
canAdvance: () => boolean;
handleGenerateIdeaMotriz: (input: string) => Promise<void>;
handleRefineIdeaMotriz: (feedback: string) => Promise<void>;
handleSaveIdeaMotriz: () => Promise<void>;
handleSaveIdeaMotriz: (data?: IdeaMotriz) => Promise<void>;
handleGeneratePersonajes: () => Promise<void>;
handleSavePersonajes: () => Promise<void>;
handleMejorarPersonajes: (feedback: string) => Promise<void>;
@@ -160,14 +160,15 @@ export function useProjectData(slug: string): UseProjectDataReturn {
}
};
const handleSaveIdeaMotriz = async () => {
if (!ideaMotriz) return;
const handleSaveIdeaMotriz = async (data?: IdeaMotriz) => {
const toSave = data || ideaMotriz;
if (!toSave) return;
setGenerating(true);
try {
await fetch(`/api/projects/${slug}/idea-motriz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save', input: ideaMotriz }),
body: JSON.stringify({ action: 'save', input: toSave }),
});
showToast('Idea Motriz guardada', 'success');
} catch (error) {

852
src/lib/db-service.ts Normal file
View File

@@ -0,0 +1,852 @@
import fs from 'fs';
import path from 'path';
import { getDb } from './db';
import { DEFAULT_VARIATION_TEMPLATES } from './prompts';
import type {
Project, IdeaMotriz, Personaje,
Capitulo, GuionConductor, ActoGuion,
PreEscaleta,
Escaleta, PlanoEscaleta,
PlanoConPrompt, PromptComposition, PersonajeEnPlano,
FondoReferencia,
CharacterAsset, CharacterAssetsData,
EmotionVariant,
VideoVariation,
} from '@/types/project';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
// =====================
// HELPERS
// =====================
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
function resolveProjectId(slug: string): string | null {
const row = getDb().prepare('SELECT id FROM projects WHERE slug = ?').get(slug) as any;
return row?.id || null;
}
function resolveCapituloId(slug: string, numero: number): string | null {
const row = getDb().prepare(`
SELECT c.id FROM capitulos c
JOIN projects p ON c.project_id = p.id
WHERE p.slug = ? AND c.numero = ?
`).get(slug, numero) as any;
return row?.id || null;
}
// =====================
// PROJECTS
// =====================
export function listProjects(): Project[] {
const rows = getDb().prepare(`
SELECT id, name, slug, status, current_step, created_at, updated_at
FROM projects ORDER BY updated_at DESC
`).all() as any[];
return rows.map(r => ({
id: r.id,
name: r.name,
slug: r.slug,
status: r.status,
currentStep: r.current_step,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
export function getProject(slug: string): Project | null {
const r = getDb().prepare(`
SELECT id, name, slug, status, current_step, created_at, updated_at
FROM projects WHERE slug = ?
`).get(slug) as any;
if (!r) return null;
return {
id: r.id,
name: r.name,
slug: r.slug,
status: r.status,
currentStep: r.current_step,
createdAt: r.created_at,
updatedAt: r.updated_at,
};
}
export function createProject(name: string): Project {
const slug = slugify(name);
const existing = getProject(slug);
if (existing) throw new Error(`El proyecto "${name}" ya existe`);
const now = new Date().toISOString();
const id = crypto.randomUUID();
getDb().prepare(`
INSERT INTO projects (id, name, slug, status, current_step, created_at, updated_at)
VALUES (?, ?, ?, 'draft', 'idea_motriz', ?, ?)
`).run(id, name, slug, now, now);
// Create directory for images
const projectDir = path.join(PROJECTS_DIR, slug);
fs.mkdirSync(path.join(projectDir, 'capitulos'), { recursive: true });
return { id, name, slug, status: 'draft', currentStep: 'idea_motriz', createdAt: now, updatedAt: now };
}
export function updateProject(slug: string, updates: Partial<Project>): Project {
const project = getProject(slug);
if (!project) throw new Error('Proyecto no encontrado');
const now = new Date().toISOString();
const db = getDb();
if (updates.name !== undefined) db.prepare('UPDATE projects SET name = ? WHERE slug = ?').run(updates.name, slug);
if (updates.status !== undefined) db.prepare('UPDATE projects SET status = ? WHERE slug = ?').run(updates.status, slug);
if (updates.currentStep !== undefined) db.prepare('UPDATE projects SET current_step = ? WHERE slug = ?').run(updates.currentStep, slug);
db.prepare('UPDATE projects SET updated_at = ? WHERE slug = ?').run(now, slug);
return getProject(slug)!;
}
export function deleteProject(slug: string): void {
getDb().prepare('DELETE FROM projects WHERE slug = ?').run(slug);
// Remove images from disk
const projectDir = path.join(PROJECTS_DIR, slug);
if (fs.existsSync(projectDir)) {
fs.rmSync(projectDir, { recursive: true, force: true });
}
}
// =====================
// IDEA MOTRIZ
// =====================
export function getIdeaMotriz(slug: string): IdeaMotriz | null {
const projectId = resolveProjectId(slug);
if (!projectId) return null;
const row = getDb().prepare('SELECT data FROM idea_motriz WHERE project_id = ?').get(projectId) as any;
if (!row) return null;
return JSON.parse(row.data) as IdeaMotriz;
}
export function saveIdeaMotriz(slug: string, ideaMotriz: IdeaMotriz): void {
const projectId = resolveProjectId(slug);
if (!projectId) throw new Error('Proyecto no encontrado');
const now = new Date().toISOString();
getDb().prepare(`
INSERT INTO idea_motriz (project_id, data, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(projectId, JSON.stringify(ideaMotriz), now);
getDb().prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(now, projectId);
}
// =====================
// PERSONAJES
// =====================
export function getPersonajes(slug: string): Personaje[] {
const projectId = resolveProjectId(slug);
if (!projectId) return [];
const rows = getDb().prepare('SELECT id, nombre, tipo, data FROM personajes WHERE project_id = ?').all(projectId) as any[];
return rows.map(r => ({
id: r.id,
nombre: r.nombre,
tipo: r.tipo,
...JSON.parse(r.data),
}));
}
export function savePersonajes(slug: string, personajes: Personaje[]): void {
const projectId = resolveProjectId(slug);
if (!projectId) throw new Error('Proyecto no encontrado');
const db = getDb();
const tx = db.transaction(() => {
const existingIds = db.prepare('SELECT id FROM personajes WHERE project_id = ?')
.all(projectId).map((r: any) => r.id);
const newIds = new Set(personajes.map(p => p.id));
// Delete removed personajes (CASCADE will remove their assets)
for (const id of existingIds) {
if (!newIds.has(id)) {
db.prepare('DELETE FROM personajes WHERE id = ?').run(id);
}
}
// Upsert each personaje
const upsert = db.prepare(`
INSERT INTO personajes (id, project_id, nombre, tipo, data)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET nombre = excluded.nombre, tipo = excluded.tipo, data = excluded.data
`);
for (const p of personajes) {
const { id, nombre, tipo, ...rest } = p;
upsert.run(id, projectId, nombre, tipo, JSON.stringify(rest));
}
});
tx();
const now = new Date().toISOString();
db.prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(now, projectId);
}
// =====================
// CHARACTER ASSETS
// =====================
export interface CharacterAssetsSummary {
hasAssets: boolean;
tposeFrontUrl?: string;
tposeSideUrl?: string;
tposeBackUrl?: string;
emotions: Partial<Record<EmotionVariant, string>>;
}
export function getCharacterAssetsData(slug: string, personajeId: string): CharacterAssetsData {
const projectId = resolveProjectId(slug);
if (!projectId) return { assets: [], baseLocked: false };
const db = getDb();
const rows = db.prepare(`
SELECT id, type, variant, label, prompt, image_url, is_base, status
FROM character_assets WHERE personaje_id = ? AND project_id = ?
`).all(personajeId, projectId) as any[];
const assets: CharacterAsset[] = rows.map(r => ({
id: r.id,
type: r.type,
variant: r.variant,
label: r.label,
prompt: r.prompt,
imageUrl: r.image_url || undefined,
isBase: r.is_base === 1,
status: r.status,
}));
const config = db.prepare('SELECT base_locked FROM character_assets_config WHERE personaje_id = ?')
.get(personajeId) as any;
const baseLocked = config?.base_locked === 1;
return { assets, baseLocked };
}
export function saveCharacterAssetsData(slug: string, personajeId: string, data: CharacterAssetsData): void {
const projectId = resolveProjectId(slug);
if (!projectId) throw new Error('Proyecto no encontrado');
const db = getDb();
const tx = db.transaction(() => {
// Delete existing assets for this personaje
db.prepare('DELETE FROM character_assets WHERE personaje_id = ? AND project_id = ?').run(personajeId, projectId);
// Insert new assets
const insert = db.prepare(`
INSERT INTO character_assets (id, personaje_id, project_id, type, variant, label, prompt, image_url, image_path, is_base, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of data.assets) {
const imagePath = a.imageUrl
? `personajes/${personajeId}/images/${a.id}.png`
: null;
insert.run(a.id, personajeId, projectId, a.type, a.variant, a.label, a.prompt, a.imageUrl || null, imagePath, a.isBase ? 1 : 0, a.status);
}
// Upsert baseLocked config
db.prepare(`
INSERT INTO character_assets_config (personaje_id, base_locked)
VALUES (?, ?)
ON CONFLICT(personaje_id) DO UPDATE SET base_locked = excluded.base_locked
`).run(personajeId, data.baseLocked ? 1 : 0);
});
tx();
}
// =====================
// VARIATION TEMPLATES
// =====================
export function getVariationTemplates(slug: string): Record<string, string> {
const projectId = resolveProjectId(slug);
if (!projectId) return { ...DEFAULT_VARIATION_TEMPLATES };
const db = getDb();
const rows = db.prepare(
'SELECT asset_key, prompt FROM variation_templates WHERE project_id = ?'
).all(projectId) as { asset_key: string; prompt: string }[];
const result: Record<string, string> = { ...DEFAULT_VARIATION_TEMPLATES };
for (const row of rows) {
result[row.asset_key] = row.prompt;
}
return result;
}
export function saveVariationTemplates(slug: string, templates: Record<string, string>): void {
const projectId = resolveProjectId(slug);
if (!projectId) throw new Error('Proyecto no encontrado');
const db = getDb();
const upsert = db.prepare(`
INSERT INTO variation_templates (project_id, asset_key, prompt)
VALUES (?, ?, ?)
ON CONFLICT(project_id, asset_key) DO UPDATE SET prompt = excluded.prompt
`);
const tx = db.transaction(() => {
for (const [key, prompt] of Object.entries(templates)) {
upsert.run(projectId, key, prompt);
}
});
tx();
}
export function getCharacterAssetsMap(
slug: string,
personajes: Personaje[]
): Record<string, CharacterAssetsSummary> {
const projectId = resolveProjectId(slug);
if (!projectId) {
const empty: Record<string, CharacterAssetsSummary> = {};
for (const p of personajes) empty[p.id] = { hasAssets: false, emotions: {} };
return empty;
}
const result: Record<string, CharacterAssetsSummary> = {};
const db = getDb();
for (const p of personajes) {
const rows = db.prepare(`
SELECT id, type, variant, image_url, is_base, status
FROM character_assets WHERE personaje_id = ? AND project_id = ?
`).all(p.id, projectId) as any[];
if (rows.length === 0) {
result[p.id] = { hasAssets: false, emotions: {} };
continue;
}
const tposeFront = rows.find((a: any) => a.is_base === 1 && a.image_url)
|| rows.find((a: any) => a.type === 'tpose' && a.variant === 'front' && a.image_url);
const tposeSide = rows.find((a: any) => a.type === 'tpose' && a.variant === 'side' && a.image_url);
const tposeBack = rows.find((a: any) => a.type === 'tpose' && a.variant === 'back' && a.image_url);
const emotions: Partial<Record<EmotionVariant, string>> = {};
for (const a of rows) {
if (a.type === 'emotion' && a.status === 'generated' && a.image_url) {
emotions[a.variant as EmotionVariant] = a.image_url;
}
}
result[p.id] = {
hasAssets: rows.some((a: any) => a.status === 'generated'),
tposeFrontUrl: tposeFront?.image_url || undefined,
tposeSideUrl: tposeSide?.image_url || undefined,
tposeBackUrl: tposeBack?.image_url || undefined,
emotions,
};
}
return result;
}
// =====================
// CAPITULOS
// =====================
export function getCapitulos(slug: string): Capitulo[] {
const projectId = resolveProjectId(slug);
if (!projectId) return [];
const rows = getDb().prepare(`
SELECT id, numero, titulo, virtud_ganada, status, current_step
FROM capitulos WHERE project_id = ? ORDER BY numero ASC
`).all(projectId) as any[];
return rows.map(r => ({
id: r.id,
numero: r.numero,
titulo: r.titulo,
virtudGanada: r.virtud_ganada,
status: r.status,
currentStep: r.current_step,
}));
}
export function getCapitulo(slug: string, numero: number): Capitulo | null {
const projectId = resolveProjectId(slug);
if (!projectId) return null;
const r = getDb().prepare(`
SELECT id, numero, titulo, virtud_ganada, status, current_step
FROM capitulos WHERE project_id = ? AND numero = ?
`).get(projectId, numero) as any;
if (!r) return null;
return {
id: r.id,
numero: r.numero,
titulo: r.titulo,
virtudGanada: r.virtud_ganada,
status: r.status,
currentStep: r.current_step,
};
}
export function createCapitulo(slug: string, numero: number, titulo: string): Capitulo {
const projectId = resolveProjectId(slug);
if (!projectId) throw new Error('Proyecto no encontrado');
const id = crypto.randomUUID();
const dirName = `${String(numero).padStart(2, '0')}-${slugify(titulo)}`;
getDb().prepare(`
INSERT INTO capitulos (id, project_id, numero, titulo, virtud_ganada, status, current_step, dir_name)
VALUES (?, ?, ?, ?, '', 'draft', 'guion_conductor', ?)
`).run(id, projectId, numero, titulo, dirName);
// Create image directories on disk
const capDir = path.join(PROJECTS_DIR, slug, 'capitulos', dirName);
fs.mkdirSync(capDir, { recursive: true });
return { id, numero, titulo, virtudGanada: '', status: 'draft', currentStep: 'guion_conductor' };
}
export function getCapDirName(slug: string, numero: number): string | null {
const projectId = resolveProjectId(slug);
if (!projectId) return null;
const row = getDb().prepare(`
SELECT dir_name FROM capitulos WHERE project_id = ? AND numero = ?
`).get(projectId, numero) as any;
return row?.dir_name || null;
}
// =====================
// GUION CONDUCTOR
// =====================
export function getGuionConductor(slug: string, numero: number): GuionConductor | null {
const capId = resolveCapituloId(slug, numero);
if (!capId) return null;
const r = getDb().prepare(`
SELECT titulo, duracion_total, virtud_ganada, nudo_del_mundo,
aliado_nombre, aliado_descripcion, gema_ganada, actos, contenido_markdown
FROM guion_conductor WHERE capitulo_id = ?
`).get(capId) as any;
if (!r) return null;
return {
titulo: r.titulo,
duracionTotal: r.duracion_total,
virtudGanada: r.virtud_ganada,
nudoDelMundo: r.nudo_del_mundo,
aliado: { nombre: r.aliado_nombre, descripcion: r.aliado_descripcion },
gemaGanada: r.gema_ganada,
actos: r.actos ? JSON.parse(r.actos) : undefined,
contenidoMarkdown: r.contenido_markdown || undefined,
};
}
export function saveGuionConductor(slug: string, numero: number, guion: GuionConductor): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
getDb().prepare(`
INSERT INTO guion_conductor (capitulo_id, titulo, duracion_total, virtud_ganada, nudo_del_mundo,
aliado_nombre, aliado_descripcion, gema_ganada, actos, contenido_markdown)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(capitulo_id) DO UPDATE SET
titulo = excluded.titulo, duracion_total = excluded.duracion_total,
virtud_ganada = excluded.virtud_ganada, nudo_del_mundo = excluded.nudo_del_mundo,
aliado_nombre = excluded.aliado_nombre, aliado_descripcion = excluded.aliado_descripcion,
gema_ganada = excluded.gema_ganada, actos = excluded.actos,
contenido_markdown = excluded.contenido_markdown
`).run(
capId,
guion.titulo || '',
guion.duracionTotal || '20:00',
guion.virtudGanada || '',
guion.nudoDelMundo || '',
guion.aliado?.nombre || '',
guion.aliado?.descripcion || '',
guion.gemaGanada || '',
guion.actos ? JSON.stringify(guion.actos) : null,
guion.contenidoMarkdown || null
);
}
// =====================
// PRE-ESCALETA
// =====================
export function getPreEscaleta(slug: string, numero: number): PreEscaleta | null {
const capId = resolveCapituloId(slug, numero);
if (!capId) return null;
const r = getDb().prepare(`
SELECT capitulo_titulo, total_planos, duracion_total_segundos, segmentos
FROM pre_escaleta WHERE capitulo_id = ?
`).get(capId) as any;
if (!r) return null;
return {
capitulo: r.capitulo_titulo,
totalPlanos: r.total_planos,
duracionTotalSegundos: r.duracion_total_segundos,
segmentos: JSON.parse(r.segmentos),
};
}
export function savePreEscaleta(slug: string, numero: number, data: PreEscaleta): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
getDb().prepare(`
INSERT INTO pre_escaleta (capitulo_id, capitulo_titulo, total_planos, duracion_total_segundos, segmentos)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(capitulo_id) DO UPDATE SET
capitulo_titulo = excluded.capitulo_titulo,
total_planos = excluded.total_planos,
duracion_total_segundos = excluded.duracion_total_segundos,
segmentos = excluded.segmentos
`).run(capId, data.capitulo || '', data.totalPlanos, data.duracionTotalSegundos, JSON.stringify(data.segmentos));
}
// =====================
// ESCALETA
// =====================
export function getEscaleta(slug: string, numero: number): Escaleta | null {
const capId = resolveCapituloId(slug, numero);
if (!capId) return null;
const r = getDb().prepare(`
SELECT capitulo_titulo, virtud_ganada, duracion_total_segundos, planos, contenido_markdown
FROM escaleta WHERE capitulo_id = ?
`).get(capId) as any;
if (!r) return null;
return {
capitulo: r.capitulo_titulo,
virtudGanada: r.virtud_ganada,
duracionTotalSegundos: r.duracion_total_segundos,
planos: r.planos ? JSON.parse(r.planos) : undefined,
contenidoMarkdown: r.contenido_markdown || undefined,
};
}
export function saveEscaleta(slug: string, numero: number, data: Escaleta): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
getDb().prepare(`
INSERT INTO escaleta (capitulo_id, capitulo_titulo, virtud_ganada, duracion_total_segundos, planos, contenido_markdown)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(capitulo_id) DO UPDATE SET
capitulo_titulo = excluded.capitulo_titulo,
virtud_ganada = excluded.virtud_ganada,
duracion_total_segundos = excluded.duracion_total_segundos,
planos = excluded.planos,
contenido_markdown = excluded.contenido_markdown
`).run(
capId,
data.capitulo || '',
data.virtudGanada || '',
data.duracionTotalSegundos || null,
data.planos ? JSON.stringify(data.planos) : null,
data.contenidoMarkdown || null
);
}
// =====================
// PROMPTS / PLANOS CON PROMPT
// =====================
export function getPromptsPlanos(slug: string, numero: number): PlanoConPrompt[] {
const capId = resolveCapituloId(slug, numero);
if (!capId) return [];
const rows = getDb().prepare(`
SELECT id, numero_plano, segmento_guion, tiempo_segundos, accion_descripcion,
indicaciones_sonido, personajes_detalle,
prompt_imagen, prompt_composition, fondo_id,
imagen_url, prompt_video, video_url
FROM prompts_planos WHERE capitulo_id = ?
`).all(capId) as any[];
return rows.map(r => ({
id: r.id,
numeroPlano: r.numero_plano,
segmentoGuion: r.segmento_guion,
tiempoSegundos: r.tiempo_segundos,
accionDescripcion: r.accion_descripcion,
indicacionesSonido: r.indicaciones_sonido,
personajesDetalle: r.personajes_detalle ? JSON.parse(r.personajes_detalle) : undefined,
promptImagen: r.prompt_imagen || '',
promptComposition: r.prompt_composition ? JSON.parse(r.prompt_composition) : undefined,
fondoId: r.fondo_id || undefined,
imagenUrl: r.imagen_url || undefined,
promptVideo: r.prompt_video || undefined,
videoUrl: r.video_url || undefined,
})).sort((a, b) => {
const pa = (a.numeroPlano || '').split('.').map(Number);
const pb = (b.numeroPlano || '').split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
});
}
export function savePromptsPlanos(slug: string, numero: number, planos: PlanoConPrompt[]): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb();
const tx = db.transaction(() => {
// Delete existing planos for this chapter
db.prepare('DELETE FROM prompts_planos WHERE capitulo_id = ?').run(capId);
const insert = db.prepare(`
INSERT INTO prompts_planos (id, capitulo_id, numero_plano, segmento_guion, tiempo_segundos,
accion_descripcion, indicaciones_sonido, personajes_detalle,
prompt_imagen, prompt_composition, fondo_id, imagen_url, prompt_video, video_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const p of planos) {
insert.run(
p.id, capId, p.numeroPlano, p.segmentoGuion || '', p.tiempoSegundos,
p.accionDescripcion || '', p.indicacionesSonido || '',
p.personajesDetalle ? JSON.stringify(p.personajesDetalle) : null,
p.promptImagen || null,
p.promptComposition ? JSON.stringify(p.promptComposition) : null,
p.fondoId || null,
p.imagenUrl || null,
p.promptVideo || null,
p.videoUrl || null
);
}
});
tx();
}
export function updatePromptPlano(
slug: string, numero: number, planoId: string,
updates: Partial<PlanoConPrompt>
): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb();
const sets: string[] = [];
const values: any[] = [];
if (updates.promptImagen !== undefined) { sets.push('prompt_imagen = ?'); values.push(updates.promptImagen || null); }
if (updates.promptComposition !== undefined) { sets.push('prompt_composition = ?'); values.push(JSON.stringify(updates.promptComposition)); }
if (updates.fondoId !== undefined) { sets.push('fondo_id = ?'); values.push(updates.fondoId || null); }
if (updates.imagenUrl !== undefined) { sets.push('imagen_url = ?'); values.push(updates.imagenUrl || null); }
if (updates.personajesDetalle !== undefined) { sets.push('personajes_detalle = ?'); values.push(JSON.stringify(updates.personajesDetalle)); }
if (updates.promptVideo !== undefined) { sets.push('prompt_video = ?'); values.push(updates.promptVideo || null); }
if (updates.videoUrl !== undefined) { sets.push('video_url = ?'); values.push(updates.videoUrl || null); }
if (sets.length === 0) return;
values.push(capId, planoId);
db.prepare(`UPDATE prompts_planos SET ${sets.join(', ')} WHERE capitulo_id = ? AND id = ?`).run(...values);
}
export function savePromptComposition(
slug: string, numero: number, planoId: string,
composition: PromptComposition, promptImagen: string
): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
getDb().prepare(`
UPDATE prompts_planos SET prompt_composition = ?, prompt_imagen = ?
WHERE capitulo_id = ? AND id = ?
`).run(JSON.stringify(composition), promptImagen, capId, planoId);
}
// =====================
// FONDOS
// =====================
export function getFondos(slug: string, numero: number): FondoReferencia[] {
const capId = resolveCapituloId(slug, numero);
if (!capId) return [];
const rows = getDb().prepare(`
SELECT id, segmento_id, nombre, descripcion, prompt_generacion, imagen_url
FROM fondos WHERE capitulo_id = ? ORDER BY segmento_id ASC
`).all(capId) as any[];
return rows.map(r => ({
id: r.id,
segmentoId: r.segmento_id,
nombre: r.nombre,
descripcion: r.descripcion,
promptGeneracion: r.prompt_generacion,
imagenUrl: r.imagen_url || undefined,
}));
}
export function saveFondos(slug: string, numero: number, fondos: FondoReferencia[]): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb();
const tx = db.transaction(() => {
db.prepare('DELETE FROM fondos WHERE capitulo_id = ?').run(capId);
const insert = db.prepare(`
INSERT INTO fondos (id, capitulo_id, segmento_id, nombre, descripcion, prompt_generacion, imagen_url, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const f of fondos) {
insert.run(f.id, capId, f.segmentoId, f.nombre, f.descripcion, f.promptGeneracion || '', f.imagenUrl || null, null);
}
});
tx();
}
export function updateFondo(
slug: string, numero: number, fondoId: string,
updates: Partial<FondoReferencia>
): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb();
const sets: string[] = [];
const values: any[] = [];
if (updates.descripcion !== undefined) { sets.push('descripcion = ?'); values.push(updates.descripcion); }
if (updates.promptGeneracion !== undefined) { sets.push('prompt_generacion = ?'); values.push(updates.promptGeneracion); }
if (updates.imagenUrl !== undefined) { sets.push('imagen_url = ?'); values.push(updates.imagenUrl || null); }
if (sets.length === 0) return;
values.push(capId, fondoId);
db.prepare(`UPDATE fondos SET ${sets.join(', ')} WHERE capitulo_id = ? AND id = ?`).run(...values);
}
// =====================
// VIDEO VARIATIONS
// =====================
/**
* Get all video variations for a plano
*/
export function getVideoVariations(slug: string, numero: number, planoId: string): VideoVariation[] {
const capId = resolveCapituloId(slug, numero);
if (!capId) return [];
const rows = getDb().prepare(`
SELECT id, plano_id, capitulo_id, video_url, video_path, prompt_video,
generation_time_seconds, cost_usd, is_selected, created_at
FROM video_variations
WHERE capitulo_id = ? AND plano_id = ?
ORDER BY created_at DESC
`).all(capId, planoId) as any[];
return rows.map(r => ({
id: r.id,
planoId: r.plano_id,
capituloId: r.capitulo_id,
videoUrl: r.video_url,
videoPath: r.video_path || undefined,
promptVideo: r.prompt_video,
generationTimeSeconds: r.generation_time_seconds || undefined,
costUsd: r.cost_usd || undefined,
isSelected: r.is_selected === 1,
createdAt: r.created_at,
}));
}
/**
* Save a new video variation
*/
export function saveVideoVariation(
slug: string,
numero: number,
variation: Omit<VideoVariation, 'capituloId'>
): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb();
db.prepare(`
INSERT INTO video_variations
(id, plano_id, capitulo_id, video_url, video_path, prompt_video,
generation_time_seconds, cost_usd, is_selected, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
variation.id,
variation.planoId,
capId,
variation.videoUrl,
variation.videoPath || null,
variation.promptVideo,
variation.generationTimeSeconds || null,
variation.costUsd || null,
variation.isSelected ? 1 : 0,
variation.createdAt
);
}
/**
* Select a video variation (unselects others for the same plano)
*/
export function selectVideoVariation(slug: string, numero: number, planoId: string, variationId: string): void {
const capId = resolveCapituloId(slug, numero);
if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb();
const tx = db.transaction(() => {
// Unselect all variations for this plano
db.prepare(`
UPDATE video_variations
SET is_selected = 0
WHERE capitulo_id = ? AND plano_id = ?
`).run(capId, planoId);
// Select the target variation
db.prepare(`
UPDATE video_variations
SET is_selected = 1
WHERE id = ?
`).run(variationId);
});
tx();
}
/**
* Delete a video variation
*/
export function deleteVideoVariation(variationId: string): void {
getDb().prepare('DELETE FROM video_variations WHERE id = ?').run(variationId);
}

203
src/lib/db.ts Normal file
View File

@@ -0,0 +1,203 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const DB_PATH = path.join(process.cwd(), 'data', 'story-studio.db');
let db: Database.Database | null = null;
export function getDb(): Database.Database {
if (db) return db;
// Ensure data/ directory exists
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
initSchema(db);
return db;
}
function initSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'draft',
current_step TEXT NOT NULL DEFAULT 'idea_motriz',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS idea_motriz (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS personajes (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
nombre TEXT NOT NULL,
tipo TEXT NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS character_assets (
id TEXT PRIMARY KEY,
personaje_id TEXT NOT NULL REFERENCES personajes(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type TEXT NOT NULL,
variant TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
image_url TEXT,
image_path TEXT,
is_base INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE IF NOT EXISTS character_assets_config (
personaje_id TEXT PRIMARY KEY REFERENCES personajes(id) ON DELETE CASCADE,
base_locked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS capitulos (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
numero INTEGER NOT NULL,
titulo TEXT NOT NULL,
virtud_ganada TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
current_step TEXT NOT NULL DEFAULT 'guion_conductor',
dir_name TEXT NOT NULL,
UNIQUE(project_id, numero)
);
CREATE TABLE IF NOT EXISTS guion_conductor (
capitulo_id TEXT PRIMARY KEY REFERENCES capitulos(id) ON DELETE CASCADE,
titulo TEXT NOT NULL DEFAULT '',
duracion_total TEXT NOT NULL DEFAULT '20:00',
virtud_ganada TEXT NOT NULL DEFAULT '',
nudo_del_mundo TEXT NOT NULL DEFAULT '',
aliado_nombre TEXT NOT NULL DEFAULT '',
aliado_descripcion TEXT NOT NULL DEFAULT '',
gema_ganada TEXT NOT NULL DEFAULT '',
actos TEXT,
contenido_markdown TEXT
);
CREATE TABLE IF NOT EXISTS pre_escaleta (
capitulo_id TEXT PRIMARY KEY REFERENCES capitulos(id) ON DELETE CASCADE,
capitulo_titulo TEXT NOT NULL DEFAULT '',
total_planos INTEGER NOT NULL DEFAULT 0,
duracion_total_segundos INTEGER NOT NULL DEFAULT 0,
segmentos TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS escaleta (
capitulo_id TEXT PRIMARY KEY REFERENCES capitulos(id) ON DELETE CASCADE,
capitulo_titulo TEXT NOT NULL DEFAULT '',
virtud_ganada TEXT NOT NULL DEFAULT '',
duracion_total_segundos INTEGER,
planos TEXT,
contenido_markdown TEXT
);
CREATE TABLE IF NOT EXISTS prompts_planos (
id TEXT NOT NULL,
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
numero_plano TEXT NOT NULL,
segmento_guion TEXT NOT NULL DEFAULT '',
tiempo_segundos INTEGER NOT NULL DEFAULT 5,
accion_descripcion TEXT NOT NULL DEFAULT '',
indicaciones_sonido TEXT NOT NULL DEFAULT '',
personajes_detalle TEXT,
personajes_en_plano TEXT,
prompt_imagen TEXT,
prompt_composition TEXT,
reference_assets TEXT,
fondo_id TEXT,
imagen_url TEXT,
image_path TEXT,
prompt_video TEXT,
video_url TEXT,
PRIMARY KEY (capitulo_id, id)
);
CREATE TABLE IF NOT EXISTS variation_templates (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
asset_key TEXT NOT NULL,
prompt TEXT NOT NULL DEFAULT '',
PRIMARY KEY (project_id, asset_key)
);
CREATE TABLE IF NOT EXISTS fondos (
id TEXT NOT NULL,
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
segmento_id TEXT NOT NULL,
nombre TEXT NOT NULL DEFAULT '',
descripcion TEXT NOT NULL DEFAULT '',
prompt_generacion TEXT NOT NULL DEFAULT '',
imagen_url TEXT,
image_path TEXT,
PRIMARY KEY (capitulo_id, id)
);
CREATE TABLE IF NOT EXISTS video_variations (
id TEXT PRIMARY KEY,
plano_id TEXT NOT NULL,
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
video_url TEXT NOT NULL,
video_path TEXT,
prompt_video TEXT NOT NULL,
generation_time_seconds REAL,
cost_usd REAL,
is_selected INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Create indexes if they don't exist
CREATE INDEX IF NOT EXISTS idx_personajes_project ON personajes(project_id);
CREATE INDEX IF NOT EXISTS idx_character_assets_personaje ON character_assets(personaje_id);
CREATE INDEX IF NOT EXISTS idx_character_assets_project ON character_assets(project_id);
CREATE INDEX IF NOT EXISTS idx_capitulos_project ON capitulos(project_id);
CREATE INDEX IF NOT EXISTS idx_prompts_planos_capitulo ON prompts_planos(capitulo_id);
CREATE INDEX IF NOT EXISTS idx_fondos_capitulo ON fondos(capitulo_id);
CREATE INDEX IF NOT EXISTS idx_video_variations_plano ON video_variations(plano_id, capitulo_id);
`);
// Run migrations for existing databases
runMigrations(db);
}
function runMigrations(db: Database.Database): void {
// Migration: Add video columns to prompts_planos table if they don't exist
const columns = db.pragma('table_info(prompts_planos)') as Array<{ name: string }>;
const columnNames = columns.map(c => c.name);
if (!columnNames.includes('prompt_video')) {
try {
db.exec('ALTER TABLE prompts_planos ADD COLUMN prompt_video TEXT');
console.log('[Migration] Added prompt_video column to prompts_planos');
} catch (e) {
// Column already exists or other error - safe to ignore
}
}
if (!columnNames.includes('video_url')) {
try {
db.exec('ALTER TABLE prompts_planos ADD COLUMN video_url TEXT');
console.log('[Migration] Added video_url column to prompts_planos');
} catch (e) {
// Column already exists or other error - safe to ignore
}
}
}

View File

@@ -1,324 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import type { Project, IdeaMotriz, Personaje, Capitulo, GuionConductor, Escaleta, CharacterAssetsData, EmotionVariant } from '@/types/project';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
// Asegurar que existe el directorio de proyectos
async function ensureProjectsDir() {
try {
await fs.access(PROJECTS_DIR);
} catch {
await fs.mkdir(PROJECTS_DIR, { recursive: true });
}
}
// Crear slug desde nombre
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
// =====================
// PROYECTOS
// =====================
export async function listProjects(): Promise<Project[]> {
await ensureProjectsDir();
const dirs = await fs.readdir(PROJECTS_DIR, { withFileTypes: true });
const projects: Project[] = [];
for (const dir of dirs) {
if (dir.isDirectory()) {
try {
const projectPath = path.join(PROJECTS_DIR, dir.name, 'project.md');
const content = await fs.readFile(projectPath, 'utf-8');
const { data } = matter(content);
projects.push(data as Project);
} catch {
// Skip invalid project directories
}
}
}
return projects.sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
export async function getProject(slug: string): Promise<Project | null> {
try {
const projectPath = path.join(PROJECTS_DIR, slug, 'project.md');
const content = await fs.readFile(projectPath, 'utf-8');
const { data } = matter(content);
return data as Project;
} catch {
return null;
}
}
export async function createProject(name: string): Promise<Project> {
await ensureProjectsDir();
const slug = slugify(name);
const projectDir = path.join(PROJECTS_DIR, slug);
// Verificar si ya existe
try {
await fs.access(projectDir);
throw new Error(`El proyecto "${name}" ya existe`);
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
}
const now = new Date().toISOString();
const project: Project = {
id: crypto.randomUUID(),
name,
slug,
createdAt: now,
updatedAt: now,
status: 'draft',
currentStep: 'idea_motriz',
};
// Crear estructura de carpetas
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(path.join(projectDir, 'capitulos'), { recursive: true });
// Guardar proyecto
const content = matter.stringify('', project);
await fs.writeFile(path.join(projectDir, 'project.md'), content);
return project;
}
export async function updateProject(slug: string, updates: Partial<Project>): Promise<Project> {
const project = await getProject(slug);
if (!project) throw new Error('Proyecto no encontrado');
const updated = {
...project,
...updates,
updatedAt: new Date().toISOString(),
};
const projectPath = path.join(PROJECTS_DIR, slug, 'project.md');
const content = matter.stringify('', updated);
await fs.writeFile(projectPath, content);
return updated;
}
export async function deleteProject(slug: string): Promise<void> {
const projectDir = path.join(PROJECTS_DIR, slug);
await fs.rm(projectDir, { recursive: true, force: true });
}
// =====================
// IDEA MOTRIZ
// =====================
export async function getIdeaMotriz(slug: string): Promise<IdeaMotriz | null> {
try {
const filePath = path.join(PROJECTS_DIR, slug, 'idea-motriz.md');
const content = await fs.readFile(filePath, 'utf-8');
const { data, content: mdContent } = matter(content);
return { ...data, concepto: mdContent } as IdeaMotriz;
} catch {
return null;
}
}
export async function saveIdeaMotriz(slug: string, ideaMotriz: IdeaMotriz): Promise<void> {
const filePath = path.join(PROJECTS_DIR, slug, 'idea-motriz.md');
const { concepto, ...frontmatter } = ideaMotriz;
const content = matter.stringify(concepto || '', frontmatter);
await fs.writeFile(filePath, content);
await updateProject(slug, { updatedAt: new Date().toISOString() });
}
// =====================
// PERSONAJES
// =====================
export async function getPersonajes(slug: string): Promise<Personaje[]> {
try {
const filePath = path.join(PROJECTS_DIR, slug, 'personajes.md');
const content = await fs.readFile(filePath, 'utf-8');
const { data } = matter(content);
return data.personajes || [];
} catch {
return [];
}
}
export async function savePersonajes(slug: string, personajes: Personaje[]): Promise<void> {
const filePath = path.join(PROJECTS_DIR, slug, 'personajes.md');
const content = matter.stringify('', { personajes });
await fs.writeFile(filePath, content);
await updateProject(slug, { updatedAt: new Date().toISOString() });
}
// =====================
// CAPÍTULOS
// =====================
export async function getCapitulos(slug: string): Promise<Capitulo[]> {
try {
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
const capitulos: Capitulo[] = [];
for (const dir of dirs) {
if (dir.isDirectory()) {
try {
const capPath = path.join(capitulosDir, dir.name, 'capitulo.md');
const content = await fs.readFile(capPath, 'utf-8');
const { data } = matter(content);
capitulos.push(data as Capitulo);
} catch {
// Skip invalid
}
}
}
return capitulos.sort((a, b) => a.numero - b.numero);
} catch {
return [];
}
}
export async function createCapitulo(slug: string, numero: number, titulo: string): Promise<Capitulo> {
const capSlug = `${String(numero).padStart(2, '0')}-${slugify(titulo)}`;
const capDir = path.join(PROJECTS_DIR, slug, 'capitulos', capSlug);
await fs.mkdir(capDir, { recursive: true });
const capitulo: Capitulo = {
id: crypto.randomUUID(),
numero,
titulo,
virtudGanada: '',
status: 'draft',
currentStep: 'guion_conductor',
};
const content = matter.stringify('', capitulo);
await fs.writeFile(path.join(capDir, 'capitulo.md'), content);
return capitulo;
}
export async function getCapitulo(slug: string, capSlug: string): Promise<Capitulo | null> {
try {
const capPath = path.join(PROJECTS_DIR, slug, 'capitulos', capSlug, 'capitulo.md');
const content = await fs.readFile(capPath, 'utf-8');
const { data } = matter(content);
return data as Capitulo;
} catch {
return null;
}
}
// =====================
// GUIÓN CONDUCTOR
// =====================
export async function getGuionConductor(slug: string, capSlug: string): Promise<GuionConductor | null> {
try {
const filePath = path.join(PROJECTS_DIR, slug, 'capitulos', capSlug, 'guion.md');
const content = await fs.readFile(filePath, 'utf-8');
const { data } = matter(content);
return data as GuionConductor;
} catch {
return null;
}
}
export async function saveGuionConductor(slug: string, capSlug: string, guion: GuionConductor): Promise<void> {
const filePath = path.join(PROJECTS_DIR, slug, 'capitulos', capSlug, 'guion.md');
const content = matter.stringify('', guion);
await fs.writeFile(filePath, content);
}
// =====================
// ESCALETA
// =====================
export async function getEscaleta(slug: string, capSlug: string): Promise<Escaleta | null> {
try {
const filePath = path.join(PROJECTS_DIR, slug, 'capitulos', capSlug, 'escaleta.md');
const content = await fs.readFile(filePath, 'utf-8');
const { data } = matter(content);
return data as Escaleta;
} catch {
return null;
}
}
export async function saveEscaleta(slug: string, capSlug: string, escaleta: Escaleta): Promise<void> {
const filePath = path.join(PROJECTS_DIR, slug, 'capitulos', capSlug, 'escaleta.md');
const content = matter.stringify('', escaleta);
await fs.writeFile(filePath, content);
}
// =====================
// CHARACTER ASSETS MAP
// =====================
export interface CharacterAssetsSummary {
hasAssets: boolean;
tposeFrontUrl?: string;
tposeSideUrl?: string;
tposeBackUrl?: string;
emotions: Partial<Record<EmotionVariant, string>>;
}
export async function getCharacterAssetsMap(
slug: string,
personajes: Personaje[]
): Promise<Record<string, CharacterAssetsSummary>> {
const result: Record<string, CharacterAssetsSummary> = {};
await Promise.all(personajes.map(async (p) => {
const assetsPath = path.join(PROJECTS_DIR, slug, 'personajes', p.id, 'assets.json');
try {
const content = await fs.readFile(assetsPath, 'utf-8');
const parsed = JSON.parse(content);
const data: CharacterAssetsData = Array.isArray(parsed)
? { assets: parsed, baseLocked: false }
: parsed;
const tposeFront = data.assets.find(a => a.isBase && a.imageUrl)
|| data.assets.find(a => a.type === 'tpose' && a.variant === 'front' && a.imageUrl);
const tposeSide = data.assets.find(a => a.type === 'tpose' && a.variant === 'side' && a.imageUrl);
const tposeBack = data.assets.find(a => a.type === 'tpose' && a.variant === 'back' && a.imageUrl);
const emotions: Partial<Record<EmotionVariant, string>> = {};
for (const asset of data.assets) {
if (asset.type === 'emotion' && asset.status === 'generated' && asset.imageUrl) {
emotions[asset.variant as EmotionVariant] = asset.imageUrl;
}
}
result[p.id] = {
hasAssets: data.assets.some(a => a.status === 'generated'),
tposeFrontUrl: tposeFront?.imageUrl,
tposeSideUrl: tposeSide?.imageUrl,
tposeBackUrl: tposeBack?.imageUrl,
emotions,
};
} catch {
result[p.id] = { hasAssets: false, emotions: {} };
}
}));
return result;
}

65
src/lib/prompt-utils.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { IdeaMotriz, PromptComposition, PersonajeEnPlano, ReferenceAsset } from '@/types/project';
import type { CharacterAssetsSummary } from './db-service';
export const DEFAULT_ESTILO = 'Pixar-style 3D animation, vibrant colors, expressive characters';
/** Get estilo from ideaMotriz or fallback to default */
export function resolveEstilo(ideaMotriz: IdeaMotriz | null): string {
return ideaMotriz?.estiloVisual || DEFAULT_ESTILO;
}
/** Ensure a value is a string (LLM may return arrays/objects for some fields) */
export function ensureString(val: unknown): string {
if (typeof val === 'string') return val;
if (val == null) return '';
if (Array.isArray(val)) {
return val.map(v => typeof v === 'object' && v !== null
? Object.values(v).filter(Boolean).join(', ')
: String(v)
).join('. ');
}
if (typeof val === 'object') return Object.values(val as Record<string, unknown>).filter(Boolean).join(', ');
return String(val);
}
/** Compose a prompt string from composition sub-fields (WITHOUT estilo — estilo is injected at runtime) */
export function composePrompt(comp: PromptComposition): string {
const parts = [
comp.tipoPlano,
comp.camara,
comp.optica,
comp.personajes,
comp.accion,
comp.luz,
comp.custom,
].map(ensureString).filter(Boolean);
return parts.join('. ') + '.';
}
/** Prepend estilo to a prompt at runtime (for image generation / display) */
export function withEstilo(promptImagen: string, estilo: string): string {
if (!estilo) return promptImagen;
if (!promptImagen) return estilo + '.';
return `${estilo}. ${promptImagen}`;
}
/** Resolve character reference assets at runtime from personajesDetalle + assetsMap */
export function resolveReferences(
detalle: PersonajeEnPlano[],
assetsMap: Record<string, CharacterAssetsSummary>
): ReferenceAsset[] {
return detalle
.filter(pd => assetsMap[pd.personajeId]?.hasAssets)
.map(pd => {
const s = assetsMap[pd.personajeId];
return {
personajeId: pd.personajeId,
nombre: pd.nombre,
emocion: pd.emocion,
tposeFrontUrl: s.tposeFrontUrl,
tposeSideUrl: s.tposeSideUrl,
tposeBackUrl: s.tposeBackUrl,
emotionUrl: s.emotions[pd.emocion],
};
});
}

View File

@@ -42,7 +42,8 @@ Debes generar un JSON con esta estructura exacta:
"duracionMax": minutos máximos,
"descripcion": "Qué ocurre en este acto"
}
]
],
"estiloVisual": "Descripción detallada del estilo visual de la serie (ej: 'Pixar-style 3D animation, vibrant colors, expressive characters'). Define la estética general que se aplicará a todas las imágenes generadas."
}
Responde SOLO con el JSON, sin texto adicional.`;
@@ -557,6 +558,17 @@ const EMOTION_VARIANTS = [
export { TPOSE_VARIANTS, EMOTION_VARIANTS };
export const DEFAULT_VARIATION_TEMPLATES: Record<string, string> = {
'tpose-side': 'Full body side profile (right side) of the character in T-pose, arms extended horizontally. White background.',
'tpose-back': 'Full body rear view of the character in T-pose, arms extended horizontally. White background.',
'emotion-alegria': 'Close-up of the character face showing clear happiness and joy, big smile, bright eyes. White background.',
'emotion-tristeza': 'Close-up of the character face showing sadness, droopy eyes, slight frown. White background.',
'emotion-sorpresa': 'Close-up of the character face showing surprise, wide open eyes, open mouth. White background.',
'emotion-miedo': 'Close-up of the character face showing fear, wide eyes, tense expression. White background.',
'emotion-enfado': 'Close-up of the character face showing anger, furrowed brows, intense expression. White background.',
'emotion-neutral': 'Close-up of the character face with neutral calm expression, relaxed features. White background.',
};
export function getCharacterAssetPromptsPrompt(personaje: Personaje): string {
return `Eres un artista conceptual especializado en character sheets para animación.

150
src/lib/remotion-service.ts Normal file
View File

@@ -0,0 +1,150 @@
import { bundle } from '@remotion/bundler';
import { renderMedia, selectComposition } from '@remotion/renderer';
import path from 'path';
import fs from 'fs/promises';
import { getPromptsPlanos, getVideoVariations } from './db-service';
export interface RenderOptions {
projectSlug: string;
capituloNumero: number;
includeIntro?: boolean;
transitionType?: 'cut' | 'crossfade';
quality?: number;
}
export interface RenderProgress {
phase: 'loading' | 'bundling' | 'rendering' | 'complete' | 'error';
progress: number;
message: string;
outputPath?: string;
}
export async function renderChapterVideo(
options: RenderOptions,
onProgress?: (progress: RenderProgress) => void
): Promise<string> {
try {
onProgress?.({ phase: 'loading', progress: 0, message: 'Cargando datos del capítulo...' });
// Load chapter data from database (server-side)
const planos = getPromptsPlanos(options.projectSlug, options.capituloNumero);
const planosWithVideos = planos
.map(plano => {
const variations = getVideoVariations(options.projectSlug, options.capituloNumero, plano.id);
const selected = variations.find(v => v.isSelected);
if (!selected?.videoPath) return null;
return {
id: plano.id,
numeroPlano: plano.numeroPlano,
tiempoSegundos: plano.tiempoSegundos,
videoPath: selected.videoPath,
accionDescripcion: plano.accionDescripcion,
};
})
.filter(Boolean);
const chapterData = {
projectSlug: options.projectSlug,
capituloNumero: options.capituloNumero,
titulo: `Capítulo ${options.capituloNumero}`,
duracionTotalSegundos: planosWithVideos.reduce((sum: number, p: any) => sum + p.tiempoSegundos, 0),
planos: planosWithVideos,
};
if (chapterData.planos.length === 0) {
throw new Error('No hay planos con videos generados para este capítulo');
}
console.log(`[Remotion Service] Loaded ${chapterData.planos.length} planos for chapter ${options.capituloNumero}`);
onProgress?.({ phase: 'bundling', progress: 10, message: 'Preparando compositor Remotion...' });
// Bundle Remotion project
const bundleLocation = await bundle({
entryPoint: path.join(process.cwd(), 'remotion', 'src', 'index.ts'),
webpackOverride: (config: any) => config,
});
console.log(`[Remotion Service] Bundle created at: ${bundleLocation}`);
onProgress?.({ phase: 'bundling', progress: 30, message: 'Compositor listo' });
// Select composition
const composition = await selectComposition({
serveUrl: bundleLocation,
id: 'Chapter',
inputProps: {
chapterData,
includeIntro: options.includeIntro ?? true,
transitionType: options.transitionType ?? 'crossfade',
transitionFrames: 15,
},
});
console.log(`[Remotion Service] Selected composition: ${composition.id}, duration: ${composition.durationInFrames} frames`);
onProgress?.({ phase: 'rendering', progress: 40, message: 'Iniciando renderizado...' });
// Prepare output directory
const outputDir = path.join(
process.cwd(),
'projects',
options.projectSlug,
'capitulos',
String(options.capituloNumero).padStart(2, '0'),
'renders'
);
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, `capitulo-${options.capituloNumero}-${Date.now()}.mp4`);
console.log(`[Remotion Service] Output path: ${outputPath}`);
// Render video
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: 'h264',
outputLocation: outputPath,
inputProps: {
chapterData,
includeIntro: options.includeIntro ?? true,
transitionType: options.transitionType ?? 'crossfade',
transitionFrames: 15,
},
onProgress: ({ progress }: { progress: number }) => {
const renderProgress = 40 + Math.floor(progress * 60);
onProgress?.({
phase: 'rendering',
progress: renderProgress,
message: `Renderizando: ${Math.floor(progress * 100)}%`
});
},
crf: options.quality ? Math.round((100 - options.quality) / 5) : 18,
pixelFormat: 'yuv420p',
});
console.log(`[Remotion Service] Render complete: ${outputPath}`);
onProgress?.({
phase: 'complete',
progress: 100,
message: 'Renderizado completo',
outputPath
});
return outputPath;
} catch (error: any) {
console.error('[Remotion Service] Error:', error);
onProgress?.({
phase: 'error',
progress: 0,
message: error.message
});
throw error;
}
}

185
src/lib/video-service.ts Normal file
View File

@@ -0,0 +1,185 @@
/**
* Video generation service using BytePlus Ark API with Seedance 1.5 Pro
*/
const ARK_API_KEY = process.env.ARK_API_KEY || '';
const ARK_BASE_URL = 'https://ark.ap-southeast.bytepluses.com/api/v3';
const MODEL = 'seedance-1-5-pro-251215';
// Pricing - Update based on BytePlus Ark actual pricing
// Placeholder: $0.10 per video generation (adjust as needed)
const COST_PER_VIDEO_USD = 0.10;
interface ArkTaskResponse {
id: string;
model: string;
status: 'pending' | 'processing' | 'succeeded' | 'failed';
created_at: number;
updated_at: number;
content?: Array<{
type: string;
video_url?: { url: string };
}> | {
video_url?: { url: string };
};
error?: {
code: string;
message: string;
};
}
export interface VideoGenerationResult {
videoUrl: string;
generationTimeSeconds: number;
costUsd: number;
fullPrompt: string;
}
/**
* Generate video from image using Seedance 1.5 Pro
* @param imageUrl - URL or data URL (base64) of the reference image
* @param prompt - Text prompt describing the action/movement
* @param duration - Video duration in seconds (default 5)
* @param cameraFixed - Whether camera should be fixed (default false)
* @param generateAudio - Whether to generate audio (default true)
* @returns Video generation result with URL, time, and cost
*/
export async function generateVideo(
imageUrl: string,
prompt: string,
duration: number = 5,
cameraFixed: boolean = false,
generateAudio: boolean = true
): Promise<VideoGenerationResult> {
const startTime = Date.now();
if (!ARK_API_KEY) {
throw new Error('ARK_API_KEY no configurada en variables de entorno');
}
// Enhance prompt to request only natural sounds, no background music
const audioInstruction = generateAudio
? 'Natural ambient sounds and action sounds only, no background music.'
: '';
// Compose the text prompt with audio instructions
const textPrompt = audioInstruction
? `${prompt} ${audioInstruction} --duration ${duration} --camerafixed ${cameraFixed}`
: `${prompt} --duration ${duration} --camerafixed ${cameraFixed}`;
// Create video generation task
const createResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ARK_API_KEY}`,
},
body: JSON.stringify({
model: MODEL,
content: [
{
type: 'text',
text: textPrompt,
},
{
type: 'image_url',
image_url: {
url: imageUrl,
},
},
],
// Additional parameters for video generation
duration,
resolution: '720p',
aspect_ratio: '16:9',
fps: 24,
camera_fixed: cameraFixed,
generate_audio: generateAudio,
}),
});
if (!createResponse.ok) {
const errorData = await createResponse.json().catch(() => ({}));
throw new Error(`Error creando tarea de video: ${createResponse.status} ${JSON.stringify(errorData)}`);
}
const createData: ArkTaskResponse = await createResponse.json();
const taskId = createData.id;
console.log(`[Video Service] Task created: ${taskId}`);
// Poll task status until completed (max 5 minutes)
const maxAttempts = 300; // 5 minutes with 1s intervals
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
const statusResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks/${taskId}`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ARK_API_KEY}`,
},
});
if (!statusResponse.ok) {
throw new Error(`Error consultando estado de tarea: ${statusResponse.status}`);
}
const statusData: ArkTaskResponse = await statusResponse.json();
console.log(`[Video Service] Task ${taskId} status: ${statusData.status}`);
if (statusData.status === 'succeeded') {
// Log the full response to understand its structure
console.log('[Video Service] Success response:', JSON.stringify(statusData, null, 2));
// Extract video URL from response
// Handle both array and object formats
let videoUrl: string | undefined;
if (Array.isArray(statusData.content)) {
// Array format: [{ type: 'video_url', video_url: { url: '...' } }]
const videoContent = statusData.content.find(c => c.type === 'video_url' && c.video_url);
videoUrl = videoContent?.video_url?.url;
} else if (statusData.content?.video_url) {
// Object format: { video_url: "https://..." } (direct string)
const vurl = (statusData.content as any).video_url;
videoUrl = typeof vurl === 'string' ? vurl : vurl?.url;
}
if (!videoUrl) {
throw new Error(`Video URL no encontrada en respuesta exitosa. Response: ${JSON.stringify(statusData)}`);
}
console.log(`[Video Service] Task ${taskId} succeeded: ${videoUrl}`);
const endTime = Date.now();
const generationTimeSeconds = (endTime - startTime) / 1000;
return {
videoUrl,
generationTimeSeconds,
costUsd: COST_PER_VIDEO_USD,
fullPrompt: textPrompt,
};
} else if (statusData.status === 'failed') {
const errorMsg = statusData.error?.message || 'Error desconocido';
throw new Error(`Generación de video falló: ${errorMsg}`);
}
attempts++;
}
throw new Error('Timeout esperando generación de video (5 minutos)');
}
/**
* Download video from URL and return as Buffer
*/
export async function downloadVideo(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error descargando video: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}

View File

@@ -0,0 +1,506 @@
/**
* Migration script: file-based persistence → SQLite
*
* Reads all project data from the projects/ directory (markdown + JSON files)
* and imports it into the SQLite database.
*
* Usage: npx tsx src/scripts/migrate-to-sqlite.ts
*
* Safe to run multiple times — uses INSERT OR IGNORE to skip existing records.
*/
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import Database from 'better-sqlite3';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
const DB_PATH = path.join(process.cwd(), 'data', 'story-studio.db');
// Ensure data/ directory exists
const dataDir = path.dirname(DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Initialize schema (same as db.ts)
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'draft',
current_step TEXT NOT NULL DEFAULT 'idea_motriz',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS idea_motriz (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS personajes (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
nombre TEXT NOT NULL,
tipo TEXT NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS character_assets (
id TEXT PRIMARY KEY,
personaje_id TEXT NOT NULL REFERENCES personajes(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type TEXT NOT NULL,
variant TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
image_url TEXT,
image_path TEXT,
is_base INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE IF NOT EXISTS character_assets_config (
personaje_id TEXT PRIMARY KEY REFERENCES personajes(id) ON DELETE CASCADE,
base_locked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS capitulos (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
numero INTEGER NOT NULL,
titulo TEXT NOT NULL,
virtud_ganada TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
current_step TEXT NOT NULL DEFAULT 'guion_conductor',
dir_name TEXT NOT NULL,
UNIQUE(project_id, numero)
);
CREATE TABLE IF NOT EXISTS guion_conductor (
capitulo_id TEXT PRIMARY KEY REFERENCES capitulos(id) ON DELETE CASCADE,
titulo TEXT NOT NULL DEFAULT '',
duracion_total TEXT NOT NULL DEFAULT '20:00',
virtud_ganada TEXT NOT NULL DEFAULT '',
nudo_del_mundo TEXT NOT NULL DEFAULT '',
aliado_nombre TEXT NOT NULL DEFAULT '',
aliado_descripcion TEXT NOT NULL DEFAULT '',
gema_ganada TEXT NOT NULL DEFAULT '',
actos TEXT,
contenido_markdown TEXT
);
CREATE TABLE IF NOT EXISTS pre_escaleta (
capitulo_id TEXT PRIMARY KEY REFERENCES capitulos(id) ON DELETE CASCADE,
capitulo_titulo TEXT NOT NULL DEFAULT '',
total_planos INTEGER NOT NULL DEFAULT 0,
duracion_total_segundos INTEGER NOT NULL DEFAULT 0,
segmentos TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS escaleta (
capitulo_id TEXT PRIMARY KEY REFERENCES capitulos(id) ON DELETE CASCADE,
capitulo_titulo TEXT NOT NULL DEFAULT '',
virtud_ganada TEXT NOT NULL DEFAULT '',
duracion_total_segundos INTEGER,
planos TEXT,
contenido_markdown TEXT
);
CREATE TABLE IF NOT EXISTS prompts_planos (
id TEXT NOT NULL,
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
numero_plano TEXT NOT NULL,
segmento_guion TEXT NOT NULL DEFAULT '',
tiempo_segundos INTEGER NOT NULL DEFAULT 5,
accion_descripcion TEXT NOT NULL DEFAULT '',
indicaciones_sonido TEXT NOT NULL DEFAULT '',
personajes_detalle TEXT,
personajes_en_plano TEXT,
prompt_imagen TEXT,
prompt_composition TEXT,
reference_assets TEXT,
fondo_id TEXT,
imagen_url TEXT,
image_path TEXT,
PRIMARY KEY (capitulo_id, id)
);
CREATE TABLE IF NOT EXISTS fondos (
id TEXT NOT NULL,
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
segmento_id TEXT NOT NULL,
nombre TEXT NOT NULL DEFAULT '',
descripcion TEXT NOT NULL DEFAULT '',
prompt_generacion TEXT NOT NULL DEFAULT '',
imagen_url TEXT,
image_path TEXT,
PRIMARY KEY (capitulo_id, id)
);
CREATE INDEX IF NOT EXISTS idx_personajes_project ON personajes(project_id);
CREATE INDEX IF NOT EXISTS idx_character_assets_personaje ON character_assets(personaje_id);
CREATE INDEX IF NOT EXISTS idx_character_assets_project ON character_assets(project_id);
CREATE INDEX IF NOT EXISTS idx_capitulos_project ON capitulos(project_id);
CREATE INDEX IF NOT EXISTS idx_prompts_planos_capitulo ON prompts_planos(capitulo_id);
CREATE INDEX IF NOT EXISTS idx_fondos_capitulo ON fondos(capitulo_id);
`);
// =====================
// HELPERS
// =====================
function readMd(filePath: string): { data: Record<string, any>; content: string } | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(raw);
return { data, content: content.trim() };
} catch {
return null;
}
}
function readJson(filePath: string): any | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
function listDirs(dirPath: string): string[] {
try {
return fs.readdirSync(dirPath, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
} catch {
return [];
}
}
let stats = {
projects: 0,
ideaMotriz: 0,
personajes: 0,
characterAssets: 0,
capitulos: 0,
guionConductor: 0,
preEscaleta: 0,
escaleta: 0,
promptsPlanos: 0,
fondos: 0,
};
// =====================
// MIGRATION
// =====================
console.log('Starting migration from file-based storage to SQLite...');
console.log(`Projects dir: ${PROJECTS_DIR}`);
console.log(`Database: ${DB_PATH}\n`);
const projectDirs = listDirs(PROJECTS_DIR);
if (projectDirs.length === 0) {
console.log('No projects found in projects/ directory.');
process.exit(0);
}
const migrationTx = db.transaction(() => {
for (const slug of projectDirs) {
const projectDir = path.join(PROJECTS_DIR, slug);
// 1. Project
const projectMd = readMd(path.join(projectDir, 'project.md'));
if (!projectMd) {
console.log(` [SKIP] ${slug}: no project.md found`);
continue;
}
const p = projectMd.data;
const projectId = p.id || crypto.randomUUID();
db.prepare(`
INSERT OR IGNORE INTO projects (id, name, slug, status, current_step, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
projectId,
p.name || slug,
p.slug || slug,
p.status || 'draft',
p.currentStep || 'idea_motriz',
p.createdAt || new Date().toISOString(),
p.updatedAt || new Date().toISOString()
);
stats.projects++;
console.log(`[PROJECT] ${slug} (${projectId})`);
// 2. Idea Motriz
const ideaMd = readMd(path.join(projectDir, 'idea-motriz.md'));
if (ideaMd) {
// concepto: frontmatter has concepto, but file-service.ts uses markdown body as concepto
// If body content is meaningful, use it; otherwise keep frontmatter concepto
const bodyConcepto = ideaMd.content;
const fmConcepto = ideaMd.data.concepto;
// If the body is just a heading or empty, prefer frontmatter concepto
const concepto = (bodyConcepto && !bodyConcepto.startsWith('#'))
? bodyConcepto
: fmConcepto || bodyConcepto || '';
const ideaData = { ...ideaMd.data, concepto };
db.prepare(`
INSERT OR IGNORE INTO idea_motriz (project_id, data, updated_at)
VALUES (?, ?, ?)
`).run(projectId, JSON.stringify(ideaData), new Date().toISOString());
stats.ideaMotriz++;
console.log(` [IDEA MOTRIZ] imported`);
}
// 3. Personajes
const perssMd = readMd(path.join(projectDir, 'personajes.md'));
if (perssMd) {
const personajes: any[] = perssMd.data.personajes || [];
const insertPers = db.prepare(`
INSERT OR IGNORE INTO personajes (id, project_id, nombre, tipo, data)
VALUES (?, ?, ?, ?, ?)
`);
for (const pers of personajes) {
const { id, nombre, tipo, ...rest } = pers;
insertPers.run(id, projectId, nombre, tipo || 'protagonista', JSON.stringify(rest));
stats.personajes++;
}
if (personajes.length > 0) {
console.log(` [PERSONAJES] ${personajes.length} imported`);
}
// 4. Character Assets (for each personaje)
const personajesDir = path.join(projectDir, 'personajes');
for (const pers of personajes) {
const assetsJson = readJson(path.join(personajesDir, pers.id, 'assets.json'));
if (!assetsJson) continue;
// Handle old format (plain array) vs new format ({assets, baseLocked})
const assetsData = Array.isArray(assetsJson)
? { assets: assetsJson, baseLocked: false }
: assetsJson;
const insertAsset = db.prepare(`
INSERT OR IGNORE INTO character_assets (id, personaje_id, project_id, type, variant, label, prompt, image_url, image_path, is_base, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of assetsData.assets) {
const imagePath = a.imageUrl
? `personajes/${pers.id}/images/${a.id}.png`
: null;
insertAsset.run(
a.id, pers.id, projectId,
a.type, a.variant, a.label || '', a.prompt || '',
a.imageUrl || null, imagePath,
a.isBase ? 1 : 0, a.status || 'pending'
);
stats.characterAssets++;
}
db.prepare(`
INSERT OR IGNORE INTO character_assets_config (personaje_id, base_locked)
VALUES (?, ?)
`).run(pers.id, assetsData.baseLocked ? 1 : 0);
console.log(` [ASSETS] ${pers.nombre}: ${assetsData.assets.length} assets`);
}
}
// 5. Capitulos
const capitulosDir = path.join(projectDir, 'capitulos');
const capDirs = listDirs(capitulosDir);
// Track seen numeros to skip duplicates
const seenNumeros = new Set<number>();
for (const dirName of capDirs) {
const capDir = path.join(capitulosDir, dirName);
const capMd = readMd(path.join(capDir, 'capitulo.md'));
if (!capMd) {
console.log(` [SKIP CAP] ${dirName}: no capitulo.md`);
continue;
}
const cap = capMd.data;
const capNum = cap.numero;
if (seenNumeros.has(capNum)) {
console.log(` [SKIP CAP] ${dirName}: duplicate numero ${capNum}`);
continue;
}
seenNumeros.add(capNum);
const capId = cap.id || crypto.randomUUID();
db.prepare(`
INSERT OR IGNORE INTO capitulos (id, project_id, numero, titulo, virtud_ganada, status, current_step, dir_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
capId, projectId, capNum,
cap.titulo || `Capítulo ${capNum}`,
cap.virtudGanada || cap.virtud_ganada || '',
cap.status || cap.estado || 'draft',
cap.currentStep || 'guion_conductor',
dirName
);
stats.capitulos++;
console.log(` [CAPITULO] ${dirName} → cap ${capNum}`);
// 6. Guion Conductor
const guionMd = readMd(path.join(capDir, 'guion-conductor.md'));
if (guionMd) {
const g = guionMd.data;
// Load actos from separate JSON file (or from frontmatter)
const actosJson = readJson(path.join(capDir, 'guion-actos.json'));
const actos = actosJson || g.actos || null;
const mdContent = guionMd.content || '';
db.prepare(`
INSERT OR IGNORE INTO guion_conductor (capitulo_id, titulo, duracion_total, virtud_ganada,
nudo_del_mundo, aliado_nombre, aliado_descripcion, gema_ganada, actos, contenido_markdown)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
capId,
g.titulo || '',
g.duracionTotal || g.duracion_total || '20:00',
g.virtudGanada || g.virtud_ganada || '',
g.nudoDelMundo || g.nudo_del_mundo || '',
g.aliado?.nombre || '',
g.aliado?.descripcion || '',
g.gemaGanada || g.gema_ganada || '',
actos ? JSON.stringify(actos) : null,
mdContent || null
);
stats.guionConductor++;
console.log(` [GUION] imported`);
}
// 7. Pre-escaleta
const preEscaletaJson = readJson(path.join(capDir, 'pre-escaleta.json'));
if (preEscaletaJson) {
db.prepare(`
INSERT OR IGNORE INTO pre_escaleta (capitulo_id, capitulo_titulo, total_planos, duracion_total_segundos, segmentos)
VALUES (?, ?, ?, ?, ?)
`).run(
capId,
preEscaletaJson.capitulo || '',
preEscaletaJson.totalPlanos || 0,
preEscaletaJson.duracionTotalSegundos || 0,
JSON.stringify(preEscaletaJson.segmentos || [])
);
stats.preEscaleta++;
console.log(` [PRE-ESCALETA] ${preEscaletaJson.totalPlanos} planos`);
}
// 8. Escaleta
const escaletaMd = readMd(path.join(capDir, 'escaleta.md'));
if (escaletaMd) {
const esc = escaletaMd.data;
// Planos come from escaleta-planos.json (the JSON source of truth)
const escaletaPlanosJson = readJson(path.join(capDir, 'escaleta-planos.json'));
const planos = escaletaPlanosJson || esc.planos || null;
const mdContent = escaletaMd.content || '';
db.prepare(`
INSERT OR IGNORE INTO escaleta (capitulo_id, capitulo_titulo, virtud_ganada, duracion_total_segundos, planos, contenido_markdown)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
capId,
esc.capitulo || '',
esc.virtudGanada || esc.virtud_ganada || '',
esc.duracionTotalSegundos || esc.duracion_total_segundos || null,
planos ? JSON.stringify(planos) : null,
mdContent || null
);
stats.escaleta++;
const planoCount = Array.isArray(planos) ? planos.length : 0;
console.log(` [ESCALETA] ${planoCount} planos`);
}
// 9. Prompts Planos
const promptsPlanosJson = readJson(path.join(capDir, 'prompts-planos.json'));
if (promptsPlanosJson && Array.isArray(promptsPlanosJson)) {
const insertPlano = db.prepare(`
INSERT OR IGNORE INTO prompts_planos (id, capitulo_id, numero_plano, segmento_guion, tiempo_segundos,
accion_descripcion, indicaciones_sonido, personajes_detalle, personajes_en_plano,
prompt_imagen, prompt_composition, reference_assets, fondo_id, imagen_url, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const p of promptsPlanosJson) {
insertPlano.run(
p.id, capId,
p.numeroPlano || '', p.segmentoGuion || '', p.tiempoSegundos || 5,
p.accionDescripcion || '', p.indicacionesSonido || '',
p.personajesDetalle ? JSON.stringify(p.personajesDetalle) : null,
p.personajesEnPlano ? JSON.stringify(p.personajesEnPlano) : null,
p.promptImagen || null,
p.promptComposition ? JSON.stringify(p.promptComposition) : null,
p.referenceAssets ? JSON.stringify(p.referenceAssets) : null,
p.fondoId || null,
p.imagenUrl || null,
null // image_path
);
stats.promptsPlanos++;
}
console.log(` [PROMPTS] ${promptsPlanosJson.length} planos`);
}
// 10. Fondos
const fondosJson = readJson(path.join(capDir, 'fondos.json'));
if (fondosJson && Array.isArray(fondosJson)) {
const insertFondo = db.prepare(`
INSERT OR IGNORE INTO fondos (id, capitulo_id, segmento_id, nombre, descripcion, prompt_generacion, imagen_url, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const f of fondosJson) {
insertFondo.run(
f.id, capId,
f.segmentoId || '', f.nombre || '', f.descripcion || '',
f.promptGeneracion || '', f.imagenUrl || null, null
);
stats.fondos++;
}
console.log(` [FONDOS] ${fondosJson.length} fondos`);
}
}
console.log('');
}
});
try {
migrationTx();
console.log('Migration completed successfully!\n');
console.log('Stats:');
console.log(` Projects: ${stats.projects}`);
console.log(` Idea Motriz: ${stats.ideaMotriz}`);
console.log(` Personajes: ${stats.personajes}`);
console.log(` Character Assets: ${stats.characterAssets}`);
console.log(` Capitulos: ${stats.capitulos}`);
console.log(` Guion Conductor: ${stats.guionConductor}`);
console.log(` Pre-Escaleta: ${stats.preEscaleta}`);
console.log(` Escaleta: ${stats.escaleta}`);
console.log(` Prompts Planos: ${stats.promptsPlanos}`);
console.log(` Fondos: ${stats.fondos}`);
} catch (error: any) {
console.error('Migration failed:', error.message);
process.exit(1);
} finally {
db.close();
}

View File

@@ -179,7 +179,6 @@ export interface PlanoEscaleta {
accionDescripcion: string;
indicacionesSonido: string;
promptImagen?: string;
personajesEnPlano?: string[];
personajesDetalle?: PersonajeEnPlano[];
imagenUrl?: string;
videoUrl?: string;
@@ -203,7 +202,6 @@ export interface PromptComposition {
camara: string;
optica: string;
tipoPlano: string;
estilo: string;
custom: string;
}
@@ -218,12 +216,27 @@ export interface ReferenceAsset {
emotionUrl?: string;
}
export interface VideoVariation {
id: string;
planoId: string;
capituloId: string;
videoUrl: string;
videoPath?: string;
promptVideo: string;
generationTimeSeconds?: number;
costUsd?: number;
isSelected: boolean;
createdAt: string;
}
export interface PlanoConPrompt extends PlanoEscaleta {
promptComposition?: PromptComposition;
promptImagen: string;
referenceAssets: ReferenceAsset[];
promptVideo?: string;
referenceAssets?: ReferenceAsset[]; // Runtime-only, not persisted
fondoId?: string;
imagenUrl?: string;
videoVariations?: VideoVariation[]; // Runtime-only, loaded on demand
}
// Character Assets (para Nano Banana Pro)