first commit

This commit is contained in:
Carlos
2026-02-21 10:31:46 +01:00
parent 17ca405add
commit c863a943ed
146 changed files with 24563 additions and 90 deletions

View File

@@ -0,0 +1,267 @@
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');
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]));
const byName = new Map(personajes.map(p => [p.nombre.toLowerCase(), p]));
return planos.map((plano) => {
let detalle: PersonajeEnPlano[] = [];
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';
return { personajeId: persona.id, nombre: persona.nombre, emocion } as PersonajeEnPlano;
}).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 };
});
}
/** Migrate old planos that only have personajesEnPlano (string[]) but no personajesDetalle */
function migrateLegacyPlanos(planos: any[], personajes: Personaje[]): PlanoEscaleta[] {
const byName = new Map(personajes.map(p => [p.nombre.toLowerCase(), p]));
return planos.map((plano) => {
if (Array.isArray(plano.personajesDetalle) && plano.personajesDetalle.length > 0) {
return plano; // already has structured data
}
if (!Array.isArray(plano.personajesEnPlano) || plano.personajesEnPlano.length === 0) {
return { ...plano, personajesDetalle: [] };
}
const detalle: PersonajeEnPlano[] = plano.personajesEnPlano
.map((name: string) => {
const persona = byName.get(name.toLowerCase());
if (!persona) return null;
return { personajeId: persona.id, nombre: persona.nombre, emocion: 'neutral' as EmotionVariant };
})
.filter(Boolean) as PersonajeEnPlano[];
return { ...plano, personajesDetalle: detalle };
});
}
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) {
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);
}
} catch (error: any) {
console.error('Error fetching escaleta:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
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);
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 */ }
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 { contenidoMarkdown: _ignore, planos: rawPlanos, ...frontmatterData } = escaleta as any;
// Normalize personajesDetalle: validate IDs and backfill personajesEnPlano
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));
return NextResponse.json({ ...frontmatterData, contenidoMarkdown: mdContent, planos });
}
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);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const personajes = await 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) {
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) {
const send = (data: any) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch { /* client disconnected */ }
};
try {
const { generateEscaleta } = await import('@/lib/llm-service');
const escaletaResult = await generateEscaleta(guion, ideaMotriz, personajes, preEscaletaFinal,
(current, total, label) => {
send({ type: 'progress', current, total, label });
}
);
const { contenidoMarkdown: _ignore, planos: rawPlanos, ...frontmatterData } = 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));
send({ type: 'done', result: { ...frontmatterData, contenidoMarkdown: mdContent, planos: planosSaved } });
} catch (error: any) {
send({ type: 'error', message: error.message });
}
controller.close();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
});
}
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);
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en escaleta:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
function generateEscaletaMarkdown(escaleta: any): string {
let md = `# Escaleta: ${escaleta.capitulo}\n\n`;
md += `**Virtud: ${escaleta.virtudGanada}** | Duración total: ${escaleta.duracionTotalSegundos}s\n\n`;
md += `| # | Plano | Segmento | Tiempo | Personajes | Acción | Sonido |\n`;
md += `|---|-------|----------|--------|------------|--------|--------|\n`;
if (escaleta.planos) {
for (let i = 0; i < escaleta.planos.length; i++) {
const p = escaleta.planos[i];
const personajesStr = Array.isArray(p.personajesDetalle) && p.personajesDetalle.length > 0
? p.personajesDetalle.map((pd: PersonajeEnPlano) => `${pd.nombre}(${pd.emocion})`).join(', ')
: '-';
md += `| ${i + 1} | ${p.numeroPlano} | ${p.segmentoGuion} | ${p.tiempoSegundos}s | ${personajesStr} | ${p.accionDescripcion} | ${p.indicacionesSonido} |\n`;
}
}
return md;
}

View File

@@ -0,0 +1,43 @@
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; fondoId: string }> }
) {
try {
const { slug, numero, fondoId } = 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 imagePath = path.join(capitulosDir, capDir.name, 'fondos', `${fondoId}.png`);
try {
const imageBuffer = await fs.readFile(imagePath);
return new NextResponse(new Uint8Array(imageBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-cache',
},
});
} catch {
return NextResponse.json({ error: 'Imagen no encontrada' }, { status: 404 });
}
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,267 @@
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 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 []; }
// 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 }
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 });
}
}
// 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({
id: `fondo-${segId}`,
segmentoId: segId,
nombre,
descripcion,
promptGeneracion: '',
});
}
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[],
slug: string,
capDirName: string
): Promise<FondoReferencia[]> {
const fondosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'fondos');
return Promise.all(fondos.map(async (fondo) => {
try {
await fs.access(path.join(fondosDir, `${fondo.id}.png`));
return {
...fondo,
imagenUrl: `/api/projects/${slug}/capitulos/${capDirName}/fondos/${fondo.id}`,
};
} catch {
return { ...fondo, imagenUrl: undefined };
}
}));
}
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) {
return NextResponse.json(null);
}
const capPath = path.join(capitulosDir, capDir.name);
const fondos = await loadFondos(capPath);
const enriched = await enrichWithImageStatus(fondos, slug, capDir.name);
const generated = enriched.filter(f => f.imagenUrl).length;
return NextResponse.json({ fondos: enriched, total: enriched.length, generated });
} catch (error: any) {
console.error('Error fetching fondos:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
const { action, fondoId, descripcion, promptGeneracion } = 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);
let fondos = await loadFondos(capPath);
if (action === 'generate') {
if (!fondoId) {
return NextResponse.json({ error: 'Se necesita fondoId' }, { status: 400 });
}
const fondoIdx = fondos.findIndex(f => f.id === fondoId);
if (fondoIdx === -1) {
return NextResponse.json({ error: 'Fondo no encontrado' }, { status: 404 });
}
const fondo = fondos[fondoIdx];
const ideaMotriz = await getIdeaMotriz(slug);
const estilo = ideaMotriz?.estiloVisual || DEFAULT_ESTILO;
// 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');
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);
return NextResponse.json({ fondo: fondos[fondoIdx] });
}
if (action === 'generate-all') {
const ideaMotriz = await getIdeaMotriz(slug);
const estilo = ideaMotriz?.estiloVisual || DEFAULT_ESTILO;
const fondosDir = path.join(capPath, 'fondos');
await fs.mkdir(fondosDir, { recursive: true });
const results: { id: string; success: boolean; error?: string }[] = [];
for (let i = 0; i < fondos.length; i++) {
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}`,
};
results.push({ id: fondo.id, success: true });
} catch (err: any) {
console.error(`Error generando fondo ${fondo.id}:`, err);
results.push({ id: fondo.id, success: false, error: err.message });
}
}
await saveFondos(capPath, fondos);
const enriched = await enrichWithImageStatus(fondos, slug, capDir.name);
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]));
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);
const generated = enriched.filter(f => f.imagenUrl).length;
return NextResponse.json({ fondos: enriched, total: enriched.length, generated });
}
if (action === 'update-description') {
if (!fondoId) {
return NextResponse.json({ error: 'Se necesita fondoId' }, { status: 400 });
}
const fondoIdx = fondos.findIndex(f => f.id === fondoId);
if (fondoIdx === -1) {
return NextResponse.json({ error: 'Fondo no encontrado' }, { status: 404 });
}
fondos[fondoIdx] = {
...fondos[fondoIdx],
...(descripcion !== undefined && { descripcion }),
...(promptGeneracion !== undefined && { promptGeneracion }),
};
await saveFondos(capPath, fondos);
return NextResponse.json({ fondo: fondos[fondoIdx] });
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en fondos:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,219 @@
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'))
);
}
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) {
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 {
return NextResponse.json(null);
}
} catch (error: any) {
console.error('Error fetching guion:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
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);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const personajes = await 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));
// 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);
// 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 });
}
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 {
return NextResponse.json({ error: 'No hay guión existente para regenerar actos' }, { status: 400 });
}
const ideaMotriz = await getIdeaMotriz(slug);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const personajes = await getPersonajes(slug);
if (personajes.length === 0) {
return NextResponse.json({ error: 'Primero debes crear los personajes' }, { status: 400 });
}
const { regenerateGuionActos } = await import('@/lib/llm-service');
const guionMetadata = {
titulo: currentGuion.titulo || '',
duracionTotal: currentGuion.duracionTotal || '20:00',
virtudGanada: currentGuion.virtudGanada || '',
nudoDelMundo: currentGuion.nudoDelMundo || '',
aliado: currentGuion.aliado || { nombre: '', descripcion: '' },
gemaGanada: currentGuion.gemaGanada || '',
};
const result = await regenerateGuionActos(guionMetadata, ideaMotriz, personajes, parseInt(numero));
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);
// 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 });
}
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);
return NextResponse.json({ success: true });
}
if (action === 'save-fields') {
const guionData = body.guionData;
if (!guionData) {
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);
// 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({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en guion:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
function generateGuionMarkdown(guion: any): string {
let md = `# ${guion.titulo}\n\n`;
md += `**VIRTUD GANADA: ${guion.virtudGanada?.toUpperCase()}**\n\n`;
md += `## Introducción\n\n`;
md += `| Categoría | Detalle |\n|-----------|--------|\n`;
md += `| **Duración Total** | ${guion.duracionTotal} |\n`;
md += `| **Nudo del Mundo** | ${guion.nudoDelMundo} |\n`;
md += `| **Aliado** | ${guion.aliado?.nombre}${guion.aliado?.descripcion} |\n`;
md += `| **Gema Ganada** | ${guion.gemaGanada} |\n\n---\n\n`;
if (guion.actos) {
for (const acto of guion.actos) {
md += `## ACTO ${acto.numero}: ${acto.nombre?.toUpperCase()} (${acto.tiempoInicio} - ${acto.tiempoFin})\n\n`;
if (acto.segmentos) {
for (const seg of acto.segmentos) {
md += `### ${seg.id} ${seg.nombre} (${seg.tiempo})\n`;
md += `| Tiempo | Acción Visual Clave | Diseño de Fondo | Diseño de Sonido |\n`;
md += `|--------|---------------------|-----------------|------------------|\n`;
md += `| ${seg.tiempo} | ${seg.accionVisualClave} | ${seg.disenoFondo || ''} | ${seg.disenoSonido} |\n\n`;
}
}
md += `---\n\n`;
}
}
return md;
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { loadPlanoImage } from '@/lib/image-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'))
);
}
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 imageBuffer = await loadPlanoImage(slug, capDir.name, planoId);
if (!imageBuffer) {
return NextResponse.json({ error: 'Imagen no encontrada' }, { status: 404 });
}
return new NextResponse(new Uint8Array(imageBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-cache',
},
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,316 @@
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 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[],
slug: string,
capDirName: string
): Promise<PlanoConPrompt[]> {
return Promise.all(planos.map(async (plano) => {
try {
await fs.access(getPlanoImagePath(slug, capDirName, plano.id));
return {
...plano,
imagenUrl: `/api/projects/${slug}/capitulos/${capDirName}/imagenes/${plano.id}`,
};
} catch {
return { ...plano, imagenUrl: undefined };
}
}));
}
/** Load reference images from disk for a plano: 3 T-poses + emotion per character + fondo */
async function loadReferenceImages(
slug: string,
capDirName: string,
refs: ReferenceAsset[],
fondoId?: string
): Promise<{ buffer: Buffer; label: string }[]> {
const images: { buffer: Buffer; label: string }[] = [];
// Load fondo reference image first (if available)
if (fondoId) {
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 (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()!;
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);
if (buffer) {
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);
if (buffer) {
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);
if (buffer) {
images.push({ buffer, label: `${ref.nombre} - ${ref.emocion}` });
}
}
}
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) {
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);
}
} catch (error: any) {
console.error('Error fetching imagenes:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
const { action, planoId, composition } = 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);
let planos: PlanoConPrompt[];
try {
const result = await loadPlanos(capPath);
planos = result.planos;
} catch {
return NextResponse.json(
{ error: 'No se encontraron planos. Genera la escaleta primero.' },
{ status: 400 }
);
}
if (action === 'generate') {
if (!planoId) {
return NextResponse.json({ error: 'Se necesita planoId' }, { status: 400 });
}
const planoIdx = planos.findIndex(p => p.id === planoId);
if (planoIdx === -1) {
return NextResponse.json({ error: 'Plano no encontrado' }, { status: 404 });
}
const plano = planos[planoIdx];
if (!plano.promptImagen) {
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 imageBuffer = await generateSceneImage(
refs,
plano.promptImagen,
{ aspectRatio: '16:9' }
);
const imagenUrl = await savePlanoImage(slug, capDir.name, plano.id, imageBuffer);
planos[planoIdx] = { ...plano, imagenUrl };
await savePromptPlanos(capPath, planos);
return NextResponse.json({ plano: planos[planoIdx] });
}
if (action === 'generate-all') {
const results: { id: string; success: boolean; error?: string }[] = [];
for (let i = 0; i < planos.length; i++) {
const plano = planos[i];
if (!plano.promptImagen) {
results.push({ id: plano.id, success: false, error: 'Sin prompt' });
continue;
}
try {
const refs = await loadReferenceImages(slug, capDir.name, plano.referenceAssets || [], plano.fondoId);
const imageBuffer = await generateSceneImage(
refs,
plano.promptImagen,
{ aspectRatio: '16:9' }
);
const imagenUrl = await savePlanoImage(slug, capDir.name, plano.id, imageBuffer);
planos[i] = { ...plano, imagenUrl };
await savePromptPlanos(capPath, planos);
results.push({ id: plano.id, success: true });
} catch (err: any) {
console.error(`Error generando imagen para ${plano.id}:`, err);
results.push({ id: plano.id, success: false, error: err.message });
}
}
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, results });
}
if (action === 'save-composition') {
if (!planoId || !composition) {
return NextResponse.json({ error: 'Se necesita planoId y composition' }, { status: 400 });
}
const planoIdx = planos.findIndex(p => p.id === planoId);
if (planoIdx === -1) {
return NextResponse.json({ error: 'Plano no encontrado' }, { status: 404 });
}
const promptComposition: PromptComposition = composition;
const promptImagen = composePrompt(promptComposition);
planos[planoIdx] = { ...planos[planoIdx], promptComposition, promptImagen };
await savePromptPlanos(capPath, planos);
return NextResponse.json({ plano: planos[planoIdx] });
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en imagenes:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
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[] = [];
let planoCounter = 1;
let totalSeconds = 0;
for (const acto of actos) {
for (let segIdx = 0; segIdx < acto.segmentos.length; segIdx++) {
const seg = acto.segmentos[segIdx];
const duracion = parseTimeRange(seg.tiempo);
const numPlanos = Math.max(2, Math.round(duracion / 5));
totalSeconds += duracion;
const planos: PreEscaletaPlano[] = [];
for (let i = 0; i < numPlanos; i++) {
planos.push({
id: `plano-${String(planoCounter).padStart(3, '0')}`,
numeroPlano: `${acto.numero}.${segIdx + 1}.${i + 1}`,
nombre: `Plano ${acto.numero}.${segIdx + 1}.${i + 1}`,
segmentoId: seg.id,
tiempoSegundos: 5,
});
planoCounter++;
}
segmentos.push({
segmentoId: seg.id,
nombre: seg.nombre,
actoNumero: acto.numero,
segmentoIndex: segIdx,
tiempo: seg.tiempo,
duracionSegundos: duracion,
totalPlanos: numPlanos,
planos,
});
}
}
return {
capitulo: capituloTitulo,
duracionTotalSegundos: totalSeconds,
totalPlanos: planoCounter - 1,
segmentos,
};
}
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) {
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);
}
} catch (error: any) {
console.error('Error fetching pre-escaleta:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
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');
if (action === 'generate') {
if (!guion?.actos || guion.actos.length === 0) {
return NextResponse.json(
{ error: 'El guión conductor no tiene actos estructurados. Regenera el guión primero.' },
{ status: 400 }
);
}
const preEscaleta = buildPreEscaleta(guion.actos, guion.titulo || '');
await fs.writeFile(preEscaletaPath, JSON.stringify(preEscaleta, null, 2));
return NextResponse.json(preEscaleta);
}
if (action === 'save') {
const { preEscaleta } = await request.json().catch(() => ({ preEscaleta: null }));
if (preEscaleta) {
await fs.writeFile(preEscaletaPath, JSON.stringify(preEscaleta, null, 2));
return NextResponse.json({ success: true });
}
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en pre-escaleta:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,403 @@
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;
}
/** Map segmentoGuion string (e.g. "I.1 El Silencio") to fondoId */
function getFondoIdForPlano(plano: any, fondos: FondoReferencia[]): string | undefined {
const segGuion: string = plano.segmentoGuion || '';
const segId = segGuion.split(' ')[0];
const fondo = fondos.find(f => f.segmentoId === segId);
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 */
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),
accion: ensureString(llmComp.accion),
luz: ensureString(llmComp.luz),
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 };
}
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) {
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);
}
} catch (error: any) {
console.error('Error fetching prompts:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; numero: string }> }
) {
try {
const { slug, numero } = await params;
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 */ }
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 {
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 { generateImagePrompts } = await import('@/lib/llm-service');
const escaletaForLLM = { ...escaletaMeta, planos } as any;
const planosConPrompts = await generateImagePrompts(escaletaForLLM, personajes);
// Enrich each plano
const planosEnriched = planosConPrompts.map((plano: any) =>
enrichPlano(plano, estiloVisual, assetsMap, 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));
const mdContent = generatePromptsMarkdown(planosEnriched);
const frontmatter = {
capitulo: escaletaMeta.capitulo || '',
totalPrompts: planosEnriched.length,
};
const fileContent = matter.stringify(mdContent, frontmatter);
await fs.writeFile(promptsPath, fileContent);
return NextResponse.json({ ...frontmatter, 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 {
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 encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: any) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch { /* client disconnected */ }
};
try {
const { generateImagePrompts } = await import('@/lib/llm-service');
const escaletaForLLM = { ...escaletaMeta, planos: streamPlanos } as any;
const planosConPrompts = await generateImagePrompts(escaletaForLLM, personajes,
(current, total, label) => {
send({ type: 'progress', current, total, label });
}
);
const planosEnriched = planosConPrompts.map((plano: any) =>
enrichPlano(plano, estiloVisual, assetsMap, fondos)
);
const promptsPlanosPath = path.join(capPath, 'prompts-planos.json');
await fs.writeFile(promptsPlanosPath, JSON.stringify(planosEnriched, null, 2));
const mdContent = generatePromptsMarkdown(planosEnriched);
const frontmatter = {
capitulo: escaletaMeta.capitulo || '',
totalPrompts: planosEnriched.length,
};
const fileContent = matter.stringify(mdContent, frontmatter);
await fs.writeFile(promptsPath, fileContent);
send({ type: 'done', result: { ...frontmatter, contenidoMarkdown: mdContent, planos: planosEnriched } });
} catch (error: any) {
send({ type: 'error', message: error.message });
}
controller.close();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
});
}
if (action === 'generate-single') {
if (!planoId) {
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 {
return NextResponse.json({ error: 'No se encontraron planos. Regenera la escaleta.' }, { status: 400 });
}
const targetPlano = escaletaPlanos.find((p: any) => p.id === planoId);
if (!targetPlano) {
return NextResponse.json({ error: 'Plano no encontrado' }, { status: 404 });
}
const personajes = await getPersonajes(slug);
const assetsMap = await getCharacterAssetsMap(slug, personajes);
// 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);
const planoConPrompt = result[0];
if (!planoConPrompt) {
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 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));
return NextResponse.json({ plano: enrichedPlano });
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en prompts:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
function generatePromptsMarkdown(planos: any[]): string {
let md = `# Prompts de Imágenes\n\n`;
md += `Total: ${planos.length} prompts generados\n\n`;
for (const plano of planos) {
md += `## Plano ${plano.numeroPlano}\n\n`;
md += `**Segmento:** ${plano.segmentoGuion} | **Duración:** ${plano.tiempoSegundos}s\n\n`;
const detalle: PersonajeEnPlano[] = plano.personajesDetalle || [];
if (detalle.length > 0) {
md += `**Personajes:**\n`;
for (const pd of detalle) {
md += `- ${pd.nombre} — *${pd.emocion}*\n`;
}
md += `\n`;
}
md += `**Acción:** ${plano.accionDescripcion}\n\n`;
if (plano.promptImagen) {
md += `> **Prompt final:** ${plano.promptImagen}\n\n`;
}
// Show composition sub-fields
const comp = plano.promptComposition;
if (comp) {
md += `**Composición:**\n`;
if (comp.tipoPlano) md += `- Tipo plano: ${comp.tipoPlano}\n`;
if (comp.camara) md += `- Cámara: ${comp.camara}\n`;
if (comp.optica) md += `- Óptica: ${comp.optica}\n`;
if (comp.luz) md += `- Luz: ${comp.luz}\n`;
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`;
}
md += `---\n\n`;
}
return md;
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCapitulos, createCapitulo, getIdeaMotriz, getPersonajes } from '@/lib/file-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const capitulos = await getCapitulos(slug);
return NextResponse.json(capitulos);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const { numero, titulo, virtudSugerida } = await request.json();
// Crear el capítulo
const capitulo = await createCapitulo(slug, numero, titulo || `Capítulo ${numero}`);
// Si se solicita, generar el guión conductor automáticamente
if (virtudSugerida !== undefined) {
const ideaMotriz = await getIdeaMotriz(slug);
const personajes = await getPersonajes(slug);
if (ideaMotriz && personajes.length > 0) {
const { generateGuionConductor } = await import('@/lib/llm-service');
await generateGuionConductor(ideaMotriz, personajes, numero, virtudSugerida);
}
}
return NextResponse.json(capitulo);
} catch (error: any) {
console.error('Error creando capítulo:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { getIdeaMotriz, saveIdeaMotriz } from '@/lib/file-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const ideaMotriz = await getIdeaMotriz(slug);
return NextResponse.json(ideaMotriz);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const { action, input, current } = await request.json();
let ideaMotriz;
if (action === 'generate') {
const { generateIdeaMotriz } = await import('@/lib/llm-service');
ideaMotriz = await generateIdeaMotriz(input);
} else if (action === 'refine' && current) {
const { refineIdeaMotriz } = await import('@/lib/llm-service');
ideaMotriz = await refineIdeaMotriz(current, input);
} else if (action === 'save') {
await saveIdeaMotriz(slug, input);
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
}
await saveIdeaMotriz(slug, ideaMotriz);
return NextResponse.json(ideaMotriz);
} catch (error: any) {
console.error('Error en idea-motriz:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,198 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getPersonajes } from '@/lib/file-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));
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
try {
const { slug, id } = await params;
const data = await getData(slug, id);
return NextResponse.json(data);
} catch (error: any) {
console.error('Error fetching assets:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
try {
const { slug, id } = await params;
const body = await request.json();
const { action } = body;
// 1. Generate text prompts via LLM
if (action === 'generate-prompts') {
const personajes = await 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);
return NextResponse.json(data);
}
// 2. Generate base image (text-to-image)
if (action === 'generate-base-image') {
const { prompt: customPrompt } = body;
const data = await getData(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 prompt = customPrompt || baseAsset.prompt;
baseAsset.status = 'generating';
await saveData(slug, id, data);
const imageBuffer = await generateImage(prompt);
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);
return NextResponse.json(data);
}
// 3. Lock the base image as canonical reference
if (action === 'lock-base') {
const data = await getData(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);
return NextResponse.json(data);
}
// 4. Generate a single variation from base reference
if (action === 'generate-variation') {
const { assetId } = body;
if (!assetId) {
return NextResponse.json({ error: 'Se necesita assetId' }, { status: 400 });
}
const data = await getData(slug, id);
if (!data.baseLocked) {
return NextResponse.json({ error: 'Primero fija la imagen base.' }, { status: 400 });
}
const baseAsset = data.assets.find(a => a.isBase);
const targetAsset = data.assets.find(a => a.id === assetId);
if (!baseAsset || !targetAsset) {
return NextResponse.json({ error: 'Asset no encontrado' }, { status: 404 });
}
const refImage = await loadImage(slug, id, baseAsset.id);
if (!refImage) {
return NextResponse.json({ error: 'Imagen base no encontrada en disco' }, { status: 400 });
}
targetAsset.status = 'generating';
await saveData(slug, id, data);
const imageBuffer = await generateVariation(refImage, targetAsset.prompt);
const imageUrl = await saveImage(slug, id, targetAsset.id, imageBuffer);
targetAsset.imageUrl = imageUrl;
targetAsset.status = 'generated';
await saveData(slug, id, data);
return NextResponse.json(data);
}
// 5. Generate all remaining variations
if (action === 'generate-all-variations') {
const data = await getData(slug, id);
if (!data.baseLocked) {
return NextResponse.json({ error: 'Primero fija la imagen base.' }, { status: 400 });
}
const baseAsset = data.assets.find(a => a.isBase);
if (!baseAsset) {
return NextResponse.json({ error: 'No hay asset base' }, { status: 400 });
}
const refImage = await loadImage(slug, id, baseAsset.id);
if (!refImage) {
return NextResponse.json({ error: 'Imagen base no encontrada en disco' }, { status: 400 });
}
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);
const imageUrl = await saveImage(slug, id, asset.id, imageBuffer);
asset.imageUrl = imageUrl;
asset.status = 'generated';
await saveData(slug, id, data);
} catch (err: any) {
console.error(`Error generando variación ${asset.id}:`, err);
asset.status = 'prompt_ready';
await saveData(slug, id, data);
}
}
return NextResponse.json(data);
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en assets:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { loadImage } from '@/lib/image-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string; assetId: string }> }
) {
try {
const { slug, id, assetId } = await params;
const imageBuffer = await loadImage(slug, id, assetId);
if (!imageBuffer) {
return NextResponse.json({ error: 'Imagen no encontrada' }, { status: 404 });
}
return new NextResponse(new Uint8Array(imageBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-cache',
},
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPersonajes, getCharacterAssetsMap } from '@/lib/file-service';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const personajes = await getPersonajes(slug);
const assetsMap = await getCharacterAssetsMap(slug, personajes);
return NextResponse.json(assetsMap);
} catch (error: any) {
console.error('Error fetching assets summary:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPersonajes, savePersonajes, getIdeaMotriz } from '@/lib/file-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const personajes = await getPersonajes(slug);
return NextResponse.json(personajes);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const { action, personaje, feedback, personajes: inputPersonajes, nuevoPersonaje, ideaMotriz: inputIdeaMotriz } = await request.json();
if (action === 'generate') {
const ideaMotriz = await getIdeaMotriz(slug);
if (!ideaMotriz) {
return NextResponse.json({ error: 'Primero debes crear la Idea Motriz' }, { status: 400 });
}
const { generatePersonajes } = await import('@/lib/llm-service');
const personajes = await generatePersonajes(ideaMotriz);
await savePersonajes(slug, personajes);
return NextResponse.json(personajes);
}
if (action === 'refine' && personaje && feedback) {
const { refinePersonaje } = await import('@/lib/llm-service');
const refined = await refinePersonaje(personaje, feedback);
const current = await getPersonajes(slug);
const updated = current.map(p => p.id === refined.id ? refined : p);
await savePersonajes(slug, updated);
return NextResponse.json(refined);
}
if (action === 'save' && inputPersonajes) {
await savePersonajes(slug, inputPersonajes);
return NextResponse.json({ success: true });
}
if (action === 'improve' && feedback && inputPersonajes) {
const { improvePersonajes } = await import('@/lib/llm-service');
const improved = await improvePersonajes(inputPersonajes, feedback);
await savePersonajes(slug, improved);
return NextResponse.json(improved);
}
if (action === 'add' && nuevoPersonaje) {
const ideaMotriz = inputIdeaMotriz || await getIdeaMotriz(slug);
const { generateNewPersonaje } = await import('@/lib/llm-service');
const newPersonaje = await generateNewPersonaje(nuevoPersonaje, ideaMotriz, inputPersonajes || []);
const current = inputPersonajes || await getPersonajes(slug);
const updated = [...current, newPersonaje];
await savePersonajes(slug, updated);
return NextResponse.json(newPersonaje);
}
return NextResponse.json({ error: 'Acción inválida' }, { status: 400 });
} catch (error: any) {
console.error('Error en personajes:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { getProject, updateProject, deleteProject } from '@/lib/file-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const project = await getProject(slug);
if (!project) {
return NextResponse.json({ error: 'Proyecto no encontrado' }, { status: 404 });
}
return NextResponse.json(project);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const updates = await request.json();
const project = await updateProject(slug, updates);
return NextResponse.json(project);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
await deleteProject(slug);
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { listProjects, createProject } from '@/lib/file-service';
export async function GET() {
try {
const projects = await listProjects();
return NextResponse.json(projects);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { name } = await request.json();
if (!name) {
return NextResponse.json({ error: 'El nombre es requerido' }, { status: 400 });
}
const project = await createProject(name);
return NextResponse.json(project);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,26 +1,93 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #FAFAF8;
--foreground: #1C1917;
--surface: #FFFFFF;
--surface-raised: #FFFFFF;
--border: #E7E5E4;
--border-subtle: #F5F5F4;
--text-primary: #1C1917;
--text-secondary: #57534E;
--text-tertiary: #A8A29E;
--accent: #4338CA;
--accent-hover: #3730A3;
--accent-subtle: #EEF2FF;
--accent-text: #4338CA;
--success: #15803D;
--success-subtle: #F0FDF4;
--danger: #DC2626;
--danger-subtle: #FEF2F2;
--warm: #D97706;
--warm-subtle: #FFFBEB;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-surface: var(--surface);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-subtle: var(--accent-subtle);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar sutil */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #D6D3D1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #A8A29E;
}
/* Focus ring consistente */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* Animaciones */
@keyframes fade-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-scale-in {
animation: scale-in 0.2s ease-out;
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Story Studio",
description: "Herramienta de creación de series animadas con IA",
};
export default function RootLayout({

View File

@@ -1,64 +1,184 @@
import Image from "next/image";
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Plus, Film, Clock, ChevronRight, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import type { Project } from '@/types/project';
export default function Home() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
try {
const res = await fetch('/api/projects');
const data = await res.json();
setProjects(data);
} catch (error) {
console.error('Error fetching projects:', error);
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
});
if (res.ok) {
setNewName('');
setShowCreate(false);
fetchProjects();
}
} catch (error) {
console.error('Error creating project:', error);
} finally {
setCreating(false);
}
};
const handleDelete = async (slug: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!confirm('¿Estás seguro de eliminar este proyecto?')) return;
try {
await fetch(`/api/projects/${slug}`, { method: 'DELETE' });
fetchProjects();
} catch (error) {
console.error('Error deleting project:', error);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-800';
case 'in_progress': return 'bg-blue-100 text-blue-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getStepLabel = (step: string) => {
switch (step) {
case 'idea_motriz': return 'Idea Motriz';
case 'personajes': return 'Personajes';
case 'capitulos': return 'Capítulos';
default: return step;
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="min-h-screen bg-[var(--background)]">
<header className="border-b border-stone-200/60 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-stone-900 tracking-tight">Story Studio</h1>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-3.5 h-3.5" />
Nuevo proyecto
</Button>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</header>
{showCreate && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
<Card className="w-full max-w-md mx-4 shadow-xl shadow-black/10 animate-scale-in">
<CardHeader>
<h2 className="text-base font-semibold text-stone-900">Nuevo proyecto</h2>
<p className="text-sm text-stone-500 mt-0.5">Dale un nombre a tu serie animada</p>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="Nombre"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Ej: Los Guardianes del Mundo Abierto"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<div className="flex gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={() => setShowCreate(false)}>
Cancelar
</Button>
<Button size="sm" onClick={handleCreate} loading={creating}>
Crear
</Button>
</div>
</CardContent>
</Card>
</div>
)}
<main className="max-w-5xl mx-auto px-6 py-10">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-5 h-5 border-2 border-indigo-600 border-t-transparent rounded-full" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-24">
<p className="text-stone-400 text-sm mb-6">Todavía no tienes proyectos</p>
<Button onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4" />
Crear tu primer proyecto
</Button>
</div>
) : (
<div className="space-y-1">
<div className="px-3 pb-3">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wider">
{projects.length} {projects.length === 1 ? 'proyecto' : 'proyectos'}
</span>
</div>
{projects.map((project) => (
<Link key={project.id} href={`/project/${project.slug}`} className="block group">
<div className="flex items-center gap-4 px-4 py-3.5 rounded-lg hover:bg-white border border-transparent hover:border-stone-200/80 transition-all duration-150">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3 className="font-medium text-stone-800 group-hover:text-stone-950 truncate transition-colors">
{project.name}
</h3>
<span className={`flex-shrink-0 px-2 py-0.5 text-[11px] font-medium rounded ${getStatusColor(project.status)}`}>
{project.status === 'completed' ? 'Completado' : project.status === 'in_progress' ? 'En progreso' : 'Borrador'}
</span>
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-stone-400">
{getStepLabel(project.currentStep)}
</span>
<span className="text-stone-300">&middot;</span>
<span className="text-xs text-stone-400">
{new Date(project.updatedAt).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })}
</span>
</div>
</div>
<button
onClick={(e) => handleDelete(project.slug, e)}
className="opacity-0 group-hover:opacity-100 p-1.5 text-stone-300 hover:text-red-500 hover:bg-red-50 rounded transition-all"
title="Eliminar proyecto"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<ChevronRight className="w-4 h-4 text-stone-300 group-hover:text-stone-400 transition-colors" />
</div>
</Link>
))}
</div>
)}
</main>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
'use client';
import { useState, use } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/Card';
import { Input, Textarea } from '@/components/ui/Input';
export default function NewCapituloPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const router = useRouter();
const [numero, setNumero] = useState(1);
const [titulo, setTitulo] = useState('');
const [virtudSugerida, setVirtudSugerida] = useState('');
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
setCreating(true);
try {
const res = await fetch(`/api/projects/${slug}/capitulos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ numero, titulo, virtudSugerida }),
});
if (res.ok) {
router.push(`/project/${slug}/capitulo/${numero}`);
}
} catch (error) {
console.error('Error creating chapter:', error);
} finally {
setCreating(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<header className="bg-white border-b border-gray-200">
<div className="max-w-3xl mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<Link href={`/project/${slug}`} className="text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-xl font-bold text-gray-900">Nuevo Capítulo</h1>
</div>
</div>
</header>
<main className="max-w-3xl mx-auto px-4 py-8">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">Crear nuevo capítulo</h2>
<p className="text-sm text-gray-500">
Define los datos básicos y la IA generará el guión conductor.
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<Input
label="Número de capítulo"
type="number"
min={1}
value={numero}
onChange={(e) => setNumero(parseInt(e.target.value) || 1)}
/>
<Input
label="Título (opcional)"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Se generará si lo dejas vacío"
/>
</div>
<Textarea
label="Virtud sugerida (opcional)"
value={virtudSugerida}
onChange={(e) => setVirtudSugerida(e.target.value)}
placeholder="Ej: Paciencia, Valentía, Generosidad..."
rows={2}
/>
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900">Generación con IA</h4>
<p className="text-sm text-blue-700 mt-1">
Al crear el capítulo, la IA generará automáticamente el guión conductor
basándose en la Idea Motriz y los personajes de tu serie.
</p>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end gap-3">
<Link href={`/project/${slug}`}>
<Button variant="outline">Cancelar</Button>
</Link>
<Button onClick={handleCreate} loading={creating}>
<Sparkles className="w-4 h-4 mr-2" />
Crear y generar guión
</Button>
</CardFooter>
</Card>
</main>
</div>
);
}

