init commit
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
293
src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts
Normal file
293
src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
src/app/api/projects/[slug]/variation-templates/route.ts
Normal file
30
src/app/api/projects/[slug]/variation-templates/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
95
src/app/api/remotion/chapter-data/[slug]/[numero]/route.ts
Normal file
95
src/app/api/remotion/chapter-data/[slug]/[numero]/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
65
src/app/api/render/route.ts
Normal file
65
src/app/api/render/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
265
src/components/capitulo/VideoVariationsCard.tsx
Normal file
265
src/components/capitulo/VideoVariationsCard.tsx
Normal 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}`;
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
852
src/lib/db-service.ts
Normal 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
203
src/lib/db.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
65
src/lib/prompt-utils.ts
Normal 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],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
150
src/lib/remotion-service.ts
Normal 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
185
src/lib/video-service.ts
Normal 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);
|
||||
}
|
||||
506
src/scripts/migrate-to-sqlite.ts
Normal file
506
src/scripts/migrate-to-sqlite.ts
Normal 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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user