View File

@@ -0,0 +1,387 @@
'use client';
import { useState, use } from 'react';
import Link from 'next/link';
import { ArrowLeft, Sparkles, Users, Film, ChevronRight, Plus, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Textarea } from '@/components/ui/Input';
import { Toast } from '@/components/ui/Toast';
import { WizardStepper } from '@/components/wizard/WizardStepper';
import { GenerationPanel } from '@/components/wizard/GenerationPanel';
import { IdeaMotrizView } from '@/components/project/IdeaMotrizView';
import { IdeaMotrizEditor } from '@/components/project/IdeaMotrizEditor';
import { PersonajesView } from '@/components/project/PersonajesView';
import { PersonajeEditor } from '@/components/project/PersonajeEditor';
import { CharacterAssetsPanel } from '@/components/project/CharacterAssetsPanel';
import { useProjectData } from '@/hooks/useProjectData';
import type { Personaje } from '@/types/project';
const WIZARD_STEPS = [
{ id: 'idea_motriz', name: 'Idea Motriz', description: 'Concepto y estructura' },
{ id: 'personajes', name: 'Personajes', description: 'Protagonistas y antagonista' },
{ id: 'capitulos', name: 'Capítulos', description: 'Guiones y escaletas' },
];
export default function ProjectPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const {
project,
ideaMotriz,
personajes,
capitulos,
currentStep,
loading,
generating,
setIdeaMotriz,
changeStep,
canAdvance,
handleGenerateIdeaMotriz,
handleRefineIdeaMotriz,
handleSaveIdeaMotriz,
handleGeneratePersonajes,
handleSavePersonajes,
handleMejorarPersonajes,
handleAddPersonaje,
handleUpdatePersonaje,
handleDeletePersonaje,
toast,
clearToast,
} = useProjectData(slug);
const [personajeFeedback, setPersonajeFeedback] = useState('');
const [showAddPersonaje, setShowAddPersonaje] = useState(false);
const [nuevoPersonaje, setNuevoPersonaje] = useState({
nombre: '',
tipo: 'aliado' as 'protagonista' | 'aliado' | 'antagonista',
especie: '',
aparienciaFisica: '',
descripcion: ''
});
const [editingPersonaje, setEditingPersonaje] = useState<Personaje | null>(null);
const [assetsPersonaje, setAssetsPersonaje] = useState<Personaje | null>(null);
const [editingIdeaMotriz, setEditingIdeaMotriz] = useState(false);
const onMejorarPersonajes = async () => {
await handleMejorarPersonajes(personajeFeedback);
setPersonajeFeedback('');
};
const onAddPersonaje = async () => {
const result = await handleAddPersonaje(nuevoPersonaje);
if (result) {
setNuevoPersonaje({ nombre: '', tipo: 'aliado', especie: '', aparienciaFisica: '', descripcion: '' });
setShowAddPersonaje(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
<div className="animate-spin w-5 h-5 border-2 border-indigo-600 border-t-transparent rounded-full" />
</div>
);
}
if (!project) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8 text-center">
<h2 className="text-xl font-semibold mb-4">Proyecto no encontrado</h2>
<Link href="/">
<Button>Volver al inicio</Button>
</Link>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--background)]">
{toast && <Toast message={toast.message} type={toast.type} onClose={clearToast} />}
<header className="border-b border-stone-200/60 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-6 py-3 flex items-center gap-4">
<Link href="/" className="p-1 -ml-1 text-stone-400 hover:text-stone-600 transition-colors">
<ArrowLeft className="w-4 h-4" />
</Link>
<div className="min-w-0">
<h1 className="text-sm font-semibold text-stone-900 truncate">{project.name}</h1>
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-8">
<WizardStepper
steps={WIZARD_STEPS}
currentStep={currentStep}
onStepClick={(index) => {
if (index <= currentStep || (index === currentStep + 1 && canAdvance())) {
changeStep(index);
}
}}
/>
<div className="mt-8">
{/* Step 0: Idea Motriz */}
{currentStep === 0 && (
<GenerationPanel
title="Idea Motriz"
description="Define el concepto central, formato, narrativa y estructura de tu serie animada."
placeholder="Describe tu idea para la serie. Por ejemplo: Una serie sobre dos gatitos que coleccionan virtudes para sanar el mundo. Sin diálogos, solo expresiones y sonidos. Cada capítulo ganan una virtud nueva..."
currentData={ideaMotriz}
onGenerate={handleGenerateIdeaMotriz}
onRefine={handleRefineIdeaMotriz}
onSave={handleSaveIdeaMotriz}
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)}
onClose={() => setEditingIdeaMotriz(false)}
/>
)}
</>
)}
</GenerationPanel>
)}
{/* Step 1: Personajes */}
{currentStep === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-stone-900">Personajes</h2>
<p className="mt-1 text-stone-500 text-sm">
Define los personajes principales, sus roles y mecánicas.
</p>
</div>
{personajes.length === 0 ? (
<div className="text-center py-16">
<p className="text-stone-400 text-sm mb-5">
Basado en la Idea Motriz, la IA generará los personajes.
</p>
<Button onClick={handleGeneratePersonajes} loading={generating}>
<Sparkles className="w-4 h-4" />
Generar personajes
</Button>
</div>
) : (
<>
<div className="flex justify-between">
<Button variant="outline" size="sm" onClick={() => setShowAddPersonaje(!showAddPersonaje)}>
<Plus className="w-3.5 h-3.5" />
Añadir
</Button>
<Button size="sm" onClick={handleSavePersonajes} loading={generating}>
Guardar cambios
</Button>
</div>
{showAddPersonaje && (
<Card className="border-dashed border-stone-300 animate-slide-up">
<CardHeader>
<h3 className="text-sm font-medium text-stone-700">Nuevo personaje</h3>
</CardHeader>
<CardContent className="space-y-3">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Nombre</label>
<input
type="text"
value={nuevoPersonaje.nombre}
onChange={(e) => setNuevoPersonaje({...nuevoPersonaje, nombre: e.target.value})}
placeholder="Ej: Búho Sabio"
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Tipo</label>
<select
value={nuevoPersonaje.tipo}
onChange={(e) => setNuevoPersonaje({...nuevoPersonaje, tipo: e.target.value as any})}
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
>
<option value="aliado">Aliado</option>
<option value="protagonista">Protagonista</option>
<option value="antagonista">Antagonista</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Especie</label>
<input
type="text"
value={nuevoPersonaje.especie}
onChange={(e) => setNuevoPersonaje({...nuevoPersonaje, especie: e.target.value})}
placeholder="Ej: búho, zorro..."
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Apariencia</label>
<Textarea
value={nuevoPersonaje.aparienciaFisica}
onChange={(e) => setNuevoPersonaje({...nuevoPersonaje, aparienciaFisica: e.target.value})}
placeholder="Color, tamaño, rasgos..."
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Rol</label>
<Textarea
value={nuevoPersonaje.descripcion}
onChange={(e) => setNuevoPersonaje({...nuevoPersonaje, descripcion: e.target.value})}
placeholder="Su rol en la historia..."
rows={2}
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={() => setShowAddPersonaje(false)}>
Cancelar
</Button>
<Button size="sm" onClick={onAddPersonaje} loading={generating}>
<Sparkles className="w-3.5 h-3.5" />
Crear con IA
</Button>
</div>
</CardContent>
</Card>
)}
{/* Lista de personajes */}
<PersonajesView
personajes={personajes}
slug={slug}
onSelectPersonaje={(p) => setEditingPersonaje(p)}
onOpenAssets={(p) => setAssetsPersonaje(p)}
/>
{editingPersonaje && (
<PersonajeEditor
personaje={editingPersonaje}
onSave={handleUpdatePersonaje}
onDelete={handleDeletePersonaje}
onClose={() => setEditingPersonaje(null)}
/>
)}
{assetsPersonaje && (
<CharacterAssetsPanel
personaje={assetsPersonaje}
slug={slug}
onClose={() => setAssetsPersonaje(null)}
/>
)}
<div className="border border-stone-200 rounded-lg p-5 bg-white">
<div className="flex items-center gap-2 mb-3">
<RefreshCw className="w-4 h-4 text-stone-400" />
<span className="text-sm font-medium text-stone-700">Refinar personajes</span>
</div>
<Textarea
value={personajeFeedback}
onChange={(e) => setPersonajeFeedback(e.target.value)}
placeholder="Ej: 'Hazlos más tiernos', 'Más detalle en la mecánica de Coco'..."
rows={2}
/>
<div className="mt-3 flex justify-end">
<Button size="sm" onClick={onMejorarPersonajes} loading={generating} disabled={!personajeFeedback.trim()}>
<RefreshCw className="w-3.5 h-3.5" />
Refinar
</Button>
</div>
</div>
</>
)}
</div>
)}
{/* Step 2: Capítulos */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-stone-900">Capítulos</h2>
<p className="mt-1 text-stone-500 text-sm">Crea y gestiona los capítulos de tu serie.</p>
</div>
<Link href={`/project/${slug}/capitulo/new`}>
<Button size="sm">
<Plus className="w-3.5 h-3.5" />
Nuevo capítulo
</Button>
</Link>
</div>
{capitulos.length === 0 ? (
<div className="text-center py-16">
<p className="text-stone-400 text-sm mb-5">Crea tu primer capítulo para comenzar.</p>
<Link href={`/project/${slug}/capitulo/new`}>
<Button>
<Plus className="w-4 h-4" />
Crear primer capítulo
</Button>
</Link>
</div>
) : (
<div className="space-y-1">
{capitulos.map((cap) => (
<Link key={cap.id} href={`/project/${slug}/capitulo/${cap.numero}`} className="block group">
<div className="flex items-center gap-4 px-4 py-3 rounded-lg hover:bg-white border border-transparent hover:border-stone-200/80 transition-all duration-150">
<span className="flex-shrink-0 w-7 h-7 bg-indigo-50 text-indigo-600 rounded text-xs font-semibold flex items-center justify-center">
{cap.numero}
</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-stone-800 group-hover:text-stone-950 truncate">{cap.titulo}</h3>
{cap.virtudGanada && (
<span className="text-xs text-stone-400">{cap.virtudGanada}</span>
)}
</div>
<ChevronRight className="w-4 h-4 text-stone-300 group-hover:text-stone-400 transition-colors" />
</div>
</Link>
))}
</div>
)}
</div>
)}
</div>
<div className="h-20" /> {/* spacer for fixed footer */}
</main>
<footer className="fixed bottom-0 left-0 right-0 z-10 border-t border-stone-200/60 bg-white/80 backdrop-blur-sm">
<div className="max-w-5xl mx-auto px-6 py-3 flex justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => changeStep(Math.max(0, currentStep - 1))}
disabled={currentStep === 0}
>
<ArrowLeft className="w-3.5 h-3.5" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
onClick={() => changeStep(Math.min(WIZARD_STEPS.length - 1, currentStep + 1))}
disabled={currentStep === WIZARD_STEPS.length - 1 || !canAdvance()}
>
Siguiente
<ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import { Sparkles, Save, Edit3, Palette } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import type { FondoReferencia } from '@/types/project';
interface FondoCardProps {
fondo: FondoReferencia;
onGenerate: (fondoId: string) => Promise<void>;
onUpdateDescription: (fondoId: string, descripcion: string, promptGeneracion: string) => Promise<void>;
generating: boolean;
disabled: boolean;
cacheKey: number;
}
export function FondoCard({ fondo, onGenerate, onUpdateDescription, generating, disabled, cacheKey }: FondoCardProps) {
const [editing, setEditing] = useState(false);
const [descripcionDraft, setDescripcionDraft] = useState(fondo.descripcion);
const [promptDraft, setPromptDraft] = useState(fondo.promptGeneracion);
const [saving, setSaving] = useState(false);
const handleStartEdit = () => {
setDescripcionDraft(fondo.descripcion);
setPromptDraft(fondo.promptGeneracion);
setEditing(true);
};
const handleSave = async () => {
setSaving(true);
try {
await onUpdateDescription(fondo.id, descripcionDraft, promptDraft);
setEditing(false);
} finally {
setSaving(false);
}
};
const isDirty = descripcionDraft !== fondo.descripcion || promptDraft !== fondo.promptGeneracion;
return (
<div className={`rounded-lg border overflow-hidden ${fondo.imagenUrl ? 'border-stone-200' : 'border-dashed border-stone-300'}`}>
{fondo.imagenUrl ? (
<img
src={`${fondo.imagenUrl}?v=${cacheKey}`}
alt={fondo.nombre}
className="w-full object-cover bg-stone-50"
style={{ aspectRatio: '16/9' }}
/>
) : (
<div className="w-full bg-stone-50 flex items-center justify-center" style={{ aspectRatio: '16/9' }}>
<Palette className="w-6 h-6 text-stone-300" />
</div>
)}
<div className="px-3 py-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-stone-700 truncate">{fondo.segmentoId} {fondo.nombre}</span>
{!editing && (
<button
onClick={handleStartEdit}
className="text-[10px] font-medium text-indigo-500 hover:text-indigo-700 transition-colors flex-shrink-0"
>
<Edit3 className="w-3 h-3" />
</button>
)}
</div>
{editing ? (
<div className="space-y-2 mt-1">
<div>
<label className="text-[10px] font-medium text-stone-500 block mb-0.5">Descripción del escenario</label>
<textarea
value={descripcionDraft}
onChange={(e) => setDescripcionDraft(e.target.value)}
rows={2}
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"
/>
</div>
<div>
<label className="text-[10px] font-medium text-stone-500 block mb-0.5">Prompt de generación (opcional, se auto-genera si vacío)</label>
<textarea
value={promptDraft}
onChange={(e) => setPromptDraft(e.target.value)}
rows={3}
placeholder="Dejar vacío para usar el prompt automático basado en la descripción y el estilo visual..."
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 placeholder:text-stone-300"
/>
</div>
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => setEditing(false)}
className="text-[10px] font-medium text-stone-400 hover:text-stone-600 transition-colors px-2 py-1"
>
Cancelar
</button>
<Button
size="sm"
variant="primary"
onClick={handleSave}
loading={saving}
disabled={!isDirty}
>
<Save className="w-3 h-3" />
Guardar
</Button>
</div>
</div>
) : (
<>
<p className="text-[10px] text-stone-400 line-clamp-2">{fondo.descripcion}</p>
{fondo.promptGeneracion && (
<p className="text-[10px] text-stone-300 mt-0.5 line-clamp-1 font-mono italic">{fondo.promptGeneracion}</p>
)}
</>
)}
<div className="mt-2 flex gap-1.5">
<Button
size="sm"
variant={fondo.imagenUrl ? 'outline' : 'primary'}
onClick={() => onGenerate(fondo.id)}
loading={generating}
disabled={disabled}
className="w-full"
>
<Sparkles className="w-3 h-3" />
{fondo.imagenUrl ? 'Regenerar' : 'Generar'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,301 @@
'use client';
import { useState } from 'react';
import { Save, Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import type { GuionConductor, ActoGuion, SegmentoGuion } from '@/types/project';
interface GuionFieldsEditorProps {
guion: GuionConductor;
onSave: (guion: GuionConductor) => Promise<void>;
onCancel: () => void;
}
export function GuionFieldsEditor({ guion, onSave, onCancel }: GuionFieldsEditorProps) {
const [edited, setEdited] = useState<GuionConductor>(JSON.parse(JSON.stringify(guion)));
const [saving, setSaving] = useState(false);
const [expandedActo, setExpandedActo] = useState<number | null>(0);
const updateField = (field: keyof GuionConductor, value: any) => {
setEdited({ ...edited, [field]: value });
};
const updateAliado = (field: 'nombre' | 'descripcion', value: string) => {
setEdited({
...edited,
aliado: { ...edited.aliado, [field]: value },
});
};
const updateActo = (actoIdx: number, field: keyof ActoGuion, value: any) => {
const actos = [...(edited.actos || [])];
actos[actoIdx] = { ...actos[actoIdx], [field]: value };
setEdited({ ...edited, actos });
};
const updateSegmento = (actoIdx: number, segIdx: number, field: keyof SegmentoGuion, value: string) => {
const actos = [...(edited.actos || [])];
const segmentos = [...actos[actoIdx].segmentos];
segmentos[segIdx] = { ...segmentos[segIdx], [field]: value };
actos[actoIdx] = { ...actos[actoIdx], segmentos };
setEdited({ ...edited, actos });
};
const addSegmento = (actoIdx: number) => {
const actos = [...(edited.actos || [])];
const acto = actos[actoIdx];
const nextId = `${acto.numero === 1 ? 'I' : acto.numero === 2 ? 'II' : 'III'}.${acto.segmentos.length + 1}`;
acto.segmentos = [
...acto.segmentos,
{ id: nextId, nombre: '', tiempo: '', accionVisualClave: '', disenoFondo: '', disenoSonido: '' },
];
setEdited({ ...edited, actos });
};
const removeSegmento = (actoIdx: number, segIdx: number) => {
const actos = [...(edited.actos || [])];
actos[actoIdx] = {
...actos[actoIdx],
segmentos: actos[actoIdx].segmentos.filter((_, i) => i !== segIdx),
};
setEdited({ ...edited, actos });
};
const handleSave = async () => {
setSaving(true);
try {
await onSave(edited);
} finally {
setSaving(false);
}
};
const inputClass = "w-full px-2.5 py-1.5 text-sm border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors";
const labelClass = "block text-xs font-medium text-stone-500 mb-1";
return (
<div className="space-y-5">
{/* Metadata fields */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelClass}>Título</label>
<input
type="text"
value={edited.titulo || ''}
onChange={(e) => updateField('titulo', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Duración Total</label>
<input
type="text"
value={edited.duracionTotal || ''}
onChange={(e) => updateField('duracionTotal', e.target.value)}
placeholder="3:00 min"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Virtud Ganada</label>
<input
type="text"
value={edited.virtudGanada || ''}
onChange={(e) => updateField('virtudGanada', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Gema Ganada</label>
<input
type="text"
value={edited.gemaGanada || ''}
onChange={(e) => updateField('gemaGanada', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Nudo del Mundo</label>
<input
type="text"
value={edited.nudoDelMundo || ''}
onChange={(e) => updateField('nudoDelMundo', e.target.value)}
className={inputClass}
/>
</div>
</div>
{/* Aliado */}
<div className="border border-stone-200 rounded-lg p-3">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wide">Aliado</span>
<div className="grid grid-cols-3 gap-3 mt-2">
<div>
<label className={labelClass}>Nombre</label>
<input
type="text"
value={edited.aliado?.nombre || ''}
onChange={(e) => updateAliado('nombre', e.target.value)}
className={inputClass}
/>
</div>
<div className="col-span-2">
<label className={labelClass}>Descripción</label>
<input
type="text"
value={edited.aliado?.descripcion || ''}
onChange={(e) => updateAliado('descripcion', e.target.value)}
className={inputClass}
/>
</div>
</div>
</div>
{/* Actos */}
{edited.actos && edited.actos.length > 0 && (
<div className="space-y-3">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wide">Actos y Segmentos</span>
{edited.actos.map((acto, actoIdx) => (
<div key={actoIdx} className="border border-stone-200 rounded-lg overflow-hidden">
{/* Acto header */}
<button
onClick={() => setExpandedActo(expandedActo === actoIdx ? null : actoIdx)}
className="w-full px-3 py-2 bg-stone-50 flex items-center justify-between hover:bg-stone-100 transition-colors"
>
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-indigo-50 text-indigo-600 rounded text-[10px] font-bold flex items-center justify-center">
{acto.numero}
</span>
<span className="text-sm font-medium text-stone-700">{acto.nombre || `Acto ${acto.numero}`}</span>
<span className="text-xs text-stone-400">{acto.tiempoInicio} - {acto.tiempoFin}</span>
<span className="text-[10px] text-stone-400 bg-stone-100 px-1.5 py-0.5 rounded">{acto.segmentos.length} seg.</span>
</div>
{expandedActo === actoIdx ? <ChevronUp className="w-3.5 h-3.5 text-stone-400" /> : <ChevronDown className="w-3.5 h-3.5 text-stone-400" />}
</button>
{expandedActo === actoIdx && (
<div className="p-3 space-y-3">
{/* Acto fields */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>Nombre</label>
<input
type="text"
value={acto.nombre || ''}
onChange={(e) => updateActo(actoIdx, 'nombre', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Tiempo Inicio</label>
<input
type="text"
value={acto.tiempoInicio || ''}
onChange={(e) => updateActo(actoIdx, 'tiempoInicio', e.target.value)}
placeholder="0:00"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Tiempo Fin</label>
<input
type="text"
value={acto.tiempoFin || ''}
onChange={(e) => updateActo(actoIdx, 'tiempoFin', e.target.value)}
placeholder="1:00"
className={inputClass}
/>
</div>
</div>
{/* Segmentos */}
<div className="space-y-2">
{acto.segmentos.map((seg, segIdx) => (
<div key={segIdx} className="bg-stone-50/50 border border-stone-100 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-stone-600">{seg.id}</span>
<button
onClick={() => removeSegmento(actoIdx, segIdx)}
className="text-stone-300 hover:text-red-500 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Nombre</label>
<input
type="text"
value={seg.nombre || ''}
onChange={(e) => updateSegmento(actoIdx, segIdx, 'nombre', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Tiempo</label>
<input
type="text"
value={seg.tiempo || ''}
onChange={(e) => updateSegmento(actoIdx, segIdx, 'tiempo', e.target.value)}
placeholder="0:00 - 0:30"
className={inputClass}
/>
</div>
<div className="col-span-2">
<label className={labelClass}>Acción Visual Clave</label>
<textarea
value={seg.accionVisualClave || ''}
onChange={(e) => updateSegmento(actoIdx, segIdx, 'accionVisualClave', e.target.value)}
rows={2}
className={inputClass + ' resize-y font-mono text-xs'}
/>
</div>
<div className="col-span-2">
<label className={labelClass}>Diseño de Fondo</label>
<textarea
value={seg.disenoFondo || ''}
onChange={(e) => updateSegmento(actoIdx, segIdx, 'disenoFondo', e.target.value)}
rows={2}
placeholder="Descripción del escenario, ambiente, iluminación..."
className={inputClass + ' resize-y font-mono text-xs'}
/>
</div>
<div className="col-span-2">
<label className={labelClass}>Diseño de Sonido</label>
<input
type="text"
value={seg.disenoSonido || ''}
onChange={(e) => updateSegmento(actoIdx, segIdx, 'disenoSonido', e.target.value)}
className={inputClass}
/>
</div>
</div>
</div>
))}
<button
onClick={() => addSegmento(actoIdx)}
className="w-full py-1.5 text-xs font-medium text-indigo-500 hover:text-indigo-700 hover:bg-indigo-50 rounded border border-dashed border-stone-200 transition-colors flex items-center justify-center gap-1"
>
<Plus className="w-3 h-3" />
Añadir segmento
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-stone-100">
<Button variant="ghost" size="sm" onClick={onCancel}>
Cancelar
</Button>
<Button size="sm" onClick={handleSave} loading={saving}>
<Save className="w-3.5 h-3.5" />
Guardar campos
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,478 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Clock, Image, AlertCircle, Sparkles, FileText, Save, ChevronDown, ChevronUp, Code } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import type { PlanoConPrompt, ReferenceAsset, PromptComposition } from '@/types/project';
interface PlanoAssetCardProps {
plano: PlanoConPrompt;
index: number;
slug: string;
capNumero: string;
fondoImagenUrl?: string;
onGeneratePrompt: (planoId: string) => Promise<void>;
onGenerateImage: (planoId: string) => Promise<void>;
onSaveComposition: (planoId: string, composition: PromptComposition) => Promise<void>;
generatingPrompt: string | null;
generatingImage: string | null;
}
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 },
{ key: 'optica', label: 'Óptica', rows: 1 },
{ 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 {
if (hasRefs) {
return `Using the character reference images above as exact visual references (maintain their colors, proportions, features, accessories, and art style), generate the following scene:\n\n${promptImagen}`;
}
return promptImagen;
}
/** Build reference labels that match what the server sends to Gemini */
function buildReferenceLabels(refs: ReferenceAsset[], fondoImagenUrl?: string): string[] {
const labels: string[] = [];
// Fondo first (matches imagenes/route.ts order)
if (fondoImagenUrl) {
labels.push('Background reference for "Fondo". Use this image as inspiration for the background/environment where the action takes place. Match the atmosphere, lighting, and setting shown.');
}
// Character refs
for (const ref of refs) {
const frontUrl = ref.tposeFrontUrl || (ref as any).tposeUrl;
if (frontUrl) labels.push(`${ref.nombre} - T-pose front`);
if (ref.tposeSideUrl) labels.push(`${ref.nombre} - T-pose side`);
if (ref.tposeBackUrl) labels.push(`${ref.nombre} - T-pose back`);
if (ref.emotionUrl) labels.push(`${ref.nombre} - ${ref.emocion}`);
}
return labels;
}
/** Build a reconstructed JSON preview of the Gemini API request */
function buildGeminiRequestJson(
promptImagen: string,
refs: ReferenceAsset[],
fondoImagenUrl?: string,
): object {
const refLabels = buildReferenceLabels(refs, fondoImagenUrl);
const contents: any[] = [];
for (const label of refLabels) {
contents.push({ inlineData: { mimeType: 'image/png', data: '<base64>' } });
contents.push({ text: `[Reference: ${label}]` });
}
const instruction = buildGeminiInstruction(promptImagen, refLabels.length > 0);
contents.push({ text: instruction });
return {
model: 'gemini-3-pro-image-preview',
contents,
config: {
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: { aspectRatio: '16:9' },
},
};
}
export function PlanoAssetCard({
plano, index, slug, capNumero, fondoImagenUrl,
onGeneratePrompt, onGenerateImage, onSaveComposition,
generatingPrompt, generatingImage,
}: PlanoAssetCardProps) {
const refs = plano.referenceAssets || [];
const hasPrompt = !!plano.promptImagen;
const hasImage = !!plano.imagenUrl;
const hasComposition = !!plano.promptComposition;
const isGeneratingPrompt = generatingPrompt === plano.id;
const isGeneratingImage = generatingImage === plano.id;
const isBusy = !!generatingPrompt || !!generatingImage;
const missingRefs = refs.filter(r => !r.tposeFrontUrl && !(r as any).tposeUrl);
const [expanded, setExpanded] = useState(false);
const [editComp, setEditComp] = useState<PromptComposition | null>(null);
const [saving, setSaving] = useState(false);
const [showJson, setShowJson] = useState(false);
const isEditing = editComp !== null;
const currentComp = editComp || plano.promptComposition;
const isDirty = isEditing && JSON.stringify(editComp) !== JSON.stringify(plano.promptComposition);
const handleStartEdit = () => {
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),
});
} else {
setEditComp({
personajes: '', accion: '', luz: '', camara: '', optica: '',
tipoPlano: '', estilo: '', custom: '',
});
}
setExpanded(true);
};
const handleFieldChange = (key: keyof PromptComposition, value: string) => {
if (!editComp) return;
setEditComp({ ...editComp, [key]: value });
};
const handleSave = async () => {
if (!editComp) return;
setSaving(true);
try {
await onSaveComposition(plano.id, editComp);
setEditComp(null);
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditComp(null);
};
return (
<div className={`bg-white border rounded-lg overflow-hidden transition-colors ${hasPrompt ? 'border-stone-200 hover:border-stone-300' : 'border-dashed border-stone-300'}`}>
{/* Header */}
<div className="px-4 py-2.5 bg-stone-50/80 border-b border-stone-100 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-50 text-indigo-600 rounded text-[11px] font-semibold flex items-center justify-center">
{index + 1}
</span>
<span className="text-xs font-medium text-stone-700">{plano.numeroPlano}</span>
<span className="text-xs text-stone-400">{plano.segmentoGuion}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-stone-400">
<Clock className="w-3 h-3" />
{plano.tiempoSegundos}s
</div>
{hasPrompt && !hasImage && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-600">
Prompt listo
</span>
)}
{hasImage && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-100 text-emerald-700">
Imagen generada
</span>
)}
</div>
</div>
{!hasPrompt ? (
/* Empty state: no prompt yet */
<div className="px-4 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-stone-600">{plano.accionDescripcion}</p>
<p className="text-xs text-stone-400 mt-1">Sin prompt de imagen</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => onGeneratePrompt(plano.id)}
loading={isGeneratingPrompt}
disabled={isBusy}
>
<FileText className="w-3.5 h-3.5" />
Generar prompt
</Button>
</div>
) : (
<>
{/* Reference images row */}
{(refs.length > 0 || fondoImagenUrl) && (
<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>
)}
{/* Character refs */}
{refs.map((ref) => (
<ReferenceGroup key={ref.personajeId} ref_={ref} />
))}
</div>
</div>
)}
{/* Composition toggle + prompt final */}
<div className="px-4 py-3 border-b border-stone-100">
<div className="flex items-center justify-between mb-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 text-[10px] font-medium text-stone-400 uppercase tracking-wide hover:text-stone-600 transition-colors"
>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Composición
</button>
<div className="flex items-center gap-1.5">
{!isEditing && hasComposition && (
<button
onClick={handleStartEdit}
className="text-[10px] font-medium text-indigo-500 hover:text-indigo-700 transition-colors"
>
Editar
</button>
)}
{isEditing && (
<>
<button
onClick={handleCancelEdit}
className="text-[10px] font-medium text-stone-400 hover:text-stone-600 transition-colors"
>
Cancelar
</button>
<Button
size="sm"
variant="primary"
onClick={handleSave}
loading={saving}
disabled={!isDirty}
>
<Save className="w-3 h-3" />
Guardar
</Button>
</>
)}
</div>
</div>
{/* Expanded: sub-field editors */}
{expanded && currentComp && (
<div className="space-y-2 mb-3">
{COMPOSITION_FIELDS.map(({ key, label, rows }) => (
<div key={key}>
<label className="text-[10px] font-medium text-stone-500 block mb-0.5">{label}</label>
{isEditing ? (
<textarea
value={fieldToString(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>}
</p>
)}
</div>
))}
</div>
)}
{/* Final composed prompt (always visible) */}
<div>
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wide">Prompt final</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}
</p>
</div>
{/* Full Gemini instruction (with reference wrapper) */}
{(() => {
const currentPrompt = isEditing && editComp ? composePromptLocal(editComp) : plano.promptImagen;
const hasRefs = refs.length > 0 || !!fondoImagenUrl;
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>
<p className="mt-1 text-[11px] text-stone-500 leading-relaxed bg-amber-50/60 border border-amber-100 rounded p-2.5 font-mono whitespace-pre-wrap">
{fullInstruction}
</p>
</div>
) : null;
})()}
{/* JSON request preview button */}
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => setShowJson(!showJson)}
className="flex items-center gap-1 text-[10px] font-medium text-stone-400 hover:text-indigo-500 transition-colors"
>
<Code className="w-3 h-3" />
{showJson ? 'Ocultar JSON' : 'JSON Nano Banana Pro'}
</button>
</div>
{showJson && (
<div className="mt-1.5">
<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 || ''),
refs,
fondoImagenUrl
),
null,
2
)}
</pre>
</div>
)}
</div>
{/* Result / Generate image */}
<div className="px-4 py-3">
{hasImage ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-medium text-stone-400 uppercase tracking-wide">Resultado</span>
<Button
variant="outline"
size="sm"
onClick={() => onGenerateImage(plano.id)}
loading={isGeneratingImage}
disabled={isBusy}
>
<Sparkles className="w-3 h-3" />
Regenerar imagen
</Button>
</div>
<img
src={`${plano.imagenUrl}?v=${Date.now()}`}
alt={`Plano ${plano.numeroPlano}`}
className="w-full rounded-lg border border-stone-200 object-cover"
style={{ aspectRatio: '16/9' }}
/>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-stone-400">
<Image className="w-4 h-4" />
<span className="text-xs">Sin imagen generada</span>
</div>
<Button
size="sm"
onClick={() => onGenerateImage(plano.id)}
loading={isGeneratingImage}
disabled={isBusy}
>
<Sparkles className="w-3.5 h-3.5" />
Generar imagen
</Button>
</div>
)}
</div>
{/* Missing refs warning */}
{missingRefs.length > 0 && (
<div className="px-4 py-2 bg-amber-50 border-t border-amber-100 flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
<span className="text-xs text-amber-700">
Sin T-pose: {missingRefs.map(r => r.nombre).join(', ')}
</span>
<Link
href={`/project/${slug}?step=personajes`}
className="ml-auto text-xs font-medium text-amber-600 hover:text-amber-800 transition-colors"
>
Generar assets
</Link>
</div>
)}
</>
)}
</div>
);
}
function ReferenceGroup({ ref_ }: { ref_: ReferenceAsset }) {
const tposes = [
{ url: ref_.tposeFrontUrl || (ref_ as any).tposeUrl, label: 'Front' },
{ url: ref_.tposeSideUrl, label: 'Side' },
{ url: ref_.tposeBackUrl, label: 'Back' },
].filter(t => t.url);
return (
<div className="flex items-center gap-1.5">
{/* T-poses */}
{tposes.length > 0 ? (
tposes.map(({ url, label }) => (
<div key={label} className="text-center">
<img
src={url}
alt={`${ref_.nombre} ${label}`}
className="w-12 h-12 rounded border border-stone-200 object-cover bg-stone-50"
/>
<span className="text-[9px] text-stone-400 mt-0.5 block">{label}</span>
</div>
))
) : (
<div className="text-center">
<div className="w-12 h-12 rounded border border-dashed border-stone-300 bg-stone-50 flex items-center justify-center">
<span className="text-[9px] text-stone-400">Sin T</span>
</div>
</div>
)}
{/* Emotion */}
<div className="text-center">
{ref_.emotionUrl ? (
<img
src={ref_.emotionUrl}
alt={`${ref_.nombre} ${ref_.emocion}`}
className="w-12 h-12 rounded border border-stone-200 object-cover bg-stone-50"
/>
) : (
<div className="w-12 h-12 rounded border border-dashed border-stone-300 bg-stone-50 flex items-center justify-center">
<span className="text-[9px] text-stone-400">Sin em.</span>
</div>
)}
<span className="text-[9px] text-stone-400 mt-0.5 block">{ref_.emocion}</span>
</div>
{/* Name */}
<span className="text-[10px] font-medium text-stone-600 self-start mt-1 ml-0.5">{ref_.nombre}</span>
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
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';
const EMOTION_LABELS: Record<string, string> = {
alegria: 'Alegría',
tristeza: 'Tristeza',
sorpresa: 'Sorpresa',
miedo: 'Miedo',
enfado: 'Enfado',
neutral: 'Neutral',
};
const EMOTION_COLORS: Record<string, string> = {
alegria: 'bg-yellow-100 text-yellow-700',
tristeza: 'bg-blue-100 text-blue-700',
sorpresa: 'bg-purple-100 text-purple-700',
miedo: 'bg-slate-100 text-slate-700',
enfado: 'bg-red-100 text-red-700',
neutral: 'bg-stone-100 text-stone-600',
};
interface PlanoCardProps {
plano: PlanoEscaleta;
index: number;
assetsMap: Record<string, CharacterAssetsSummary>;
slug: string;
}
export function PlanoCard({ plano, index, assetsMap, slug }: PlanoCardProps) {
const personajes = plano.personajesDetalle || [];
const missingAssets = personajes.filter(p => !assetsMap[p.personajeId]?.hasAssets);
return (
<div className="bg-white border border-stone-200 rounded-lg overflow-hidden hover:border-stone-300 transition-colors">
{/* Header */}
<div className="px-4 py-2.5 bg-stone-50/80 border-b border-stone-100 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-50 text-indigo-600 rounded text-[11px] font-semibold flex items-center justify-center">
{index + 1}
</span>
<span className="text-xs font-medium text-stone-700">{plano.numeroPlano}</span>
<span className="text-xs text-stone-400">{plano.segmentoGuion}</span>
</div>
<div className="flex items-center gap-1 text-xs text-stone-400">
<Clock className="w-3 h-3" />
{plano.tiempoSegundos}s
</div>
</div>
{/* Personajes strip */}
{personajes.length > 0 && (
<div className="px-4 py-2.5 border-b border-stone-100 flex flex-wrap gap-2">
{personajes.map((pd) => (
<PersonajeBadge
key={pd.personajeId}
personaje={pd}
assets={assetsMap[pd.personajeId]}
slug={slug}
/>
))}
</div>
)}
{/* Body */}
<div className="px-4 py-3 space-y-2">
<p className="text-sm text-stone-700 leading-relaxed">{plano.accionDescripcion}</p>
{plano.indicacionesSonido && (
<div className="flex items-start gap-1.5 text-xs text-stone-400">
<Volume2 className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{plano.indicacionesSonido}</span>
</div>
)}
</div>
{/* Missing assets warning */}
{missingAssets.length > 0 && (
<div className="px-4 py-2 bg-amber-50 border-t border-amber-100 flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
<span className="text-xs text-amber-700">
Sin assets: {missingAssets.map(p => p.nombre).join(', ')}
</span>
<Link
href={`/project/${slug}?step=personajes`}
className="ml-auto text-xs font-medium text-amber-600 hover:text-amber-800 transition-colors"
>
Generar
</Link>
</div>
)}
</div>
);
}
function PersonajeBadge({
personaje,
assets,
slug,
}: {
personaje: PersonajeEnPlano;
assets?: CharacterAssetsSummary;
slug: string;
}) {
const emotionUrl = assets?.emotions[personaje.emocion];
const tposeUrl = assets?.tposeFrontUrl;
const avatarUrl = tposeUrl;
const emotionColor = EMOTION_COLORS[personaje.emocion] || EMOTION_COLORS.neutral;
return (
<div className="flex items-center gap-1.5 rounded-full bg-stone-50 border border-stone-150 pl-1 pr-2.5 py-0.5">
{/* Avatar */}
{avatarUrl ? (
<img
src={avatarUrl}
alt={personaje.nombre}
className="w-6 h-6 rounded-full object-cover bg-stone-100"
/>
) : (
<div className="w-6 h-6 rounded-full bg-stone-200 flex items-center justify-center text-[10px] font-medium text-stone-500">
{personaje.nombre.charAt(0)}
</div>
)}
{/* Name */}
<span className="text-xs font-medium text-stone-700">{personaje.nombre}</span>
{/* Emotion badge with optional thumbnail */}
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${emotionColor}`}>
{emotionUrl && (
<img
src={emotionUrl}
alt={personaje.emocion}
className="w-4 h-4 rounded-sm object-cover"
/>
)}
{EMOTION_LABELS[personaje.emocion] || personaje.emocion}
</span>
</div>
);
}

View File

@@ -0,0 +1,435 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Sparkles, Copy, Check, X, Lock, Unlock, Play, RefreshCw, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Input';
import type { Personaje, CharacterAsset, CharacterAssetsData } from '@/types/project';
interface CharacterAssetsPanelProps {
personaje: Personaje;
slug: string;
onClose: () => void;
}
const API_BASE = (slug: string, id: string) =>
`/api/projects/${slug}/personajes/${id}/assets`;
export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAssetsPanelProps) {
const [data, setData] = useState<CharacterAssetsData>({ assets: [], baseLocked: false });
const [loading, setLoading] = useState<string | null>(null);
const [fetching, setFetching] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [basePromptEdit, setBasePromptEdit] = useState('');
const [imgVersion, setImgVersion] = useState(0);
const abortRef = useRef(false);
const url = API_BASE(slug, personaje.id);
const fetchData = useCallback(async () => {
setFetching(true);
try {
const res = await fetch(url);
if (res.ok) {
const d = await res.json();
setData(d);
setImgVersion(v => v + 1);
const base = d.assets?.find((a: CharacterAsset) => a.isBase);
if (base) setBasePromptEdit(base.prompt || '');
}
} catch (e) {
console.error('Error fetching assets:', e);
} finally {
setFetching(false);
}
}, [url]);
useEffect(() => { fetchData(); }, [fetchData]);
// Cleanup: abort batch generation on unmount
useEffect(() => {
return () => { abortRef.current = true; };
}, []);
const postAction = async (action: string, extra?: Record<string, any>): Promise<CharacterAssetsData | null> => {
const loadingKey = extra?.assetId || action;
setLoading(loadingKey);
setError(null);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...extra }),
});
const d = await res.json();
if (!res.ok) { setError(d.error || `Error ${res.status}`); return null; }
setData(d);
setImgVersion(v => v + 1);
const base = d.assets?.find((a: CharacterAsset) => a.isBase);
if (base) setBasePromptEdit(base.prompt || '');
return d;
} catch (e: any) {
setError(e.message || 'Error de conexión');
return null;
} finally {
setLoading(null);
}
};
const generateAllVariations = async () => {
const pending = data.assets.filter(a => !a.isBase && a.status !== 'generated');
if (pending.length === 0) return;
abortRef.current = false;
for (const asset of pending) {
if (abortRef.current) break;
const result = await postAction('generate-variation', { assetId: asset.id });
if (!result) break;
}
};
const copyPrompt = (asset: CharacterAsset) => {
navigator.clipboard.writeText(asset.prompt);
setCopiedId(asset.id);
setTimeout(() => setCopiedId(null), 2000);
};
const { assets, baseLocked } = data;
const baseAsset = assets.find(a => a.isBase);
const otherAssets = assets.filter(a => !a.isBase);
const tposeOthers = otherAssets.filter(a => a.type === 'tpose');
const emotionAssets = otherAssets.filter(a => a.type === 'emotion');
const generatedCount = assets.filter(a => a.status === 'generated').length;
// Determine current workflow step
const step = assets.length === 0 ? 0
: !baseAsset?.imageUrl ? 1
: !baseLocked ? 2
: 3;
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">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-100">
<div className="flex items-center gap-3">
<h2 className="text-base font-semibold text-stone-900">{personaje.nombre}</h2>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Character Assets</span>
{assets.length > 0 && (
<span className="text-[10px] text-stone-400">{generatedCount}/{assets.length} generados</span>
)}
</div>
<button onClick={onClose} className="p-1 text-stone-400 hover:text-stone-600 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* 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>
)}
{/* Error */}
{error && (
<div className="mx-5 mt-3 text-xs text-red-600 bg-red-50 px-3 py-2 rounded">{error}</div>
)}
<div className="p-5 overflow-y-auto max-h-[calc(90vh-160px)]">
{fetching ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-5 h-5 border-2 border-indigo-600 border-t-transparent rounded-full" />
</div>
/* Step 0: No prompts yet */
) : step === 0 ? (
<div className="text-center py-12">
<p className="text-stone-500 text-sm mb-1">Sin prompts de assets.</p>
<p className="text-stone-400 text-xs mb-5">
Genera prompts para T-pose y emociones usando IA.
</p>
<Button onClick={() => postAction('generate-prompts')} loading={loading === 'generate-prompts'}>
<Sparkles className="w-4 h-4" />
Generar prompts de assets
</Button>
</div>
/* Steps 1-3: Has prompts */
) : (
<div className="space-y-6">
{/* BASE IMAGE SECTION */}
<div className={`border rounded-lg p-4 ${
baseLocked ? 'border-emerald-200 bg-emerald-50/30' : 'border-indigo-200 bg-indigo-50/30'
}`}>
<div className="flex items-center gap-2 mb-3">
{baseLocked ? <Lock className="w-3.5 h-3.5 text-emerald-500" /> : <Unlock className="w-3.5 h-3.5 text-indigo-500" />}
<span className="text-xs font-semibold text-stone-700">Imagen Base (Referencia Canónica)</span>
{baseLocked && <span className="text-[10px] text-emerald-600 font-medium">Fijada</span>}
</div>
<div className="flex gap-4">
{/* Base image preview */}
<div className="w-48 flex-shrink-0">
{baseAsset?.imageUrl ? (
<div className="aspect-square bg-white rounded-lg border border-stone-200 overflow-hidden relative group/base">
<img src={`${baseAsset.imageUrl}?v=${imgVersion}`} alt="Base" className="w-full h-full object-cover" />
{loading === 'generate-base-image' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Loader2 className="w-5 h-5 text-white animate-spin" />
</div>
)}
</div>
) : loading === 'generate-base-image' ? (
<div className="aspect-square bg-white rounded-lg border border-stone-200 flex items-center justify-center">
<Loader2 className="w-5 h-5 text-indigo-400 animate-spin" />
</div>
) : (
<div className="aspect-square bg-white rounded-lg border border-stone-200 flex items-center justify-center">
<span className="text-xs text-stone-300">Sin imagen</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex-1 space-y-3">
{!baseLocked && (
<>
<div>
<label className="text-[11px] font-medium text-stone-500 mb-1 block">Prompt (editable)</label>
<Textarea
value={basePromptEdit}
onChange={(e) => setBasePromptEdit(e.target.value)}
rows={4}
className="text-xs"
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => postAction('generate-base-image', { prompt: basePromptEdit })}
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>
{baseAsset?.imageUrl && baseAsset.status === 'generated' && (
<Button
size="sm"
variant="secondary"
onClick={() => postAction('lock-base')}
loading={loading === 'lock-base'}
>
<Lock className="w-3.5 h-3.5" />
Fijar como referencia
</Button>
)}
</div>
</>
)}
{baseLocked && (
<p className="text-xs text-stone-500">
Imagen fijada como referencia. Las variaciones se generarán a partir de esta imagen.
</p>
)}
</div>
</div>
</div>
{/* VARIATIONS SECTION */}
{baseLocked && (
<>
<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>
{/* Other T-Poses */}
{tposeOthers.length > 0 && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider block mb-2">T-Pose</span>
<div className="grid grid-cols-4 gap-3">
{tposeOthers.map(asset => (
<AssetCard
key={asset.id}
asset={asset}
imgVersion={imgVersion}
copiedId={copiedId}
onCopy={copyPrompt}
onGenerate={() => postAction('generate-variation', { assetId: asset.id })}
generating={loading === asset.id}
canGenerate={baseLocked && !loading}
/>
))}
</div>
</div>
)}
{/* Emotions */}
{emotionAssets.length > 0 && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider block mb-2">Emociones</span>
<div className="grid grid-cols-4 gap-3">
{emotionAssets.map(asset => (
<AssetCard
key={asset.id}
asset={asset}
imgVersion={imgVersion}
copiedId={copiedId}
onCopy={copyPrompt}
onGenerate={() => postAction('generate-variation', { assetId: asset.id })}
generating={loading === asset.id}
canGenerate={baseLocked && !loading}
/>
))}
</div>
</div>
)}
</>
)}
{/* Pre-lock: show all assets as prompt list */}
{!baseLocked && assets.length > 1 && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider block mb-2">
Prompts pendientes ({otherAssets.length} variaciones)
</span>
<div className="grid grid-cols-4 gap-3">
{otherAssets.map(asset => (
<AssetCard
key={asset.id}
asset={asset}
imgVersion={imgVersion}
copiedId={copiedId}
onCopy={copyPrompt}
canGenerate={false}
/>
))}
</div>
</div>
)}
{/* Regenerate prompts */}
<div className="pt-3 border-t border-stone-100 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => postAction('generate-prompts')}
loading={loading === 'generate-prompts'}
>
<RefreshCw className="w-3 h-3" />
Regenerar todos los prompts
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function AssetCard({
asset,
imgVersion,
copiedId,
onCopy,
onGenerate,
generating,
canGenerate,
}: {
asset: CharacterAsset;
imgVersion: number;
copiedId: string | null;
onCopy: (asset: CharacterAsset) => void;
onGenerate?: () => void;
generating?: boolean;
canGenerate?: boolean;
}) {
const isCopied = copiedId === asset.id;
const statusColors: Record<string, string> = {
pending: 'bg-stone-300',
prompt_ready: 'bg-amber-400',
generating: 'bg-blue-400 animate-pulse',
generated: 'bg-emerald-400',
};
return (
<div className="border border-stone-200 rounded-lg p-2.5 group">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${statusColors[asset.status] || 'bg-stone-300'}`} />
<span className="text-[11px] font-medium text-stone-700">{asset.label}</span>
</div>
</div>
{asset.imageUrl ? (
<div className="aspect-square bg-stone-50 rounded border border-stone-100 mb-1.5 overflow-hidden relative group/img">
<img src={`${asset.imageUrl}?v=${imgVersion}`} alt={asset.label} className="w-full h-full object-cover" />
{generating ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Loader2 className="w-4 h-4 text-white animate-spin" />
</div>
) : canGenerate && onGenerate ? (
<button
onClick={onGenerate}
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity"
>
<RefreshCw className="w-4 h-4 text-white" />
</button>
) : null}
</div>
) : generating ? (
<div className="aspect-square bg-stone-50 rounded border border-stone-100 mb-1.5 flex items-center justify-center">
<Loader2 className="w-4 h-4 text-indigo-400 animate-spin" />
</div>
) : (
<div className="aspect-square bg-stone-50 rounded border border-stone-100 mb-1.5 flex flex-col items-center justify-center gap-1">
{canGenerate && onGenerate ? (
<button
onClick={onGenerate}
className="text-[10px] text-indigo-500 hover:text-indigo-700 font-medium"
>
Generar
</button>
) : (
<span className="text-[10px] text-stone-300">Pendiente</span>
)}
</div>
)}
{asset.prompt && (
<div className="flex gap-1">
<p className="text-[9px] text-stone-400 leading-tight flex-1 line-clamp-2">{asset.prompt}</p>
<button
onClick={() => onCopy(asset)}
className="flex-shrink-0 p-0.5 text-stone-300 hover:text-indigo-500 transition-colors"
title="Copiar prompt"
>
{isCopied ? <Check className="w-2.5 h-2.5 text-emerald-500" /> : <Copy className="w-2.5 h-2.5" />}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { X, Save, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Input';
import type { IdeaMotriz } from '@/types/project';
interface IdeaMotrizEditorProps {
ideaMotriz: IdeaMotriz;
onSave: (ideaMotriz: IdeaMotriz) => void;
onClose: () => void;
}
export function IdeaMotrizEditor({ ideaMotriz, onSave, onClose }: IdeaMotrizEditorProps) {
const [edited, setEdited] = useState<IdeaMotriz>(JSON.parse(JSON.stringify(ideaMotriz)));
const handleSave = () => {
onSave(edited);
onClose();
};
const updateField = (field: keyof IdeaMotriz, value: any) => {
setEdited({ ...edited, [field]: value });
};
const updateFormato = (field: string, value: any) => {
setEdited({
...edited,
formato: { ...edited.formato, [field]: value }
});
};
const updateValor = (index: number, value: string) => {
const newValores = [...(edited.valores || [])];
newValores[index] = value;
setEdited({ ...edited, valores: newValores });
};
const addValor = () => {
setEdited({ ...edited, valores: [...(edited.valores || []), ''] });
};
const removeValor = (index: number) => {
const newValores = (edited.valores || []).filter((_, i) => i !== index);
setEdited({ ...edited, valores: newValores });
};
const updateAntagonista = (field: string, value: string) => {
setEdited({
...edited,
antagonista: { ...edited.antagonista, [field]: value }
});
};
return (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-lg shadow-xl shadow-black/10 max-w-3xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-100">
<h2 className="text-base font-semibold text-stone-900">Editar Idea Motriz</h2>
<button onClick={onClose} className="p-1 text-stone-400 hover:text-stone-600 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 overflow-y-auto max-h-[calc(90vh-140px)] space-y-5">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Idea Motriz Central</label>
<Textarea
value={edited.ideaMotrizCentral || ''}
onChange={(e) => updateField('ideaMotrizCentral', e.target.value)}
placeholder="La idea central que guía toda la historia..."
rows={3}
/>
</div>
{/* Formato */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Capítulos</label>
<input
type="number"
value={edited.formato?.capitulos || ''}
onChange={(e) => updateFormato('capitulos', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Duración (minutos)</label>
<input
type="number"
value={edited.formato?.duracionMinutos || ''}
onChange={(e) => updateFormato('duracionMinutos', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
</div>
{/* Narrativa */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Narrativa</label>
<Textarea
value={edited.narrativa || ''}
onChange={(e) => updateField('narrativa', e.target.value)}
placeholder="Descripción del estilo narrativo..."
rows={3}
/>
</div>
{/* Estilo Visual */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Estilo Visual</label>
<Textarea
value={edited.estiloVisual || ''}
onChange={(e) => updateField('estiloVisual', e.target.value)}
placeholder="Pixar-style 3D animation, vibrant colors, expressive characters..."
rows={2}
/>
<p className="text-[11px] text-stone-400 mt-1">Se inyecta automáticamente en cada prompt de imagen. Si se deja vacío, se usa el estilo por defecto.</p>
</div>
{/* Valores */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-stone-600">Valores</label>
<button
type="button"
onClick={addValor}
className="text-indigo-600 hover:text-indigo-700 text-xs font-medium flex items-center gap-1 transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Añadir
</button>
</div>
<div className="space-y-2">
{(edited.valores || []).map((valor, i) => (
<div key={i} className="flex gap-2">
<input
type="text"
value={valor}
onChange={(e) => updateValor(i, e.target.value)}
placeholder="Ej: Cooperación, Empatía..."
className="flex-1 px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
<button
type="button"
onClick={() => removeValor(i)}
className="text-stone-300 hover:text-red-500 px-1.5 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
{/* Antagonista */}
<div className="border border-stone-200 p-4 rounded-lg">
<h3 className="text-sm font-medium text-stone-700 mb-3">Antagonista</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Nombre</label>
<input
type="text"
value={edited.antagonista?.nombre || ''}
onChange={(e) => updateAntagonista('nombre', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Descripción</label>
<Textarea
value={edited.antagonista?.descripcion || ''}
onChange={(e) => updateAntagonista('descripcion', e.target.value)}
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Representación Visual</label>
<input
type="text"
value={edited.antagonista?.representacionVisual || ''}
onChange={(e) => updateAntagonista('representacionVisual', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
</div>
</div>
{/* Expansión */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Expansión</label>
<Textarea
value={edited.expansion || ''}
onChange={(e) => updateField('expansion', e.target.value)}
placeholder="Ideas para expandir la serie..."
rows={2}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-stone-100">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancelar
</Button>
<Button size="sm" onClick={handleSave}>
<Save className="w-3.5 h-3.5" />
Guardar
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import type { IdeaMotriz } from '@/types/project';
interface IdeaMotrizViewProps {
data: IdeaMotriz;
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <h3 className="text-xs font-medium text-stone-400 uppercase tracking-wider mb-2">{children}</h3>;
}
export function IdeaMotrizView({ data }: IdeaMotrizViewProps) {
return (
<div className="space-y-6">
<div>
<SectionLabel>Premisa</SectionLabel>
<p className="text-stone-800 leading-relaxed">{data.ideaMotrizCentral}</p>
</div>
<div className="flex gap-6">
<div>
<SectionLabel>Capítulos</SectionLabel>
<p className="text-xl font-semibold text-stone-900 tabular-nums">{data.formato?.capitulos || '-'}</p>
</div>
<div className="w-px bg-stone-100" />
<div>
<SectionLabel>Duración</SectionLabel>
<p className="text-xl font-semibold text-stone-900 tabular-nums">{data.formato?.duracionMinutos || '-'} min</p>
</div>
</div>
<div>
<SectionLabel>Narrativa</SectionLabel>
<p className="text-stone-600 text-sm leading-relaxed">{data.narrativa}</p>
</div>
{data.estiloVisual && (
<div>
<SectionLabel>Estilo Visual</SectionLabel>
<p className="text-sm text-stone-600 leading-relaxed font-mono bg-stone-50 rounded px-3 py-2">{data.estiloVisual}</p>
</div>
)}
{data.valores && data.valores.length > 0 && (
<div>
<SectionLabel>Valores</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{data.valores.map((valor, i) => (
<span key={i} className="px-2.5 py-1 bg-stone-100 text-stone-600 rounded text-xs font-medium">
{valor}
</span>
))}
</div>
</div>
)}
{data.protagonistas && data.protagonistas.length > 0 && (
<div>
<SectionLabel>Protagonistas</SectionLabel>
<div className="space-y-3">
{data.protagonistas.map((p, i) => (
<div key={i} className="border border-stone-100 rounded-lg p-4">
<div className="flex items-baseline gap-2">
<h4 className="font-medium text-stone-900">{p.nombre}</h4>
<span className="text-xs text-stone-400">{p.rol}</span>
</div>
{p.utileria && (
<p className="text-xs text-stone-500 mt-1.5">Utilería: {p.utileria}</p>
)}
{p.mecanica && (
<p className="text-sm text-stone-600 mt-1.5">{p.mecanica}</p>
)}
</div>
))}
</div>
</div>
)}
{data.antagonista && (
<div>
<SectionLabel>Antagonista</SectionLabel>
<div className="border border-stone-100 rounded-lg p-4">
<h4 className="font-medium text-stone-900">{data.antagonista.nombre}</h4>
<p className="text-sm text-stone-600 mt-1">{data.antagonista.descripcion}</p>
{data.antagonista.representacionVisual && (
<p className="text-xs text-stone-400 mt-2">Visual: {data.antagonista.representacionVisual}</p>
)}
</div>
</div>
)}
{data.estructuraGuion && data.estructuraGuion.length > 0 && (
<div>
<SectionLabel>Estructura</SectionLabel>
<div className="space-y-2">
{data.estructuraGuion.map((acto) => (
<div key={acto.numero} className="flex items-start gap-3 py-2">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-50 text-indigo-600 rounded text-xs font-semibold flex items-center justify-center mt-0.5">
{acto.numero}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<h4 className="text-sm font-medium text-stone-800">{acto.nombre}</h4>
<span className="text-xs text-stone-400">{acto.duracionMin}-{acto.duracionMax} min</span>
</div>
<p className="text-sm text-stone-500 mt-0.5">{acto.descripcion}</p>
</div>
</div>
))}
</div>
</div>
)}
{data.expansion && (
<div>
<SectionLabel>Expansión</SectionLabel>
<p className="text-sm text-stone-600 leading-relaxed">{data.expansion}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { X, Save, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Input';
import type { Personaje } from '@/types/project';
interface PersonajeEditorProps {
personaje: Personaje;
onSave: (personaje: Personaje) => void;
onDelete: (id: string) => void;
onClose: () => void;
}
export function PersonajeEditor({ personaje, onSave, onDelete, onClose }: PersonajeEditorProps) {
const [editedPersonaje, setEditedPersonaje] = useState<Personaje>({ ...personaje });
const handleChange = (field: keyof Personaje, value: string) => {
setEditedPersonaje({ ...editedPersonaje, [field]: value });
};
const handleSave = () => {
onSave(editedPersonaje);
onClose();
};
const handleDelete = () => {
if (confirm(`¿Eliminar a ${personaje.nombre}?`)) {
onDelete(personaje.id);
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-lg shadow-xl shadow-black/10 max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-100">
<h2 className="text-base font-semibold text-stone-900">{personaje.nombre}</h2>
<button onClick={onClose} className="p-1 text-stone-400 hover:text-stone-600 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 overflow-y-auto max-h-[calc(90vh-140px)] space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Nombre</label>
<input
type="text"
value={editedPersonaje.nombre}
onChange={(e) => handleChange('nombre', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Tipo</label>
<select
value={editedPersonaje.tipo}
onChange={(e) => handleChange('tipo', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
>
<option value="protagonista">Protagonista</option>
<option value="aliado">Aliado</option>
<option value="antagonista">Antagonista</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Especie</label>
<input
type="text"
value={editedPersonaje.especie || ''}
onChange={(e) => handleChange('especie', e.target.value)}
placeholder="Ej: gato, búho, zorro..."
className="w-full px-3 py-2 border border-stone-200 rounded-lg bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Apariencia Física</label>
<Textarea
value={editedPersonaje.aparienciaFisica || ''}
onChange={(e) => handleChange('aparienciaFisica', e.target.value)}
placeholder="Describe el aspecto físico: color, tamaño, rasgos distintivos..."
rows={3}
/>
</div>
{/* Rol Principal */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Rol Principal</label>
<Textarea
value={editedPersonaje.rolPrincipal}
onChange={(e) => handleChange('rolPrincipal', e.target.value)}
placeholder="Su función en la historia..."
rows={2}
/>
</div>
{/* Personalidad */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Personalidad</label>
<Textarea
value={editedPersonaje.personalidad}
onChange={(e) => handleChange('personalidad', e.target.value)}
placeholder="Rasgos de carácter..."
rows={2}
/>
</div>
{/* Habilidad Innata */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Habilidad Innata</label>
<Textarea
value={editedPersonaje.habilidadInnata}
onChange={(e) => handleChange('habilidadInnata', e.target.value)}
placeholder="Qué sabe hacer naturalmente..."
rows={2}
/>
</div>
{/* Utilería Visual */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Utilería Visual</label>
<Textarea
value={editedPersonaje.utileriaVisual}
onChange={(e) => handleChange('utileriaVisual', e.target.value)}
placeholder="Objetos distintivos que lleva..."
rows={2}
/>
</div>
{/* Mecánica Narrativa */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Mecánica Narrativa</label>
<Textarea
value={editedPersonaje.mecanicaNarrativa}
onChange={(e) => handleChange('mecanicaNarrativa', e.target.value)}
placeholder="Cómo funciona en la historia..."
rows={2}
/>
</div>
{/* Comunicación */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Comunicación</label>
<Textarea
value={editedPersonaje.comunicacion}
onChange={(e) => handleChange('comunicacion', e.target.value)}
placeholder="Cómo se expresa (sonidos, gestos)..."
rows={2}
/>
</div>
{/* Avance de Habilidad */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1.5">Avance de Habilidad</label>
<Textarea
value={editedPersonaje.avanceHabilidad}
onChange={(e) => handleChange('avanceHabilidad', e.target.value)}
placeholder="Cómo progresa a lo largo de la serie..."
rows={2}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-4 border-t border-stone-100">
<Button variant="ghost" size="sm" onClick={handleDelete} className="text-red-500 hover:text-red-600 hover:bg-red-50">
<Trash2 className="w-3.5 h-3.5" />
Eliminar
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancelar
</Button>
<Button size="sm" onClick={handleSave}>
<Save className="w-3.5 h-3.5" />
Guardar
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useEffect } from 'react';
import { Image } from 'lucide-react';
import type { Personaje, CharacterAssetsData } from '@/types/project';
interface PersonajesViewProps {
personajes: Personaje[];
slug: string;
onSelectPersonaje?: (personaje: Personaje) => void;
onOpenAssets?: (personaje: Personaje) => void;
selectedId?: string;
}
export function PersonajesView({ personajes, slug, onSelectPersonaje, onOpenAssets, selectedId }: PersonajesViewProps) {
const [baseImages, setBaseImages] = useState<Record<string, string>>({});
useEffect(() => {
let cancelled = false;
async function fetchBaseImages() {
const images: Record<string, string> = {};
await Promise.all(
personajes.map(async (p) => {
try {
const res = await fetch(`/api/projects/${slug}/personajes/${p.id}/assets`);
if (!res.ok) return;
const data: CharacterAssetsData = await res.json();
const base = data.assets.find(a => a.isBase && a.imageUrl);
if (base?.imageUrl) images[p.id] = base.imageUrl;
} catch { /* ignore */ }
})
);
if (!cancelled) setBaseImages(images);
}
if (personajes.length > 0) fetchBaseImages();
return () => { cancelled = true; };
}, [personajes, slug]);
const getTipoStyle = (tipo: string) => {
switch (tipo) {
case 'protagonista': return 'bg-indigo-50 text-indigo-600';
case 'aliado': return 'bg-emerald-50 text-emerald-600';
case 'antagonista': return 'bg-red-50 text-red-600';
default: return 'bg-stone-100 text-stone-600';
}
};
return (
<div className="space-y-3">
{personajes.map((personaje) => (
<div
key={personaje.id}
onClick={() => onSelectPersonaje?.(personaje)}
className={`
bg-white border rounded-lg p-5 transition-all duration-150
${onSelectPersonaje ? 'cursor-pointer hover:border-stone-300' : ''}
${selectedId === personaje.id ? 'ring-2 ring-indigo-500 border-indigo-300' : 'border-stone-200'}
`}
>
<div className="flex gap-4">
{/* Base image thumbnail */}
{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]}
alt={personaje.nombre}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-20 h-20 flex-shrink-0 rounded-lg border border-stone-100 bg-stone-50 flex items-center justify-center">
<span className="text-[10px] text-stone-300">Sin imagen</span>
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-sm font-semibold text-stone-900">{personaje.nombre}</h3>
<span className={`px-2 py-0.5 text-[11px] font-medium rounded ${getTipoStyle(personaje.tipo)}`}>
{personaje.tipo}
</span>
{personaje.especie && (
<span className="text-xs text-stone-400">{personaje.especie}</span>
)}
<div className="flex-1" />
{onOpenAssets && (
<button
onClick={(e) => { e.stopPropagation(); onOpenAssets(personaje); }}
className="px-2 py-1 text-[11px] font-medium text-stone-400 hover:text-indigo-600 hover:bg-indigo-50 rounded transition-colors flex items-center gap-1"
>
<Image className="w-3 h-3" />
Assets
</button>
)}
</div>
{personaje.aparienciaFisica && (
<p className="text-sm text-stone-500 mb-2 leading-relaxed">{personaje.aparienciaFisica}</p>
)}
<div className="text-sm text-stone-600 mb-2">
<p>{personaje.rolPrincipal}</p>
</div>
{personaje.personalidad && (
<p className="text-xs text-stone-400 mb-2">{personaje.personalidad}</p>
)}
<div className="grid grid-cols-2 gap-x-6 gap-y-2 pt-2 border-t border-stone-100">
{personaje.utileriaVisual && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Utilería</span>
<p className="text-xs text-stone-600 mt-0.5">{personaje.utileriaVisual}</p>
</div>
)}
{personaje.comunicacion && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Comunicación</span>
<p className="text-xs text-stone-600 mt-0.5">{personaje.comunicacion}</p>
</div>
)}
{personaje.mecanicaNarrativa && (
<div className="col-span-2 mt-1">
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Mecánica narrativa</span>
<p className="text-xs text-stone-600 mt-0.5">{personaje.mecanicaNarrativa}</p>
</div>
)}
</div>
</div>
</div>
</div>
))}
{personajes.length === 0 && (
<div className="text-center py-12">
<p className="text-sm text-stone-400">No hay personajes definidos</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { forwardRef } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className = '', variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none';
const variants = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700 active:bg-indigo-800 focus-visible:ring-indigo-500 shadow-sm',
secondary: 'bg-stone-800 text-white hover:bg-stone-900 active:bg-stone-950 focus-visible:ring-stone-500 shadow-sm',
outline: 'border border-stone-200 bg-white text-stone-700 hover:bg-stone-50 hover:border-stone-300 active:bg-stone-100 focus-visible:ring-stone-400',
ghost: 'bg-transparent text-stone-600 hover:bg-stone-100 hover:text-stone-900 active:bg-stone-150 focus-visible:ring-stone-400',
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus-visible:ring-red-500 shadow-sm',
};
const sizes = {
sm: 'px-2.5 py-1.5 text-xs gap-1',
md: 'px-4 py-2 text-sm gap-1.5',
lg: 'px-5 py-2.5 text-base gap-2',
};
return (
<button
ref={ref}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,38 @@
'use client';
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className = '' }: CardProps) {
return (
<div className={`bg-white rounded-lg border border-stone-200/80 ${className}`}>
{children}
</div>
);
}
export function CardHeader({ children, className = '' }: CardProps) {
return (
<div className={`px-5 py-4 border-b border-stone-100 ${className}`}>
{children}
</div>
);
}
export function CardContent({ children, className = '' }: CardProps) {
return (
<div className={`px-5 py-5 ${className}`}>
{children}
</div>
);
}
export function CardFooter({ children, className = '' }: CardProps) {
return (
<div className={`px-5 py-4 border-t border-stone-100 bg-stone-50/50 rounded-b-lg ${className}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', label, error, id, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label htmlFor={id} className="block text-sm font-medium text-stone-600 mb-1.5">
{label}
</label>
)}
<input
ref={ref}
id={id}
className={`
w-full px-3 py-2 border rounded-lg bg-white text-stone-900
placeholder:text-stone-400
focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400
transition-colors duration-150
${error ? 'border-red-400' : 'border-stone-200'}
${className}
`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className = '', label, error, id, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label htmlFor={id} className="block text-sm font-medium text-stone-600 mb-1.5">
{label}
</label>
)}
<textarea
ref={ref}
id={id}
className={`
w-full px-3 py-2 border rounded-lg resize-none bg-white text-stone-900
placeholder:text-stone-400
focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400
transition-colors duration-150
${error ? 'border-red-400' : 'border-stone-200'}
${className}
`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Textarea.displayName = 'Textarea';

View File

@@ -0,0 +1,148 @@
'use client';
import { useEffect, useRef } from 'react';
import { X, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
export interface ProgressLogEntry {
label: string;
status: 'success' | 'error';
detail?: string;
}
interface ProgressModalProps {
open: boolean;
title: string;
current: number;
total: number; // 0 = indeterminate
currentLabel: string;
logs: ProgressLogEntry[];
onCancel: () => void;
}
export function ProgressModal({ open, title, current, total, currentLabel, logs, onCancel }: ProgressModalProps) {
const logsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs.length]);
if (!open) return null;
const isIndeterminate = total === 0;
const progress = total > 0 ? (current / total) * 100 : 0;
const isDone = !isIndeterminate && current >= total;
return (
<>
<style>{`
@keyframes progress-indeterminate {
0% { margin-left: -30%; }
50% { margin-left: 80%; }
100% { margin-left: -30%; }
}
`}</style>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={isDone ? onCancel : undefined}
/>
<div className="relative bg-white rounded-xl shadow-2xl border border-stone-200 w-full max-w-lg mx-4 overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">{title}</h3>
{isDone && (
<button onClick={onCancel} className="text-stone-400 hover:text-stone-600 transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Progress */}
<div className="px-5 py-4 space-y-3">
{isIndeterminate ? (
<div className="w-full h-1.5 bg-stone-100 rounded-full overflow-hidden">
<div
className="h-full w-[30%] bg-indigo-500 rounded-full"
style={{ animation: 'progress-indeterminate 1.5s ease-in-out infinite' }}
/>
</div>
) : (
<>
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-stone-500">
{isDone ? 'Completado' : `${current} de ${total}`}
</span>
<span className="text-stone-400">{Math.round(progress)}%</span>
</div>
<div className="w-full h-1.5 bg-stone-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${isDone ? 'bg-emerald-500' : 'bg-indigo-500'}`}
style={{ width: `${progress}%` }}
/>
</div>
</>
)}
{!isDone && (
<div className="flex items-center gap-2">
<Loader2 className="w-3.5 h-3.5 text-indigo-500 animate-spin flex-shrink-0" />
<span className="text-sm text-stone-600 truncate">{currentLabel}</span>
</div>
)}
{isDone && logs.length > 0 && (() => {
const errors = logs.filter(l => l.status === 'error').length;
const successes = logs.filter(l => l.status === 'success').length;
return (
<div className="flex items-center gap-2">
{errors > 0 ? (
<XCircle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" />
)}
<span className="text-sm text-stone-700">
{errors > 0 ? `${successes} completados, ${errors} con error` : 'Proceso completado'}
</span>
</div>
);
})()}
</div>
{/* Logs */}
{logs.length > 0 && (
<div className="px-5 pb-3 max-h-48 overflow-y-auto border-t border-stone-50">
<div className="space-y-1 pt-3">
{logs.map((log, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
{log.status === 'success' ? (
<CheckCircle2 className="w-3 h-3 text-emerald-500 flex-shrink-0 mt-0.5" />
) : (
<XCircle className="w-3 h-3 text-red-500 flex-shrink-0 mt-0.5" />
)}
<span className={log.status === 'error' ? 'text-red-600' : 'text-stone-500'}>{log.label}</span>
{log.detail && (
<span className="text-stone-300 ml-auto truncate flex-shrink-0 max-w-[200px]"> {log.detail}</span>
)}
</div>
))}
<div ref={logsEndRef} />
</div>
</div>
)}
{/* Actions */}
<div className="px-5 py-3 border-t border-stone-100 flex justify-end">
{isDone ? (
<Button size="sm" onClick={onCancel}>Cerrar</Button>
) : (
<Button size="sm" variant="danger" onClick={onCancel}>
Cancelar
</Button>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { forwardRef } from 'react';
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string; label: string }[];
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', label, error, id, options, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<select
ref={ref}
id={id}
className={`
w-full px-3 py-2 border rounded-lg shadow-sm bg-white text-gray-900
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
${error ? 'border-red-500' : 'border-gray-300'}
${className}
`}
{...props}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Select.displayName = 'Select';

View File

@@ -0,0 +1,40 @@
'use client';
import { useEffect } from 'react';
import { CheckCircle2, AlertCircle, X } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error';
onClose: () => void;
}
export function Toast({ message, type, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, 4000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className={`
fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium
shadow-lg shadow-black/5 animate-slide-up
${type === 'success'
? 'bg-white text-emerald-800 border border-emerald-200'
: 'bg-white text-red-800 border border-red-200'
}
`}>
{type === 'success'
? <CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
: <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
}
<span className="max-w-xs">{message}</span>
<button
onClick={onClose}
className="ml-1 p-0.5 rounded text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { Sparkles, RefreshCw, Save, Edit3 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Input';
interface GenerationPanelProps {
title: string;
description: string;
placeholder: string;
currentData: any;
onGenerate: (input: string) => Promise<void>;
onRefine: (feedback: string) => Promise<void>;
onSave: (data: any) => Promise<void>;
isLoading: boolean;
children: React.ReactNode;
}
export function GenerationPanel({
title,
description,
placeholder,
currentData,
onGenerate,
onRefine,
onSave,
isLoading,
children,
}: GenerationPanelProps) {
const [input, setInput] = useState('');
const [mode, setMode] = useState<'generate' | 'refine' | 'edit'>('generate');
const [feedback, setFeedback] = useState('');
const hasData = currentData !== null && currentData !== undefined;
const handleGenerate = async () => {
if (!input.trim()) return;
await onGenerate(input);
setInput('');
setMode('refine');
};
const handleRefine = async () => {
if (!feedback.trim()) return;
await onRefine(feedback);
setFeedback('');
};
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-stone-900">{title}</h2>
<p className="mt-1 text-stone-500 text-sm">{description}</p>
</div>
{!hasData && mode === 'generate' && (
<div className="border border-stone-200 rounded-lg p-6 bg-white">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={placeholder}
rows={5}
/>
<div className="mt-4 flex justify-end">
<Button onClick={handleGenerate} loading={isLoading}>
<Sparkles className="w-4 h-4" />
Generar con IA
</Button>
</div>
</div>
)}
{hasData && (
<>
<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">
<Button
variant="ghost"
size="sm"
onClick={() => setMode(mode === 'edit' ? 'refine' : 'edit')}
>
<Edit3 className="w-3.5 h-3.5" />
{mode === 'edit' ? 'Cerrar' : '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}
</div>
</div>
{mode === 'refine' && (
<div className="border border-stone-200 rounded-lg p-5 bg-white">
<div className="flex items-center gap-2 mb-3">
<RefreshCw className="w-4 h-4 text-stone-400" />
<span className="text-sm font-medium text-stone-700">Refinar con feedback</span>
</div>
<Textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Describe los cambios que quieres hacer..."
rows={3}
/>
<div className="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setFeedback('')}>
Limpiar
</Button>
<Button size="sm" onClick={handleRefine} loading={isLoading}>
<RefreshCw className="w-3.5 h-3.5" />
Refinar
</Button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { Check } from 'lucide-react';
interface Step {
id: string;
name: string;
description?: string;
}
interface WizardStepperProps {
steps: Step[];
currentStep: number;
onStepClick?: (index: number) => void;
}
export function WizardStepper({ steps, currentStep, onStepClick }: WizardStepperProps) {
return (
<nav aria-label="Progress">
<ol className="flex items-center gap-2">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isClickable = onStepClick && index <= currentStep;
return (
<li key={step.id} className="flex items-center gap-2 flex-1">
<button
type="button"
onClick={() => isClickable && onStepClick(index)}
disabled={!isClickable}
className={`
flex items-center gap-3 w-full px-3 py-2.5 rounded-lg transition-colors duration-150
${isClickable ? 'cursor-pointer' : 'cursor-default'}
${isCurrent
? 'bg-indigo-50 border border-indigo-200'
: isCompleted
? 'bg-white border border-stone-200 hover:bg-stone-50'
: 'bg-stone-50 border border-transparent'
}
`}
>
<span
className={`
flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold
${isCompleted
? 'bg-indigo-600 text-white'
: isCurrent
? 'bg-indigo-600 text-white'
: 'bg-stone-200 text-stone-500'
}
`}
>
{isCompleted ? (
<Check className="h-3.5 w-3.5" />
) : (
<span>{index + 1}</span>
)}
</span>
<span className="flex flex-col min-w-0 text-left">
<span
className={`text-sm font-medium truncate ${
isCurrent ? 'text-indigo-700' : isCompleted ? 'text-stone-700' : 'text-stone-400'
}`}
>
{step.name}
</span>
{step.description && (
<span className={`text-xs truncate ${isCurrent ? 'text-indigo-500' : 'text-stone-400'}`}>
{step.description}
</span>
)}
</span>
</button>
{index !== steps.length - 1 && (
<div
className={`w-8 h-px flex-shrink-0 ${isCompleted ? 'bg-indigo-300' : 'bg-stone-200'}`}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
</nav>
);
}

313
src/hooks/useProjectData.ts Normal file
View File

@@ -0,0 +1,313 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { Project, IdeaMotriz, Personaje, Capitulo } from '@/types/project';
interface UseProjectDataReturn {
project: Project | null;
ideaMotriz: IdeaMotriz | null;
personajes: Personaje[];
capitulos: Capitulo[];
currentStep: number;
loading: boolean;
generating: boolean;
setIdeaMotriz: (data: IdeaMotriz | null) => void;
setPersonajes: (data: Personaje[]) => void;
setGenerating: (v: boolean) => void;
changeStep: (step: number) => Promise<void>;
canAdvance: () => boolean;
handleGenerateIdeaMotriz: (input: string) => Promise<void>;
handleRefineIdeaMotriz: (feedback: string) => Promise<void>;
handleSaveIdeaMotriz: () => Promise<void>;
handleGeneratePersonajes: () => Promise<void>;
handleSavePersonajes: () => Promise<void>;
handleMejorarPersonajes: (feedback: string) => Promise<void>;
handleAddPersonaje: (nuevoPersonaje: { nombre: string; tipo: string; especie: string; aparienciaFisica: string; descripcion: string }) => Promise<Personaje | null>;
handleUpdatePersonaje: (updated: Personaje) => void;
handleDeletePersonaje: (id: string) => void;
showToast: (message: string, type: 'success' | 'error') => void;
toast: { message: string; type: 'success' | 'error' } | null;
clearToast: () => void;
}
export function useProjectData(slug: string): UseProjectDataReturn {
const [project, setProject] = useState<Project | null>(null);
const [ideaMotriz, setIdeaMotriz] = useState<IdeaMotriz | null>(null);
const [personajes, setPersonajes] = useState<Personaje[]>([]);
const [capitulos, setCapitulos] = useState<Capitulo[]>([]);
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const showToast = useCallback((message: string, type: 'success' | 'error') => {
setToast({ message, type });
}, []);
const clearToast = useCallback(() => setToast(null), []);
useEffect(() => {
fetchData();
}, [slug]);
const fetchData = async () => {
setLoading(true);
try {
const [projectRes, ideaRes, personajesRes, capitulosRes] = await Promise.all([
fetch(`/api/projects/${slug}`),
fetch(`/api/projects/${slug}/idea-motriz`),
fetch(`/api/projects/${slug}/personajes`),
fetch(`/api/projects/${slug}/capitulos`),
]);
const projectData = await projectRes.json();
setProject(projectData);
const ideaData = await ideaRes.json();
if (ideaData && !ideaData.error) setIdeaMotriz(ideaData);
const personajesData = await personajesRes.json();
if (Array.isArray(personajesData)) setPersonajes(personajesData);
const capitulosData = await capitulosRes.json();
if (Array.isArray(capitulosData)) setCapitulos(capitulosData);
const stepMap: Record<string, number> = { idea_motriz: 0, personajes: 1, capitulos: 2 };
if (projectData?.currentStep && stepMap[projectData.currentStep] !== undefined) {
setCurrentStep(stepMap[projectData.currentStep]);
} else if (ideaData && !ideaData.error) {
if (personajesData.length > 0) {
setCurrentStep(2);
} else {
setCurrentStep(1);
}
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const changeStep = async (newStep: number) => {
setCurrentStep(newStep);
const stepNames = ['idea_motriz', 'personajes', 'capitulos'];
try {
await fetch(`/api/projects/${slug}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentStep: stepNames[newStep] || stepNames[0] }),
});
} catch (error) {
console.error('Error persisting step:', error);
}
};
const canAdvance = () => {
switch (currentStep) {
case 0: return ideaMotriz !== null;
case 1: return personajes.length > 0;
default: return true;
}
};
// --- Idea Motriz ---
const handleGenerateIdeaMotriz = async (input: string) => {
setGenerating(true);
try {
const res = await fetch(`/api/projects/${slug}/idea-motriz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', input }),
});
const data = await res.json();
if (data && !data.error) {
setIdeaMotriz(data);
showToast('Idea Motriz generada correctamente', 'success');
} else {
showToast(data.error || 'Error generando Idea Motriz', 'error');
}
} catch (error) {
console.error('Error generating idea motriz:', error);
showToast('Error de conexión al generar Idea Motriz', 'error');
} finally {
setGenerating(false);
}
};
const handleRefineIdeaMotriz = async (feedback: string) => {
if (!ideaMotriz) return;
setGenerating(true);
try {
const res = await fetch(`/api/projects/${slug}/idea-motriz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'refine', input: feedback, current: ideaMotriz }),
});
const data = await res.json();
if (data && !data.error) {
setIdeaMotriz(data);
showToast('Idea Motriz refinada correctamente', 'success');
} else {
showToast(data.error || 'Error refinando Idea Motriz', 'error');
}
} catch (error) {
console.error('Error refining idea motriz:', error);
showToast('Error de conexión al refinar Idea Motriz', 'error');
} finally {
setGenerating(false);
}
};
const handleSaveIdeaMotriz = async () => {
if (!ideaMotriz) 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 }),
});
showToast('Idea Motriz guardada', 'success');
} catch (error) {
console.error('Error saving idea motriz:', error);
showToast('Error al guardar Idea Motriz', 'error');
} finally {
setGenerating(false);
}
};
// --- Personajes ---
const handleGeneratePersonajes = async () => {
setGenerating(true);
try {
const res = await fetch(`/api/projects/${slug}/personajes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate' }),
});
const data = await res.json();
if (Array.isArray(data)) {
setPersonajes(data);
showToast('Personajes generados correctamente', 'success');
} else {
showToast(data.error || 'Error generando personajes', 'error');
}
} catch (error) {
console.error('Error generating personajes:', error);
showToast('Error de conexión al generar personajes', 'error');
} finally {
setGenerating(false);
}
};
const handleSavePersonajes = async () => {
setGenerating(true);
try {
await fetch(`/api/projects/${slug}/personajes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save', personajes }),
});
showToast('Personajes guardados', 'success');
} catch (error) {
console.error('Error saving personajes:', error);
showToast('Error al guardar personajes', 'error');
} finally {
setGenerating(false);
}
};
const handleMejorarPersonajes = async (feedback: string) => {
if (!feedback.trim()) return;
setGenerating(true);
try {
const res = await fetch(`/api/projects/${slug}/personajes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'improve', feedback, personajes }),
});
const data = await res.json();
if (Array.isArray(data)) {
setPersonajes(data);
showToast('Personajes mejorados correctamente', 'success');
} else {
showToast(data.error || 'Error mejorando personajes', 'error');
}
} catch (error) {
console.error('Error mejorando personajes:', error);
showToast('Error de conexión al mejorar personajes', 'error');
} finally {
setGenerating(false);
}
};
const handleAddPersonaje = async (nuevoPersonaje: { nombre: string; tipo: string; especie: string; aparienciaFisica: string; descripcion: string }): Promise<Personaje | null> => {
if (!nuevoPersonaje.nombre.trim()) return null;
setGenerating(true);
try {
const res = await fetch(`/api/projects/${slug}/personajes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'add',
nuevoPersonaje,
personajes,
ideaMotriz,
}),
});
const data = await res.json();
if (data && !data.error) {
setPersonajes([...personajes, data]);
showToast(`Personaje "${data.nombre}" creado`, 'success');
return data as Personaje;
} else {
showToast(data.error || 'Error creando personaje', 'error');
return null;
}
} catch (error) {
console.error('Error añadiendo personaje:', error);
showToast('Error de conexión al crear personaje', 'error');
return null;
} finally {
setGenerating(false);
}
};
const handleUpdatePersonaje = (updatedPersonaje: Personaje) => {
setPersonajes(personajes.map(p => p.id === updatedPersonaje.id ? updatedPersonaje : p));
};
const handleDeletePersonaje = (id: string) => {
setPersonajes(personajes.filter(p => p.id !== id));
};
return {
project,
ideaMotriz,
personajes,
capitulos,
currentStep,
loading,
generating,
setIdeaMotriz,
setPersonajes,
setGenerating,
changeStep,
canAdvance,
handleGenerateIdeaMotriz,
handleRefineIdeaMotriz,
handleSaveIdeaMotriz,
handleGeneratePersonajes,
handleSavePersonajes,
handleMejorarPersonajes,
handleAddPersonaje,
handleUpdatePersonaje,
handleDeletePersonaje,
showToast,
toast,
clearToast,
};
}

324
src/lib/file-service.ts Normal file
View File

@@ -0,0 +1,324 @@
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;
}

216
src/lib/image-service.ts Normal file
View File

@@ -0,0 +1,216 @@
import { GoogleGenAI } from '@google/genai';
import fs from 'fs/promises';
import path from 'path';
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
let genaiClient: GoogleGenAI | null = null;
function getGenAI(): GoogleGenAI {
if (!genaiClient) {
if (!process.env.GEMINI_API_KEY) {
throw new Error('GEMINI_API_KEY no está configurada. Añádela a .env.local');
}
genaiClient = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
}
return genaiClient;
}
// ---------------------
// IMAGE STORAGE
// ---------------------
function getImagesDir(slug: string, personajeId: string): string {
return path.join(PROJECTS_DIR, slug, 'personajes', personajeId, 'images');
}
export function getImagePath(slug: string, personajeId: string, assetId: string): string {
return path.join(getImagesDir(slug, personajeId), `${assetId}.png`);
}
export async function saveImage(slug: string, personajeId: string, assetId: string, imageBuffer: Buffer): Promise<string> {
const dir = getImagesDir(slug, personajeId);
await fs.mkdir(dir, { recursive: true });
const filePath = getImagePath(slug, personajeId, assetId);
await fs.writeFile(filePath, imageBuffer);
return `/api/projects/${slug}/personajes/${personajeId}/images/${assetId}`;
}
export async function loadImage(slug: string, personajeId: string, assetId: string): Promise<Buffer | null> {
try {
const filePath = getImagePath(slug, personajeId, assetId);
return await fs.readFile(filePath);
} catch {
return null;
}
}
// ---------------------
// TEXT-TO-IMAGE (base character)
// ---------------------
export async function generateImage(
prompt: string,
options?: { aspectRatio?: string }
): Promise<Buffer> {
const ai = getGenAI();
const response = await ai.models.generateContent({
model: 'gemini-3-pro-image-preview',
contents: prompt,
config: {
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: {
aspectRatio: options?.aspectRatio || '1:1',
},
},
});
const candidates = response.candidates;
if (!candidates || candidates.length === 0) {
throw new Error('No se generó imagen: sin candidatos');
}
for (const part of candidates[0].content?.parts || []) {
if (part.inlineData) {
const buffer = Buffer.from(part.inlineData.data!, 'base64');
return buffer;
}
}
throw new Error('No se encontró imagen en la respuesta');
}
// ---------------------
// IMAGE-TO-IMAGE (variations from reference)
// ---------------------
export async function generateVariation(
referenceImageBuffer: Buffer,
prompt: string,
options?: { aspectRatio?: string }
): Promise<Buffer> {
const ai = getGenAI();
const base64Ref = referenceImageBuffer.toString('base64');
const referenceInstruction = `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${prompt}`;
const contents = [
{
inlineData: {
mimeType: 'image/png',
data: base64Ref,
},
},
{ text: referenceInstruction },
];
const response = await ai.models.generateContent({
model: 'gemini-3-pro-image-preview',
contents,
config: {
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: {
aspectRatio: options?.aspectRatio || '1:1',
},
},
});
const candidates = response.candidates;
if (!candidates || candidates.length === 0) {
throw new Error('No se generó variación: sin candidatos');
}
for (const part of candidates[0].content?.parts || []) {
if (part.inlineData) {
return Buffer.from(part.inlineData.data!, 'base64');
}
}
throw new Error('No se encontró imagen en la respuesta de variación');
}
// ---------------------
// SCENE IMAGE (multi-reference)
// ---------------------
export async function generateSceneImage(
referenceImages: { buffer: Buffer; label: string }[],
prompt: string,
options?: { aspectRatio?: string }
): Promise<Buffer> {
const ai = getGenAI();
const contents: any[] = [];
// Add each reference image with its label
for (const ref of referenceImages) {
contents.push({
inlineData: {
mimeType: 'image/png',
data: ref.buffer.toString('base64'),
},
});
contents.push({ text: `[Reference: ${ref.label}]` });
}
// Add the scene prompt
const instruction = referenceImages.length > 0
? `Using the character reference images above as exact visual references (maintain their colors, proportions, features, accessories, and art style), generate the following scene:\n\n${prompt}`
: prompt;
contents.push({ text: instruction });
const response = await ai.models.generateContent({
model: 'gemini-3-pro-image-preview',
contents,
config: {
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: {
aspectRatio: options?.aspectRatio || '16:9',
},
},
});
const candidates = response.candidates;
if (!candidates || candidates.length === 0) {
throw new Error('No se generó imagen de escena: sin candidatos');
}
for (const part of candidates[0].content?.parts || []) {
if (part.inlineData) {
return Buffer.from(part.inlineData.data!, 'base64');
}
}
throw new Error('No se encontró imagen en la respuesta de escena');
}
// ---------------------
// PLANO IMAGE STORAGE
// ---------------------
function getPlanoImagesDir(slug: string, capDir: string): string {
return path.join(PROJECTS_DIR, slug, 'capitulos', capDir, 'imagenes');
}
export function getPlanoImagePath(slug: string, capDir: string, planoId: string): string {
return path.join(getPlanoImagesDir(slug, capDir), `${planoId}.png`);
}
export async function savePlanoImage(slug: string, capDir: string, planoId: string, imageBuffer: Buffer): Promise<string> {
const dir = getPlanoImagesDir(slug, capDir);
await fs.mkdir(dir, { recursive: true });
const filePath = getPlanoImagePath(slug, capDir, planoId);
await fs.writeFile(filePath, imageBuffer);
// capDir is e.g. "01" — use it directly as numero in the URL (findCapDir handles padding)
return `/api/projects/${slug}/capitulos/${capDir}/imagenes/${planoId}`;
}
export async function loadPlanoImage(slug: string, capDir: string, planoId: string): Promise<Buffer | null> {
try {
const filePath = getPlanoImagePath(slug, capDir, planoId);
return await fs.readFile(filePath);
} catch {
return null;
}
}

278
src/lib/llm-service.ts Normal file
View File

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

600
src/lib/prompts.ts Normal file
View File

@@ -0,0 +1,600 @@
import type { IdeaMotriz, Personaje, GuionConductor, ActoGuion, Escaleta, CharacterAsset } from '@/types/project';
// =====================
// IDEA MOTRIZ
// =====================
export function getIdeaMotrizGeneratePrompt(): string {
return `Eres un director creativo experto en series animadas.
Tu tarea es crear la "Idea Motriz" completa de una serie animada basándote en la descripción del usuario.
Debes generar un JSON con esta estructura exacta:
{
"concepto": "Resumen narrativo del concepto",
"ideaMotrizCentral": "La premisa central en una frase",
"formato": {
"capitulos": número de capítulos,
"duracionMinutos": duración por capítulo
},
"narrativa": "Descripción del estilo narrativo (con/sin diálogos, etc.)",
"expansion": "Cómo puede expandirse la serie",
"valores": ["valor1", "valor2", ...],
"personajesSecundarios": "Descripción del rol de personajes secundarios",
"protagonistas": [
{
"nombre": "Nombre",
"rol": "Rol principal",
"utileria": "Objeto distintivo",
"mecanica": "Mecánica de habilidad"
}
],
"antagonista": {
"nombre": "Nombre del antagonista",
"descripcion": "Descripción",
"representacionVisual": "Cómo se ve",
"mision": "Qué busca o representa"
},
"estructuraGuion": [
{
"numero": 1,
"nombre": "Nombre del acto",
"duracionMin": minutos mínimos,
"duracionMax": minutos máximos,
"descripcion": "Qué ocurre en este acto"
}
]
}
Responde SOLO con el JSON, sin texto adicional.`;
}
export function getIdeaMotrizRefinePrompt(current: IdeaMotriz): string {
return `Eres un director creativo. Tienes la siguiente Idea Motriz de una serie:
${JSON.stringify(current, null, 2)}
El usuario quiere hacer ajustes. Modifica la idea según su feedback manteniendo la estructura JSON.
Responde SOLO con el JSON completo actualizado.`;
}
// =====================
// PERSONAJES
// =====================
export function getPersonajesGeneratePrompt(ideaMotriz: IdeaMotriz): string {
return `Eres un diseñador de personajes experto. Basándote en esta Idea Motriz:
${JSON.stringify(ideaMotriz, null, 2)}
Genera personajes detallados. Para cada personaje incluye:
- id: UUID único
- nombre
- tipo: "protagonista" | "aliado" | "antagonista"
- especie: qué animal o criatura es (ej: "gato", "búho", "zorro")
- aparienciaFisica: descripción visual detallada (color de pelaje/plumas, tamaño, rasgos distintivos, expresión)
- rolPrincipal: su función en la historia
- personalidad: rasgos de carácter
- habilidadInnata: qué sabe hacer naturalmente
- utileriaVisual: objetos distintivos que lleva
- mecanicaNarrativa: cómo funciona en la historia
- comunicacion: cómo se expresa (sonidos, gestos)
- avanceHabilidad: cómo progresa
Responde con un JSON: { "personajes": [...] }`;
}
export function getPersonajeRefinePrompt(personaje: Personaje): string {
return `Tienes este personaje:
${JSON.stringify(personaje, null, 2)}
Modifícalo según el feedback del usuario. Responde SOLO con el JSON del personaje actualizado.`;
}
export function getPersonajesImprovePrompt(personajes: Personaje[]): string {
return `Tienes esta lista de personajes:
${JSON.stringify(personajes, null, 2)}
El usuario quiere mejorarlos según su feedback. Modifica los personajes manteniendo la estructura.
Responde con JSON: { "personajes": [...] }`;
}
export function getNewPersonajePrompt(
datos: { nombre: string; tipo: string; descripcion: string },
ideaMotriz: IdeaMotriz,
personajesExistentes: Personaje[]
): string {
return `Eres un diseñador de personajes. Crea un personaje completo para una serie animada.
CONTEXTO DE LA SERIE:
${JSON.stringify(ideaMotriz, null, 2)}
PERSONAJES YA EXISTENTES (para evitar duplicados y mantener coherencia):
${JSON.stringify(personajesExistentes.map(p => ({ nombre: p.nombre, tipo: p.tipo, rol: p.rolPrincipal })), null, 2)}
NUEVO PERSONAJE A CREAR:
- Nombre: ${datos.nombre}
- Tipo: ${datos.tipo}
- Descripción del usuario: ${datos.descripcion || 'Sin descripción adicional'}
Genera un JSON completo con esta estructura:
{
"id": "id-unico",
"nombre": "${datos.nombre}",
"tipo": "${datos.tipo}",
"especie": "qué animal o criatura es",
"aparienciaFisica": "descripción visual detallada (color, tamaño, rasgos distintivos)",
"rolPrincipal": "descripción del rol",
"personalidad": "rasgos de carácter",
"habilidadInnata": "qué sabe hacer",
"utileriaVisual": "objetos distintivos",
"mecanicaNarrativa": "cómo funciona en la historia",
"comunicacion": "cómo se expresa",
"avanceHabilidad": "cómo progresa"
}
Responde SOLO con el JSON.`;
}
// =====================
// GUIÓN CONDUCTOR
// =====================
export function getGuionConductorPrompt(
ideaMotriz: IdeaMotriz,
personajes: Personaje[],
capituloNumero: number,
virtudSugerida?: string
): string {
return `Eres un guionista experto. Genera un guión conductor para el capítulo ${capituloNumero}.
CONTEXTO DE LA SERIE:
${JSON.stringify(ideaMotriz, null, 2)}
PERSONAJES:
${JSON.stringify(personajes, null, 2)}
${virtudSugerida ? `VIRTUD SUGERIDA: ${virtudSugerida}` : 'Elige una virtud apropiada para este capítulo.'}
Genera un JSON con esta estructura:
{
"titulo": "Título del capítulo",
"virtudGanada": "La virtud que se gana",
"duracionTotal": "20:00",
"nudoDelMundo": "El problema/conflicto del capítulo",
"aliado": {
"nombre": "Nombre del aliado (animal personificado)",
"descripcion": "Descripción visual y rol"
},
"gemaGanada": "Nombre de la gema",
"actos": [
{
"numero": 1,
"nombre": "Nombre del acto",
"tiempoInicio": "0:00",
"tiempoFin": "4:00",
"segmentos": [
{
"id": "I.1",
"nombre": "Nombre del segmento",
"tiempo": "0:00-1:00",
"accionVisualClave": "Descripción detallada de lo que ocurre visualmente",
"disenoFondo": "Descripción del escenario/ambiente: lugar, iluminación, hora del día, elementos del entorno, atmósfera",
"disenoSonido": "Música, efectos, vocalizaciones"
}
]
}
]
}
Sigue la estructura de 4 actos según la idea motriz. Responde SOLO con el JSON.`;
}
/**
* Prompt para regenerar SOLO los actos del guión, manteniendo la metadata fija.
* El LLM recibe titulo, duracionTotal, aliado, virtudGanada, gemaGanada, nudoDelMundo
* como restricciones fijas y genera únicamente el array de actos con sus segmentos.
*/
export function getRegenerateActosPrompt(
guionMetadata: {
titulo: string;
duracionTotal: string;
virtudGanada: string;
nudoDelMundo: string;
aliado: { nombre: string; descripcion: string };
gemaGanada: string;
},
ideaMotriz: IdeaMotriz,
personajes: Personaje[],
capituloNumero: number
): string {
return `Eres un guionista experto. Genera SOLO los actos y segmentos para el capítulo ${capituloNumero}.
Los siguientes datos del capítulo ya están definidos y son FIJOS (NO los modifiques ni los incluyas en tu respuesta):
- Título: ${guionMetadata.titulo}
- Duración total: ${guionMetadata.duracionTotal}
- Virtud ganada: ${guionMetadata.virtudGanada}
- Nudo del mundo: ${guionMetadata.nudoDelMundo}
- Aliado: ${guionMetadata.aliado.nombre}${guionMetadata.aliado.descripcion}
- Gema ganada: ${guionMetadata.gemaGanada}
CONTEXTO DE LA SERIE:
${JSON.stringify(ideaMotriz, null, 2)}
PERSONAJES:
${JSON.stringify(personajes, null, 2)}
Genera un JSON con SOLO los actos. Los actos deben encajar dentro de la duración total de ${guionMetadata.duracionTotal} y desarrollar el nudo "${guionMetadata.nudoDelMundo}" involucrando al aliado "${guionMetadata.aliado.nombre}".
Sigue la estructura de actos según la idea motriz (típicamente 4 actos).
{
"actos": [
{
"numero": 1,
"nombre": "Nombre del acto",
"tiempoInicio": "0:00",
"tiempoFin": "4:00",
"segmentos": [
{
"id": "I.1",
"nombre": "Nombre del segmento",
"tiempo": "0:00-1:00",
"accionVisualClave": "Descripción detallada de lo que ocurre visualmente",
"disenoFondo": "Descripción del escenario/ambiente: lugar, iluminación, hora del día, elementos del entorno, atmósfera",
"disenoSonido": "Música, efectos, vocalizaciones"
}
]
}
]
}
Responde SOLO con el JSON.`;
}
// =====================
// ESCALETA
// =====================
/** Parse "M:SS" or "MM:SS" to seconds */
export function parseTimeToSeconds(t: string): number {
const parts = t.split(':').map(Number);
if (parts.length === 2) return parts[0] * 60 + parts[1];
return 0;
}
/** Parse "M:SS-M:SS" range and return duration in seconds */
export function parseTimeRange(range: string): number {
const parts = range.split('-').map(s => s.trim());
if (parts.length !== 2) return 0;
return parseTimeToSeconds(parts[1]) - parseTimeToSeconds(parts[0]);
}
export function getEscaletaPrompt(
guion: GuionConductor,
ideaMotriz: IdeaMotriz,
personajes: Personaje[]
): string {
const personajesRef = personajes.map(p =>
`- ID: "${p.id}", Nombre: "${p.nombre}" (${p.especie}, ${p.tipo}). Utilería: ${p.utileriaVisual}`
).join('\n');
// Build per-segment time budget from guión
let segmentBudgets = '';
let totalBudget = 0;
if (guion.actos) {
for (const acto of guion.actos) {
for (const seg of acto.segmentos) {
const duracion = parseTimeRange(seg.tiempo);
const numPlanos = Math.max(2, Math.round(duracion / 5));
totalBudget += duracion;
segmentBudgets += `- ${seg.id} "${seg.nombre}" (${seg.tiempo}): ${duracion}s → genera ~${numPlanos} planos\n`;
}
}
}
if (!totalBudget) {
totalBudget = parseTimeToSeconds(guion.duracionTotal || '20:00');
}
return `Eres un director de animación. Convierte este guión conductor en una escaleta detallada plano por plano.
GUIÓN CONDUCTOR:
${JSON.stringify(guion, null, 2)}
CONTEXTO:
- Serie: ${ideaMotriz.ideaMotrizCentral}
- Narrativa: ${ideaMotriz.narrativa}
PERSONAJES DISPONIBLES (usa estos IDs exactos):
${personajesRef}
EMOCIONES VÁLIDAS: alegria | tristeza | sorpresa | miedo | enfado | neutral
PRESUPUESTO DE TIEMPO POR SEGMENTO (OBLIGATORIO):
${segmentBudgets}
TOTAL: ${totalBudget}s
REGLA CRÍTICA: La suma de tiempoSegundos de los planos de CADA segmento DEBE coincidir con la duración del segmento en el guión. Si un segmento dura 90s, la suma de sus planos debe ser exactamente 90s. NO generes planos que sumen menos del 90% del tiempo asignado.
Para cada segmento, genera suficientes planos (de 3-8 segundos cada uno) para cubrir TODO su tiempo:
- Un segmento de 60s necesita ~10-12 planos
- Un segmento de 90s necesita ~15-18 planos
- Un segmento de 120s necesita ~20-24 planos
- Un segmento de 150s necesita ~25-30 planos
Cada plano debe tener:
- id: identificador único (ej: "plano-001")
- numeroPlano: formato "acto.segmento.plano" (ej: "1.1.1")
- segmentoGuion: referencia al segmento (ej: "I.1 Inicio Tranquilo")
- tiempoSegundos: duración (3-8 seg)
- accionDescripcion: descripción técnica detallada (tipo de plano, ángulo de cámara, iluminación, acción específica del personaje, elementos visuales). Varía los tipos de plano: wide shot, medium shot, close-up, over-the-shoulder, POV, etc.
- indicacionesSonido: música, efectos, vocalizaciones
- personajesDetalle: array de personajes que aparecen, cada uno con:
- personajeId: ID exacto (ej: "coco-001")
- nombre: nombre del personaje
- emocion: una emoción válida que refleje su estado en ese plano
IMPORTANTE: Desglosa cada acción del guión en múltiples planos cinematográficos. Un personaje "corriendo hacia un lugar" puede ser: plano de pies corriendo, plano medio de expresión, plano general del recorrido, plano de llegada. Cada emoción visual merece su propio plano.
Responde con JSON:
{
"capitulo": "título",
"virtudGanada": "virtud",
"duracionTotalSegundos": ${totalBudget},
"planos": [...]
}`;
}
export function getEscaletaActoPrompt(
acto: ActoGuion,
guion: GuionConductor,
ideaMotriz: IdeaMotriz,
personajes: Personaje[]
): string {
const personajesRef = personajes.map(p =>
`- ID: "${p.id}", Nombre: "${p.nombre}" (${p.especie}, ${p.tipo}). Utilería: ${p.utileriaVisual}`
).join('\n');
let segmentBudgets = '';
let actoBudget = 0;
for (const seg of acto.segmentos) {
const duracion = parseTimeRange(seg.tiempo);
const numPlanos = Math.max(2, Math.round(duracion / 5));
actoBudget += duracion;
segmentBudgets += `- ${seg.id} "${seg.nombre}" (${seg.tiempo}): ${duracion}s → genera ~${numPlanos} planos\n`;
}
return `Eres un director de animación. Genera la escaleta detallada (plano por plano) para el ACTO ${acto.numero} "${acto.nombre}" (${acto.tiempoInicio}-${acto.tiempoFin}).
CONTEXTO DE LA SERIE:
- Serie: ${ideaMotriz.ideaMotrizCentral}
- Narrativa: ${ideaMotriz.narrativa}
- Capítulo: ${guion.titulo}
ACTO ${acto.numero} - SEGMENTOS:
${JSON.stringify(acto.segmentos, null, 2)}
PERSONAJES DISPONIBLES (usa estos IDs exactos):
${personajesRef}
EMOCIONES VÁLIDAS: alegria | tristeza | sorpresa | miedo | enfado | neutral
PRESUPUESTO DE TIEMPO POR SEGMENTO (OBLIGATORIO):
${segmentBudgets}
TOTAL ACTO: ${actoBudget}s
REGLA CRÍTICA: La suma de tiempoSegundos de los planos de CADA segmento DEBE coincidir con la duración del segmento. NO generes planos que sumen menos del 90% del tiempo asignado.
Guías de cantidad:
- Un segmento de 60s necesita ~10-12 planos
- Un segmento de 90s necesita ~15-18 planos
- Un segmento de 120s necesita ~20-24 planos
- Un segmento de 150s necesita ~25-30 planos
Cada plano debe tener:
- id: identificador único (ej: "plano-001")
- numeroPlano: formato "acto.segmento.plano" (ej: "${acto.numero}.1.1")
- segmentoGuion: referencia al segmento (ej: "${acto.segmentos[0]?.id} ${acto.segmentos[0]?.nombre}")
- tiempoSegundos: duración (3-8 seg)
- accionDescripcion: descripción técnica detallada (tipo de plano, ángulo de cámara, iluminación, acción, elementos visuales). Varía: wide shot, medium shot, close-up, over-the-shoulder, POV, etc.
- indicacionesSonido: música, efectos, vocalizaciones
- personajesDetalle: array de personajes que aparecen, cada uno con:
- personajeId: ID exacto (ej: "coco-001")
- nombre: nombre del personaje
- emocion: una emoción válida que refleje su estado en ese plano
IMPORTANTE: Desglosa cada acción en múltiples planos cinematográficos. "Un personaje corriendo" = plano de pies, plano medio de expresión, plano general del recorrido, plano de llegada. Cada emoción merece su propio plano.
Responde con JSON:
{
"planos": [...]
}`;
}
/**
* Per-segment prompt: generates planos for a SINGLE segment (escena) of the guión.
* This is the most granular approach — one LLM call per segment — which ensures
* the LLM generates the exact number of planos needed for that segment's duration.
*/
export function getEscaletaSegmentoPrompt(
segmento: { id: string; nombre: string; tiempo: string; accionVisualClave: string; disenoSonido: string },
actoNumero: number,
segmentoIndex: number,
guion: GuionConductor,
ideaMotriz: IdeaMotriz,
personajes: Personaje[],
targetPlanos: number,
targetSeconds: number,
subBatchInfo?: { batchIndex: number; totalBatches: number; planosInThisBatch: number; startPlano: number }
): string {
const personajesRef = personajes.map(p =>
`- ID: "${p.id}", Nombre: "${p.nombre}" (${p.especie}, ${p.tipo}). Utilería: ${p.utileriaVisual}`
).join('\n');
const actualPlanos = subBatchInfo ? subBatchInfo.planosInThisBatch : targetPlanos;
const startPlano = subBatchInfo ? subBatchInfo.startPlano : 1;
const batchNote = subBatchInfo
? `\nNOTA: Este es el lote ${subBatchInfo.batchIndex + 1} de ${subBatchInfo.totalBatches} para este segmento. Genera los planos ${startPlano} a ${startPlano + actualPlanos - 1} de ${targetPlanos} totales.`
: '';
return `Eres un director de animación. Genera EXACTAMENTE ${actualPlanos} planos para el segmento "${segmento.id} ${segmento.nombre}" del capítulo "${guion.titulo}".
${batchNote}
CONTEXTO:
- Serie: ${ideaMotriz.ideaMotrizCentral}
- Narrativa: ${ideaMotriz.narrativa}
- Capítulo: ${guion.titulo}
- Virtud: ${guion.virtudGanada}
SEGMENTO A DESGLOSAR:
- ID: ${segmento.id}
- Nombre: ${segmento.nombre}
- Tiempo: ${segmento.tiempo} (${targetSeconds} segundos total)
- Acción visual: ${segmento.accionVisualClave}
- Diseño de sonido: ${segmento.disenoSonido}
PERSONAJES DISPONIBLES (usa estos IDs exactos):
${personajesRef}
EMOCIONES VÁLIDAS: alegria | tristeza | sorpresa | miedo | enfado | neutral
═══════════════════════════════════════════════
INSTRUCCIÓN OBLIGATORIA: Genera EXACTAMENTE ${actualPlanos} planos.
Cada plano debe durar 5 segundos (tiempoSegundos: 5).
Total: ${actualPlanos} planos × 5s = ${actualPlanos * 5}s
═══════════════════════════════════════════════
Cada plano debe variar en tipo de encuadre y ángulo de cámara para crear ritmo visual:
- Wide/establishing shot: para mostrar escenario completo
- Medium shot: personaje de cintura para arriba, acción general
- Close-up: rostro, expresión emocional
- Extreme close-up: detalle de ojos, patas, objeto
- Over-the-shoulder: perspectiva de un personaje mirando a otro
- Low angle: cámara baja mirando hacia arriba (da poder/grandeza)
- High angle: cámara alta mirando hacia abajo (da vulnerabilidad)
- POV: lo que ve el personaje
- Tracking shot: siguiendo movimiento del personaje
- Pan/tilt: barrido horizontal o vertical del escenario
Distribuye la acción del segmento a lo largo de los ${actualPlanos} planos. NO comprimas todo en pocos planos. Cada micro-momento merece su propio plano: una mirada, un gesto, un detalle del entorno, una reacción.
Formato de cada plano:
{
"id": "plano-XXX",
"numeroPlano": "${actoNumero}.${segmentoIndex + 1}.N",
"segmentoGuion": "${segmento.id} ${segmento.nombre}",
"tiempoSegundos": 5,
"accionDescripcion": "TIPO DE PLANO. Descripción técnica detallada...",
"indicacionesSonido": "música, efectos, vocalizaciones",
"personajesDetalle": [
{ "personajeId": "id-exacto", "nombre": "Nombre", "emocion": "emocion_valida" }
]
}
Responde con JSON: { "planos": [...] } con EXACTAMENTE ${actualPlanos} planos.`;
}
// =====================
// PROMPTS PARA IMÁGENES
// =====================
export function getImagePromptsPrompt(
personajes: Personaje[],
): string {
const characterDescriptions = personajes.map(p =>
`- ID: "${p.id}", Nombre: "${p.nombre}" (${p.especie}, ${p.tipo}): ${p.aparienciaFisica}. Utilería: ${p.utileriaVisual}`
).join('\n');
return `Eres un prompt engineer especializado en generación de imágenes para animación con IA.
PERSONAJES (referencia visual completa):
${characterDescriptions}
CONTEXTO: Las imágenes se generarán con Gemini imagen, que recibirá imágenes de referencia: T-poses del personaje (frontal, lateral, trasera), su emoción, e imagen de referencia del fondo/escenario. El fondo ya se envía como imagen de referencia visual, así que NO necesitas describir el escenario en el prompt. Tu prompt compuesto complementa esas referencias visuales.
Para cada plano de la escaleta, genera un objeto "promptComposition" con estos sub-campos:
1. "personajes": Una CADENA DE TEXTO (string, NO array ni objeto) describiendo todos los personajes en escena. Para cada uno: "Nombre: descripción física breve, emoción visible, posición en el encuadre, pose corporal". Si hay varios, sepáralos con punto y coma. Ejemplo: "Coco: gato naranja con gafas de piloto, expresión curiosa, centro del encuadre, caminando hacia la derecha; Miko: búho gris, sorprendido, fondo izquierda, posado en rama".
2. "accion": Movimiento principal, poses dinámicas, qué ocurre en la escena.
3. "luz": Tipo de iluminación (difusa, directa, contraluz), dirección, temperatura de color (cálida/fría), atmósfera lumínica.
4. "camara": Ángulo de cámara (contrapicado, cenital, frontal, tres cuartos, over-the-shoulder, POV, etc.).
5. "optica": Tipo de lente (gran angular, teleobjetivo, normal, macro), profundidad de campo.
6. "tipoPlano": Tipo de encuadre (plano general, plano medio, primer plano, plano detalle, plano americano, etc.).
7. "custom": Siempre cadena vacía "".
NO generes los campos "estilo" ni "fondo" — se inyectan automáticamente desde las referencias visuales.
IMPORTANTE: Cada sub-campo debe ser específico y unívoco. Si alguien lee la composición, debe poder imaginar exactamente la misma escena.
Responde con JSON: { "planos": [...] } donde cada plano incluye todos los campos originales más "promptComposition".`;
}
// =====================
// CHARACTER ASSET PROMPTS (Nano Banana Pro)
// =====================
const TPOSE_VARIANTS = [
{ variant: 'front', label: 'T-Pose Frontal' },
{ variant: 'side', label: 'T-Pose Lateral' },
{ variant: 'back', label: 'T-Pose Trasera' },
] as const;
const EMOTION_VARIANTS = [
{ variant: 'alegria', label: 'Alegría' },
{ variant: 'tristeza', label: 'Tristeza' },
{ variant: 'sorpresa', label: 'Sorpresa' },
{ variant: 'miedo', label: 'Miedo' },
{ variant: 'enfado', label: 'Enfado' },
{ variant: 'neutral', label: 'Neutral' },
] as const;
export { TPOSE_VARIANTS, EMOTION_VARIANTS };
export function getCharacterAssetPromptsPrompt(personaje: Personaje): string {
return `Eres un artista conceptual especializado en character sheets para animación.
PERSONAJE:
- Nombre: ${personaje.nombre}
- Especie: ${personaje.especie}
- Tipo: ${personaje.tipo}
- Apariencia física: ${personaje.aparienciaFisica}
- Utilería visual: ${personaje.utileriaVisual}
- Personalidad: ${personaje.personalidad}
Genera prompts de imagen optimizados para crear el character sheet de este personaje.
Cada prompt debe producir una imagen con FONDO BLANCO LISO, enfocada exclusivamente en el personaje.
Genera un JSON con este formato:
{
"assets": [
{
"type": "tpose",
"variant": "front" | "side" | "back",
"prompt": "prompt detallado para la imagen"
},
{
"type": "emotion",
"variant": "alegria" | "tristeza" | "sorpresa" | "miedo" | "enfado" | "neutral",
"prompt": "prompt detallado para la expresión"
}
]
}
REGLAS PARA LOS PROMPTS:
- T-Pose frontal: cuerpo completo de frente, brazos extendidos horizontalmente, postura neutra, fondo blanco
- T-Pose lateral: cuerpo completo de perfil (lado derecho), brazos extendidos, fondo blanco
- T-Pose trasera: cuerpo completo de espaldas, brazos extendidos, fondo blanco
- Emociones: primer plano del rostro/cara del personaje mostrando la emoción clara, fondo blanco
- Todos los prompts deben incluir la descripción física exacta del personaje (colores, rasgos, utilería)
- Estilo: character design sheet, clean lines, flat white background, no shadows on background
Genera exactamente 3 T-poses y 6 emociones (9 assets total).
Responde SOLO con el JSON.`;
}

275
src/types/project.ts Normal file
View File

@@ -0,0 +1,275 @@
// Tipos para la gestión de proyectos de series animadas
export interface Project {
id: string;
name: string;
slug: string;
createdAt: string;
updatedAt: string;
status: ProjectStatus;
currentStep: ProjectStep;
}
export type ProjectStatus = 'draft' | 'in_progress' | 'completed';
export type ProjectStep =
| 'idea_motriz'
| 'personajes'
| 'capitulos';
// Idea Motriz
export interface IdeaMotriz {
concepto: string;
ideaMotrizCentral: string;
formato: {
capitulos: number;
duracionMinutos: number;
};
narrativa: string;
expansion: string;
valores: string[];
personajesSecundarios: string;
protagonistas: ProtagonistaResumen[];
antagonista: {
nombre: string;
descripcion: string;
representacionVisual: string;
mision: string;
};
estructuraGuion: EstructuraActo[];
estiloVisual?: string;
}
export interface ProtagonistaResumen {
nombre: string;
rol: string;
utileria: string;
mecanica: string;
}
export interface EstructuraActo {
numero: number;
nombre: string;
duracionMin: number;
duracionMax: number;
descripcion: string;
}
// Personajes
export interface Personaje {
id: string;
nombre: string;
tipo: 'protagonista' | 'aliado' | 'antagonista';
especie: string; // Ej: "gato", "búho", "zorro"
aparienciaFisica: string; // Descripción detallada: color, tamaño, rasgos distintivos
rolPrincipal: string;
personalidad: string;
habilidadInnata: string;
utileriaVisual: string;
mecanicaNarrativa: string;
comunicacion: string;
avanceHabilidad: string;
imagePrompt?: string;
imageUrl?: string;
}
export interface ReglasCooperacion {
descripcion: string;
condiciones: string[];
}
// Capítulos
export interface Capitulo {
id: string;
numero: number;
titulo: string;
virtudGanada: string;
status: CapituloStatus;
currentStep: CapituloStep;
}
export type CapituloStatus = 'draft' | 'guion' | 'escaleta' | 'prompts' | 'assets' | 'completed';
export type CapituloStep =
| 'guion_conductor'
| 'escaleta'
| 'prompts'
| 'imagenes'
| 'videos';
// Guión Conductor
export interface GuionConductor {
titulo: string;
virtudGanada: string;
duracionTotal: string;
nudoDelMundo: string;
aliado: {
nombre: string;
descripcion: string;
};
gemaGanada: string;
actos?: ActoGuion[];
contenidoMarkdown?: string;
}
export interface ActoGuion {
numero: number;
nombre: string;
tiempoInicio: string;
tiempoFin: string;
segmentos: SegmentoGuion[];
}
export interface SegmentoGuion {
id: string;
nombre: string;
tiempo: string;
accionVisualClave: string;
disenoFondo: string;
disenoSonido: string;
}
// Pre-Escaleta (esqueleto de planos computado desde el guión)
export interface PreEscaletaSegmento {
segmentoId: string; // e.g. "I.1"
nombre: string; // e.g. "La Alerta de Coco"
actoNumero: number;
segmentoIndex: number; // index within the acto
tiempo: string; // e.g. "0:00-1:00"
duracionSegundos: number;
totalPlanos: number;
planos: PreEscaletaPlano[];
}
export interface PreEscaletaPlano {
id: string; // e.g. "plano-001"
numeroPlano: string; // e.g. "1.1.1"
nombre: string; // placeholder name
segmentoId: string;
tiempoSegundos: number; // always 5
}
export interface PreEscaleta {
capitulo: string;
duracionTotalSegundos: number;
totalPlanos: number;
segmentos: PreEscaletaSegmento[];
}
// Escaleta (Desglose de planos)
export interface Escaleta {
capitulo: string;
virtudGanada: string;
planos?: PlanoEscaleta[];
duracionTotalSegundos?: number;
contenidoMarkdown?: string;
}
export interface PersonajeEnPlano {
personajeId: string;
nombre: string;
emocion: EmotionVariant;
}
export interface PlanoEscaleta {
id: string;
numeroPlano: string;
segmentoGuion: string;
tiempoSegundos: number;
accionDescripcion: string;
indicacionesSonido: string;
promptImagen?: string;
personajesEnPlano?: string[];
personajesDetalle?: PersonajeEnPlano[];
imagenUrl?: string;
videoUrl?: string;
}
// Fondo de referencia (imagen de escenario para consistencia visual)
export interface FondoReferencia {
id: string;
segmentoId: string;
nombre: string;
descripcion: string;
promptGeneracion: string;
imagenUrl?: string;
}
// Prompt compuesto de sub-campos editables
export interface PromptComposition {
personajes: string;
accion: string;
luz: string;
camara: string;
optica: string;
tipoPlano: string;
estilo: string;
custom: string;
}
// Plano con prompt e imágenes de referencia (output de prompts generation)
export interface ReferenceAsset {
personajeId: string;
nombre: string;
emocion: string;
tposeFrontUrl?: string;
tposeSideUrl?: string;
tposeBackUrl?: string;
emotionUrl?: string;
}
export interface PlanoConPrompt extends PlanoEscaleta {
promptComposition?: PromptComposition;
promptImagen: string;
referenceAssets: ReferenceAsset[];
fondoId?: string;
imagenUrl?: string;
}
// Character Assets (para Nano Banana Pro)
export type CharacterAssetType = 'tpose' | 'emotion' | 'pose';
export type TposeVariant = 'front' | 'side' | 'back';
export type EmotionVariant = 'alegria' | 'tristeza' | 'sorpresa' | 'miedo' | 'enfado' | 'neutral';
export interface CharacterAsset {
id: string;
type: CharacterAssetType;
variant: string; // TposeVariant | EmotionVariant | custom pose name
label: string;
prompt: string;
imageUrl?: string;
isBase?: boolean; // true for the canonical reference image
status: 'pending' | 'prompt_ready' | 'generating' | 'generated';
}
export interface CharacterAssetsData {
assets: CharacterAsset[];
baseLocked: boolean; // true once user approves the base image
}
// Estado del Wizard
export interface WizardState {
projectId: string;
currentStep: number;
steps: WizardStep[];
}
export interface WizardStep {
id: string;
name: string;
status: 'pending' | 'current' | 'completed' | 'error';
data?: any;
}
// Respuestas LLM
export interface LLMGenerationRequest {
type: 'idea_motriz' | 'personajes' | 'guion' | 'escaleta' | 'prompts';
context: Record<string, any>;
userInput?: string;
}
export interface LLMGenerationResponse {
success: boolean;
data?: any;
error?: string;
}