Files
story-studio/src/app/project/[slug]/capitulo/[numero]/page.tsx
Carlos Narro f58ab93646 feat(video): generación de vídeo H3 async (no bloqueante) con estados queued→ready
En vez de esperar el render dentro de la request (imposible con la cola nocturna de H3),
ahora el ciclo es async y la cola/worker viven server-side en h3api:

- submit no bloqueante: POST /v1/jobs → se persiste una variación `queued` con h3_job_id y se
  responde al instante. video-service expone submitVideoJob() + getJobStatus() (sin polling).
- nueva acción `sync-jobs` en el route de vídeos: avanza los jobs pendientes del capítulo
  (queued→processing→ready/error); en `done` descarga el mp4, lo guarda y auto-selecciona la
  primera variación lista. Reintenta en el siguiente tick ante fallos de red (no marca error).
- SQLite: columnas `status`/`h3_job_id`/`error` en video_variations + migración idempotente
  (filas existentes → 'ready'). video_url pasa a DEFAULT '' (queued aún no tiene vídeo).
- UI: la tarjeta de variación muestra spinner 'En cola'/'Generando…' y estado 'error'; el
  player solo con variación 'ready'. La página hace polling cada 45s solo si hay pendientes.
- resolución por plano (16:9 832×480 / 9:16 480×832 vía body.aspect). cost_usd = 0.

NO mergear a master hasta que essia-server dé "live" + H3API_KEY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 00:43:20 +02:00

1692 lines
77 KiB
TypeScript

'use client';
import { useEffect, useState, useRef, useCallback, use } from 'react';
import Link from 'next/link';
import { ArrowLeft, Sparkles, FileText, List, Image, Video, ChevronRight, Edit3, Save, Eye, LayoutGrid, AlertTriangle, Palette, RefreshCw, Film } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Toast } from '@/components/ui/Toast';
import { ProgressModal, type ProgressLogEntry } from '@/components/ui/ProgressModal';
import { WizardStepper } from '@/components/wizard/WizardStepper';
import { PlanoCard } from '@/components/capitulo/PlanoCard';
import { PlanoAssetCard } from '@/components/capitulo/PlanoAssetCard';
import { FondoCard } from '@/components/capitulo/FondoCard';
import { GuionFieldsEditor } from '@/components/capitulo/GuionFieldsEditor';
import { VideoVariationsCard } from '@/components/capitulo/VideoVariationsCard';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { GuionConductor, Escaleta, PlanoEscaleta, PreEscaleta, PlanoConPrompt, FondoReferencia, PromptComposition, PersonajeEnPlano } from '@/types/project';
import type { CharacterAssetsSummary } from '@/lib/db-service';
const CAPITULO_STEPS = [
{ id: 'guion', name: 'Guión Conductor', description: 'Estructura narrativa' },
{ id: 'escaleta', name: 'Escaleta', description: 'Desglose de planos' },
{ id: 'prompts', name: 'Prompts', description: 'Para generación de imágenes' },
{ id: 'assets', name: 'Assets', description: 'Imágenes y videos' },
];
export default function CapituloPage({
params
}: {
params: Promise<{ slug: string; numero: string }>
}) {
const { slug, numero } = use(params);
const [guion, setGuion] = useState<GuionConductor | null>(null);
const [preEscaleta, setPreEscaleta] = useState<PreEscaleta | null>(null);
const [escaleta, setEscaleta] = useState<Escaleta | null>(null);
const [promptsData, setPromptsData] = useState<{ contenidoMarkdown?: string; totalPrompts?: number } | null>(null);
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 [editingGuion, setEditingGuion] = useState(false);
const [editingGuionFields, setEditingGuionFields] = useState(false);
const [editingEscaleta, setEditingEscaleta] = useState(false);
const [guionDraft, setGuionDraft] = useState('');
const [escaletaDraft, setEscaletaDraft] = useState('');
const [escaletaView, setEscaletaView] = useState<'cards' | 'markdown'>('cards');
const [assetsMap, setAssetsMap] = useState<Record<string, CharacterAssetsSummary>>({});
const [personajesList, setPersonajesList] = useState<{ id: string; nombre: string }[]>([]);
const [imagenesData, setImagenesData] = useState<{ planos: PlanoConPrompt[]; total: number; generated: number; withPrompt?: number; estiloVisual?: string } | null>(null);
const [generatingPlano, setGeneratingPlano] = useState<string | null>(null);
const [generatingPromptSingle, setGeneratingPromptSingle] = useState<string | null>(null);
const [fondosData, setFondosData] = useState<{ fondos: FondoReferencia[]; total: number; generated: number } | null>(null);
const [generatingFondo, setGeneratingFondo] = useState<string | null>(null);
const [generatingAllFondos, setGeneratingAllFondos] = useState(false);
const [fondosVersion, setFondosVersion] = useState(0);
const [imgVersion, setImgVersion] = useState(() => Date.now());
const [videosData, setVideosData] = useState<{ planos: PlanoConPrompt[]; total: number; generated: number; withPrompt?: number } | null>(null);
const [generatingVideo, setGeneratingVideo] = useState<string | null>(null);
// Remotion render state
const [renderJobId, setRenderJobId] = useState<string | null>(null);
const [renderProgress, setRenderProgress] = useState<any>(null);
// Progress modal state
const [progress, setProgress] = useState<{
open: boolean;
title: string;
current: number;
total: number;
currentLabel: string;
logs: ProgressLogEntry[];
}>({ open: false, title: '', current: 0, total: 0, currentLabel: '', logs: [] });
const abortRef = useRef<AbortController | null>(null);
const startProgress = useCallback((title: string, total: number = 0, label: string = 'Iniciando...') => {
abortRef.current = new AbortController();
setProgress({ open: true, title, current: 0, total, currentLabel: label, logs: [] });
}, []);
const updateProgress = useCallback((current: number, total: number, label: string) => {
setProgress(prev => ({ ...prev, current, total, currentLabel: label }));
}, []);
const addProgressLog = useCallback((label: string, status: 'success' | 'error', detail?: string) => {
setProgress(prev => ({ ...prev, logs: [...prev.logs, { label, status, detail }] }));
}, []);
const finishProgress = useCallback((current: number, total: number) => {
setProgress(prev => ({ ...prev, current, total, currentLabel: '' }));
}, []);
const closeProgress = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setProgress(prev => ({ ...prev, open: false }));
}, []);
useEffect(() => {
fetchData();
}, [slug, numero]);
const showToast = (message: string, type: 'success' | 'error') => {
setToast({ message, type });
};
const fetchData = async () => {
setLoading(true);
try {
const [guionRes, preEscaletaRes, escaletaRes, promptsRes, assetsRes, imagenesRes, fondosRes, personajesRes, videosRes] = await Promise.all([
fetch(`/api/projects/${slug}/capitulos/${numero}/guion`),
fetch(`/api/projects/${slug}/capitulos/${numero}/pre-escaleta`),
fetch(`/api/projects/${slug}/capitulos/${numero}/escaleta`),
fetch(`/api/projects/${slug}/capitulos/${numero}/prompts`),
fetch(`/api/projects/${slug}/personajes/assets-summary`),
fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`),
fetch(`/api/projects/${slug}/capitulos/${numero}/fondos`),
fetch(`/api/projects/${slug}/personajes`),
fetch(`/api/projects/${slug}/capitulos/${numero}/videos`),
]);
if (guionRes.ok) {
const guionData = await guionRes.json();
if (guionData) setGuion(guionData);
}
if (preEscaletaRes.ok) {
const preEscaletaData = await preEscaletaRes.json();
if (preEscaletaData) setPreEscaleta(preEscaletaData);
}
if (escaletaRes.ok) {
const escaletaData = await escaletaRes.json();
if (escaletaData) setEscaleta(escaletaData);
}
if (promptsRes.ok) {
const promptsDataRes = await promptsRes.json();
if (promptsDataRes) setPromptsData(promptsDataRes);
}
if (assetsRes.ok) {
const assetsData = await assetsRes.json();
if (assetsData) setAssetsMap(assetsData);
}
if (imagenesRes.ok) {
const imagenesDataRes = await imagenesRes.json();
if (imagenesDataRes) setImagenesData(imagenesDataRes);
}
if (fondosRes.ok) {
const fondosDataRes = await fondosRes.json();
if (fondosDataRes) setFondosData(fondosDataRes);
}
if (personajesRes.ok) {
const pList = await personajesRes.json();
if (Array.isArray(pList)) setPersonajesList(pList.map((p: any) => ({ id: p.id, nombre: p.nombre })));
}
if (videosRes.ok) {
const videosDataRes = await videosRes.json();
if (videosDataRes) setVideosData(videosDataRes);
}
setImgVersion(Date.now());
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const handleSaveGuionMarkdown = async () => {
setGenerating(true);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/guion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save', contenidoMarkdown: guionDraft }),
});
if (!res.ok) {
const data = await res.json();
showToast(data.error || 'Error guardando guión', 'error');
return;
}
setGuion({ ...guion!, contenidoMarkdown: guionDraft });
setEditingGuion(false);
showToast('Guión guardado correctamente', 'success');
} catch (error) {
console.error('Error saving guion:', error);
showToast('Error al guardar guión', 'error');
} finally {
setGenerating(false);
}
};
const handleSaveGuionFields = async (updatedGuion: GuionConductor) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/guion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save-fields', guionData: updatedGuion }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error guardando campos', 'error');
return;
}
setGuion(data);
setEditingGuionFields(false);
showToast('Guión actualizado correctamente', 'success');
} catch (error) {
console.error('Error saving guion fields:', error);
showToast('Error al guardar campos del guión', 'error');
}
};
const handleSaveEscaletaMarkdown = async () => {
setGenerating(true);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/escaleta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save', contenidoMarkdown: escaletaDraft }),
});
if (!res.ok) {
const data = await res.json();
showToast(data.error || 'Error guardando escaleta', 'error');
return;
}
setEscaleta({ ...escaleta!, contenidoMarkdown: escaletaDraft });
setEditingEscaleta(false);
showToast('Escaleta guardada correctamente', 'success');
} catch (error) {
console.error('Error saving escaleta:', error);
showToast('Error al guardar escaleta', 'error');
} finally {
setGenerating(false);
}
};
// SSE stream reader helper
const readSSEStream = async (
response: Response,
onProgress: (current: number, total: number, label: string) => void,
signal?: AbortSignal
): Promise<any> => {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let result: any = null;
try {
while (true) {
if (signal?.aborted) { reader.cancel(); throw new Error('Cancelado'); }
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
const match = line.match(/^data:\s*(.*)/);
if (!match) continue;
try {
const event = JSON.parse(match[1]);
if (event.type === 'progress') onProgress(event.current, event.total, event.label);
else if (event.type === 'done') result = event.result;
else if (event.type === 'error') throw new Error(event.message);
} catch (e: any) { if (e.message !== 'Cancelado') throw e; }
}
}
} finally { reader.releaseLock(); }
return result;
};
const handleGenerateGuion = async () => {
startProgress('Generando Guión Conductor', 0, 'Creando estructura narrativa...');
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/guion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate' }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error generando guión', 'error'); closeProgress(); return; }
setGuion(data);
addProgressLog('Guión conductor generado', 'success');
finishProgress(1, 1);
showToast('Guión conductor generado correctamente', 'success');
} catch (error: any) {
if (error.name !== 'AbortError') { showToast('Error de conexión al generar guión', 'error'); }
closeProgress();
}
};
const handleRegenerateActos = async () => {
startProgress('Regenerando Actos', 0, 'Generando nuevos actos con IA...');
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/guion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'regenerate-actos' }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error regenerando actos', 'error'); closeProgress(); return; }
setGuion(data);
addProgressLog('Actos regenerados (metadata conservada)', 'success');
finishProgress(1, 1);
showToast('Actos regenerados correctamente', 'success');
} catch (error: any) {
if (error.name !== 'AbortError') { showToast('Error de conexión al regenerar actos', 'error'); }
closeProgress();
}
};
const handleGeneratePrompts = async () => {
if (!escaleta) return;
startProgress('Generando Prompts', 0, 'Iniciando generación de prompts...');
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/prompts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate-stream' }),
signal: abortRef.current?.signal,
});
if (!res.ok) {
const errData = await res.json();
showToast(errData.error || 'Error generando prompts', 'error');
closeProgress();
return;
}
const data = await readSSEStream(res, (current, total, label) => {
updateProgress(current, total, label);
if (current > 0) addProgressLog(`Lote ${current}`, 'success', label);
}, abortRef.current?.signal);
if (data) {
setPromptsData(data);
try {
const imgRes = await fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`);
if (imgRes.ok) { const imgData = await imgRes.json(); if (imgData) setImagenesData(imgData); }
} catch { /* ok */ }
addProgressLog(`${data.totalPrompts || 0} prompts generados`, 'success');
finishProgress(data.totalPrompts || 1, data.totalPrompts || 1);
showToast(`${data.totalPrompts || 0} prompts generados`, 'success');
} else { closeProgress(); }
} catch (error: any) {
if (error.name !== 'AbortError' && error.message !== 'Cancelado') { showToast('Error al generar prompts', 'error'); }
closeProgress();
}
};
const handleGeneratePreEscaleta = async () => {
if (!guion) return;
startProgress('Generando Pre-Escaleta', 0, 'Calculando estructura de planos...');
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/pre-escaleta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', guion }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error generando pre-escaleta', 'error'); closeProgress(); return; }
setPreEscaleta(data);
addProgressLog(`Pre-escaleta: ${data.totalPlanos} planos en ${data.segmentos?.length} segmentos`, 'success');
finishProgress(1, 1);
showToast(`Pre-escaleta generada: ${data.totalPlanos} planos`, 'success');
} catch (error: any) {
if (error.name !== 'AbortError') { showToast('Error al generar pre-escaleta', 'error'); }
closeProgress();
}
};
const handleGenerateEscaleta = async () => {
if (!guion || !preEscaleta) return;
startProgress('Generando Escaleta', preEscaleta.segmentos.length, 'Iniciando generación por segmentos...');
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/escaleta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate-stream', guion }),
signal: abortRef.current?.signal,
});
if (!res.ok) {
const errData = await res.json();
showToast(errData.error || 'Error generando escaleta', 'error');
closeProgress();
return;
}
const data = await readSSEStream(res, (current, total, label) => {
updateProgress(current, total, label);
if (current > 0) addProgressLog(`Segmento ${current}/${total}`, 'success', label);
}, abortRef.current?.signal);
if (data) {
setEscaleta(data);
addProgressLog(`Escaleta completa: ${data.planos?.length || 0} planos`, 'success');
finishProgress(preEscaleta.segmentos.length, preEscaleta.segmentos.length);
showToast('Escaleta generada correctamente', 'success');
} else { closeProgress(); }
} catch (error: any) {
if (error.name !== 'AbortError' && error.message !== 'Cancelado') { showToast('Error al generar escaleta', 'error'); }
closeProgress();
}
};
const handleGenerateSinglePrompt = async (planoId: string) => {
setGeneratingPromptSingle(planoId);
startProgress('Generando Prompt', 0, `Prompt para plano ${planoId}...`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/prompts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate-single', planoId }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error generando prompt', 'error'); closeProgress(); return; }
if (imagenesData) {
const updatedPlanos = imagenesData.planos.map(p => p.id === planoId ? { ...p, ...data.plano } : p);
const withPrompt = updatedPlanos.filter((p: PlanoConPrompt) => p.promptImagen).length;
setImagenesData({ ...imagenesData, planos: updatedPlanos, withPrompt });
}
addProgressLog(`Prompt generado para ${planoId}`, 'success');
finishProgress(1, 1);
showToast(`Prompt generado para plano ${planoId}`, 'success');
} catch (error: any) {
if (error.name !== 'AbortError') { showToast('Error al generar prompt', 'error'); }
closeProgress();
} finally { setGeneratingPromptSingle(null); }
};
const handleGeneratePlanoImage = async (planoId: string) => {
setGeneratingPlano(planoId);
startProgress('Generando Imagen', 0, `Generando imagen para plano ${planoId}...`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', planoId }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error generando imagen', 'error'); closeProgress(); return; }
if (imagenesData) {
const updatedPlanos = imagenesData.planos.map(p => p.id === planoId ? data.plano : p);
const generated = updatedPlanos.filter((p: PlanoConPrompt) => p.imagenUrl).length;
setImagenesData({ ...imagenesData, planos: updatedPlanos, generated });
}
setImgVersion(Date.now());
addProgressLog(`Imagen generada para ${planoId}`, 'success');
finishProgress(1, 1);
showToast(`Imagen generada para plano ${planoId}`, 'success');
} catch (error: any) {
if (error.name !== 'AbortError') { showToast('Error al generar imagen', 'error'); }
closeProgress();
} finally { setGeneratingPlano(null); }
};
const handleGenerateAllImages = async (onlyPending = false) => {
if (!imagenesData) return;
const pending = onlyPending
? imagenesData.planos.filter(p => p.promptImagen && !p.imagenUrl)
: imagenesData.planos.filter(p => p.promptImagen);
if (pending.length === 0) return;
startProgress('Generando Imágenes', pending.length, `Imagen 1/${pending.length}...`);
let successCount = 0;
let errorCount = 0;
let localPlanos = [...imagenesData.planos];
for (let i = 0; i < pending.length; i++) {
if (abortRef.current?.signal.aborted) break;
const plano = pending[i];
updateProgress(i, pending.length, `Imagen ${i + 1}/${pending.length}${plano.numeroPlano || plano.id}`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', planoId: plano.id }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (res.ok) {
localPlanos = localPlanos.map(p => p.id === plano.id ? data.plano : p);
const generated = localPlanos.filter(p => p.imagenUrl).length;
setImagenesData({ ...imagenesData, planos: localPlanos, generated });
addProgressLog(`${plano.numeroPlano || plano.id}`, 'success');
successCount++;
} else {
addProgressLog(`${plano.numeroPlano || plano.id}`, 'error', data.error);
errorCount++;
}
} catch (error: any) {
if (error.name === 'AbortError') break;
addProgressLog(`${plano.numeroPlano || plano.id}`, 'error', 'Error de conexión');
errorCount++;
}
}
finishProgress(pending.length, pending.length);
if (errorCount > 0) {
showToast(`${successCount} imágenes generadas, ${errorCount} con error`, 'error');
} else {
showToast(`${successCount} imágenes generadas correctamente`, 'success');
}
};
const handleGenerateFondo = async (fondoId: string) => {
setGeneratingFondo(fondoId);
startProgress('Generando Fondo', 0, `Generando fondo ${fondoId}...`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/fondos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', fondoId }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error generando fondo', 'error'); closeProgress(); return; }
if (fondosData) {
const updatedFondos = fondosData.fondos.map(f => f.id === fondoId ? data.fondo : f);
const generated = updatedFondos.filter(f => f.imagenUrl).length;
setFondosData({ ...fondosData, fondos: updatedFondos, generated });
}
setFondosVersion(v => v + 1);
addProgressLog(`Fondo ${fondoId} generado`, 'success');
finishProgress(1, 1);
showToast('Fondo generado correctamente', 'success');
} catch (error: any) {
if (error.name !== 'AbortError') { showToast('Error al generar fondo', 'error'); }
closeProgress();
} finally { setGeneratingFondo(null); }
};
const handleGenerateAllFondos = async () => {
if (!fondosData || fondosData.fondos.length === 0) return;
const pending = fondosData.fondos;
setGeneratingAllFondos(true);
startProgress('Generando Fondos', pending.length, `Fondo 1/${pending.length}...`);
let successCount = 0;
let errorCount = 0;
let localFondos = [...fondosData.fondos];
for (let i = 0; i < pending.length; i++) {
if (abortRef.current?.signal.aborted) break;
const fondo = pending[i];
updateProgress(i, pending.length, `Fondo ${i + 1}/${pending.length}${fondo.nombre}`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/fondos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', fondoId: fondo.id }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (res.ok) {
localFondos = localFondos.map(f => f.id === fondo.id ? data.fondo : f);
const generated = localFondos.filter(f => f.imagenUrl).length;
setFondosData({ ...fondosData, fondos: localFondos, generated });
setFondosVersion(v => v + 1);
addProgressLog(`${fondo.nombre}`, 'success');
successCount++;
} else {
addProgressLog(`${fondo.nombre}`, 'error', data.error);
errorCount++;
}
} catch (error: any) {
if (error.name === 'AbortError') break;
addProgressLog(`${fondo.nombre}`, 'error', 'Error de conexión');
errorCount++;
}
}
finishProgress(pending.length, pending.length);
setGeneratingAllFondos(false);
if (errorCount > 0) {
showToast(`${successCount} fondos generados, ${errorCount} con error`, 'error');
} else {
showToast(`${successCount} fondos generados correctamente`, 'success');
}
};
const handleUpdateFondoDescription = async (fondoId: string, descripcion: string, promptGeneracion: string) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/fondos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update-description', fondoId, descripcion, promptGeneracion }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error actualizando fondo', 'error');
return;
}
if (fondosData) {
const updatedFondos = fondosData.fondos.map(f =>
f.id === fondoId ? data.fondo : f
);
setFondosData({ ...fondosData, fondos: updatedFondos });
}
showToast('Fondo actualizado', 'success');
} catch (error) {
console.error('Error updating fondo:', error);
showToast('Error al actualizar fondo', 'error');
}
};
const handleRefreshFondos = async () => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/fondos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'refresh' }),
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error actualizando fondos', 'error'); return; }
setFondosData(data);
showToast('Fondos sincronizados con el guión', 'success');
} catch {
showToast('Error al sincronizar fondos', 'error');
}
};
const handleSaveComposition = async (planoId: string, composition: PromptComposition) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save-composition', planoId, composition }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error guardando composición', 'error');
return;
}
// Update the specific plano in local state
if (imagenesData) {
const updatedPlanos = imagenesData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setImagenesData({ ...imagenesData, planos: updatedPlanos });
}
showToast('Composición guardada', 'success');
} catch (error) {
console.error('Error saving composition:', error);
showToast('Error al guardar composición', 'error');
}
};
const handleSavePersonajes = async (planoId: string, personajesDetalle: PersonajeEnPlano[]) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/imagenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save-personajes', planoId, personajesDetalle }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error guardando personajes', 'error');
return;
}
if (imagenesData) {
const updatedPlanos = imagenesData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setImagenesData({ ...imagenesData, planos: updatedPlanos });
}
showToast('Personajes actualizados', 'success');
} catch (error) {
console.error('Error saving personajes:', error);
showToast('Error al guardar personajes', 'error');
}
};
const handleGeneratePlanoVideo = async (planoId: string, customPrompt?: string) => {
// El render es async (cola nocturna de H3): encolamos y devolvemos al instante; el estado
// real (queued → processing → ready) lo refresca el polling de syncVideoJobs.
setGeneratingVideo(planoId);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', planoId, promptVideo: customPrompt }),
});
const data = await res.json();
if (!res.ok) { showToast(data.error || 'Error encolando el vídeo', 'error'); return; }
if (videosData) {
const updatedPlanos = videosData.planos.map(p => p.id === planoId ? data.plano : p);
setVideosData({ ...videosData, planos: updatedPlanos });
}
showToast('Vídeo en cola — se generará en la ventana nocturna', 'success');
} catch {
showToast('Error al encolar el vídeo', 'error');
} finally { setGeneratingVideo(null); }
};
const handleGenerateAllVideos = async (onlyPending = false) => {
if (!videosData) return;
const targets = onlyPending
? videosData.planos.filter(p => p.imagenUrl && !(p.videoVariations || []).some(v => v.status === 'ready' || v.status === 'queued' || v.status === 'processing'))
: videosData.planos.filter(p => p.imagenUrl);
if (targets.length === 0) return;
startProgress('Encolando vídeos', targets.length, `Encolando 1/${targets.length}...`);
let successCount = 0;
let errorCount = 0;
let localPlanos = [...videosData.planos];
for (let i = 0; i < targets.length; i++) {
if (abortRef.current?.signal.aborted) break;
const plano = targets[i];
updateProgress(i, targets.length, `Encolando ${i + 1}/${targets.length}${plano.numeroPlano || plano.id}`);
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate', planoId: plano.id }),
signal: abortRef.current?.signal,
});
const data = await res.json();
if (res.ok) {
localPlanos = localPlanos.map(p => p.id === plano.id ? data.plano : p);
setVideosData({ ...videosData, planos: localPlanos });
addProgressLog(`${plano.numeroPlano || plano.id}`, 'success');
successCount++;
} else {
addProgressLog(`${plano.numeroPlano || plano.id}`, 'error', data.error);
errorCount++;
}
} catch (error: any) {
if (error.name === 'AbortError') break;
addProgressLog(`${plano.numeroPlano || plano.id}`, 'error', 'Error de conexión');
errorCount++;
}
}
finishProgress(targets.length, targets.length);
if (errorCount > 0) {
showToast(`${successCount} vídeos encolados, ${errorCount} con error`, 'error');
} else {
showToast(`${successCount} vídeos en cola — se generarán en la ventana nocturna`, 'success');
}
};
// Refresca el estado de los jobs de vídeo pendientes (queued/processing) del capítulo.
const syncVideoJobs = useCallback(async () => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'sync-jobs' }),
});
if (!res.ok) return;
const data = await res.json();
if (data.planos) {
setVideosData(prev => prev ? { ...prev, planos: data.planos, generated: data.generated } : prev);
}
} catch {
// fallo transitorio de red: reintenta en el próximo tick
}
}, [slug, numero]);
// Polling mientras haya variaciones de vídeo pendientes. Sin pings agresivos: cada 45s.
const hasPendingVideos = !!videosData?.planos.some(
p => (p.videoVariations || []).some(v => v.status === 'queued' || v.status === 'processing')
);
useEffect(() => {
if (!hasPendingVideos) return;
syncVideoJobs(); // refresco inmediato al detectar pendientes
const id = setInterval(syncVideoJobs, 45000);
return () => clearInterval(id);
}, [hasPendingVideos, syncVideoJobs]);
const handleSaveVideoPrompt = async (planoId: string, promptVideo: string) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save-prompt', planoId, promptVideo }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error guardando prompt de video', 'error');
return;
}
if (videosData) {
const updatedPlanos = videosData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setVideosData({ ...videosData, planos: updatedPlanos });
}
showToast('Prompt de video guardado', 'success');
} catch (error) {
console.error('Error saving video prompt:', error);
showToast('Error al guardar prompt de video', 'error');
}
};
const handleSelectVideoVariation = async (planoId: string, variationId: string) => {
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'select-variation', planoId, variationId }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error seleccionando variación', 'error');
return;
}
if (videosData) {
const updatedPlanos = videosData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setVideosData({ ...videosData, planos: updatedPlanos });
}
showToast('Variación seleccionada', 'success');
} catch (error) {
console.error('Error selecting variation:', error);
showToast('Error al seleccionar variación', 'error');
}
};
const handleDeleteVideoVariation = async (planoId: string, variationId: string) => {
if (!confirm('¿Eliminar esta variación de video?')) return;
try {
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete-variation', planoId, variationId }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error eliminando variación', 'error');
return;
}
if (videosData) {
const updatedPlanos = videosData.planos.map(p =>
p.id === planoId ? data.plano : p
);
setVideosData({ ...videosData, planos: updatedPlanos });
}
showToast('Variación eliminada', 'success');
} catch (error) {
console.error('Error deleting variation:', error);
showToast('Error al eliminar variación', 'error');
}
};
const handleStartRender = async () => {
try {
const res = await fetch('/api/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectSlug: slug,
capituloNumero: parseInt(numero),
includeIntro: true,
transitionType: 'crossfade',
quality: 90,
}),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Error iniciando render', 'error');
return;
}
const { jobId } = data;
setRenderJobId(jobId);
// Poll for progress
const pollInterval = setInterval(async () => {
const progressRes = await fetch(`/api/render?jobId=${jobId}`);
const progress = await progressRes.json();
setRenderProgress(progress);
if (progress.phase === 'complete') {
clearInterval(pollInterval);
setRenderJobId(null);
showToast(`Video renderizado: ${progress.outputPath}`, 'success');
} else if (progress.phase === 'error') {
clearInterval(pollInterval);
setRenderJobId(null);
showToast(`Error: ${progress.message}`, 'error');
}
}, 2000);
} catch (error) {
console.error('Error starting render:', error);
showToast('Error al iniciar renderizado', 'error');
}
};
// Build fondoId → imagenUrl lookup
const fondoImagenMap: Record<string, string> = {};
if (fondosData) {
for (const f of fondosData.fondos) {
if (f.imagenUrl) fondoImagenMap[f.id] = `${f.imagenUrl}?v=${fondosVersion}`;
}
}
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>
);
}
return (
<div className="min-h-screen bg-[var(--background)]">
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
<ProgressModal
open={progress.open}
title={progress.title}
current={progress.current}
total={progress.total}
currentLabel={progress.currentLabel}
logs={progress.logs}
onCancel={closeProgress}
/>
<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={`/project/${slug}`} 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">
Cap. {numero} {guion?.titulo ? `${guion.titulo}` : ''}
</h1>
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-8">
<WizardStepper
steps={CAPITULO_STEPS}
currentStep={currentStep}
onStepClick={(index) => setCurrentStep(index)}
/>
<div className="mt-8">
{/* Step 0: Guión Conductor */}
{currentStep === 0 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-stone-900">Guión Conductor</h2>
<p className="mt-1 text-stone-500 text-sm">Estructura narrativa del capítulo.</p>
</div>
{!guion ? (
<div className="text-center py-16">
<p className="text-stone-400 text-sm mb-5">La IA creará la estructura narrativa basada en tu Idea Motriz.</p>
<Button onClick={handleGenerateGuion} loading={generating}>
<Sparkles className="w-4 h-4" />
Generar guión
</Button>
</div>
) : (
<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">
<div className="flex items-baseline gap-3 min-w-0">
<span className="text-sm font-medium text-stone-800 truncate">{guion.titulo}</span>
{guion.virtudGanada && <span className="text-xs text-stone-400 flex-shrink-0">{guion.virtudGanada}</span>}
</div>
<div className="flex gap-1.5 flex-shrink-0">
{!editingGuion && !editingGuionFields && (
<>
<Button variant="ghost" size="sm" onClick={() => { setEditingGuionFields(true); setEditingGuion(false); }}>
<Edit3 className="w-3.5 h-3.5" />
Editar campos
</Button>
<Button variant="ghost" size="sm" onClick={() => { setGuionDraft(guion.contenidoMarkdown || ''); setEditingGuion(true); setEditingGuionFields(false); }}>
<FileText className="w-3.5 h-3.5" />
Editar markdown
</Button>
</>
)}
{editingGuion && (
<>
<Button variant="ghost" size="sm" onClick={() => setEditingGuion(false)}>
<Eye className="w-3.5 h-3.5" />
Vista previa
</Button>
<Button size="sm" onClick={handleSaveGuionMarkdown} loading={generating}>
<Save className="w-3.5 h-3.5" />
Guardar
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={handleRegenerateActos} loading={generating}>
<Sparkles className="w-3.5 h-3.5" />
Regenerar actos
</Button>
</div>
</div>
<div className="px-5 py-4">
{editingGuionFields ? (
<GuionFieldsEditor
guion={guion}
onSave={handleSaveGuionFields}
onCancel={() => setEditingGuionFields(false)}
/>
) : (
<>
<div className="flex gap-6 mb-5 text-sm">
{guion.duracionTotal && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Duración</span>
<p className="text-stone-800 font-medium mt-0.5">{guion.duracionTotal}</p>
</div>
)}
{guion.aliado?.nombre && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Aliado</span>
<p className="text-stone-800 font-medium mt-0.5">{guion.aliado.nombre}</p>
</div>
)}
{guion.gemaGanada && (
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Gema</span>
<p className="text-stone-800 font-medium mt-0.5">{guion.gemaGanada}</p>
</div>
)}
</div>
{guion.nudoDelMundo && (
<div className="mb-5 border-l-2 border-stone-200 pl-4">
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Nudo del Mundo</span>
<p className="text-sm text-stone-600 mt-0.5">{guion.nudoDelMundo}</p>
</div>
)}
{editingGuion ? (
<textarea
value={guionDraft}
onChange={(e) => setGuionDraft(e.target.value)}
className="w-full h-[500px] px-4 py-3 border border-stone-200 rounded-lg font-mono text-sm bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 resize-y transition-colors"
/>
) : guion.contenidoMarkdown ? (
<div className="prose prose-sm max-w-none prose-headings:text-stone-900 prose-h2:text-lg prose-h2:border-b prose-h2:border-stone-100 prose-h2:pb-2 prose-h3:text-base prose-p:text-stone-600 prose-table:text-sm prose-td:p-2 prose-th:p-2 prose-th:bg-stone-50 prose-th:text-stone-600">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{guion.contenidoMarkdown}</ReactMarkdown>
</div>
) : null}
</>
)}
</div>
</div>
)}
</div>
)}
{/* Step 1: Pre-Escaleta + Escaleta */}
{currentStep === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-stone-900">Escaleta</h2>
<p className="mt-1 text-stone-500 text-sm">Desglose plano por plano con tiempos exactos.</p>
</div>
{/* Pre-Escaleta section */}
<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">
<div className="flex items-baseline gap-3">
<span className="text-sm font-medium text-stone-800">Pre-Escaleta</span>
<span className="text-xs text-stone-400">Estructura de planos por escena</span>
</div>
<Button
variant={preEscaleta ? 'outline' : 'primary'}
size="sm"
onClick={handleGeneratePreEscaleta}
loading={generating}
disabled={!guion || !guion.actos}
>
<Sparkles className="w-3.5 h-3.5" />
{preEscaleta ? 'Regenerar' : 'Generar'}
</Button>
</div>
{!guion?.actos && (
<div className="px-5 py-6 text-center">
<p className="text-sm text-amber-600">El guión conductor no tiene actos estructurados. Regenera el guión primero.</p>
</div>
)}
{preEscaleta && (
<div className="px-5 py-4">
<div className="flex items-center gap-6 mb-4 text-sm">
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Total planos</span>
<p className="text-lg font-semibold text-stone-800">{preEscaleta.totalPlanos}</p>
</div>
<div className="w-px h-8 bg-stone-200" />
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Duración</span>
<p className="text-lg font-semibold text-stone-800">
{Math.floor(preEscaleta.duracionTotalSegundos / 60)}:{(preEscaleta.duracionTotalSegundos % 60).toString().padStart(2, '0')}
<span className="text-sm font-normal text-stone-400 ml-1">({preEscaleta.duracionTotalSegundos}s)</span>
</p>
</div>
<div className="w-px h-8 bg-stone-200" />
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Segmentos</span>
<p className="text-lg font-semibold text-stone-800">{preEscaleta.segmentos.length}</p>
</div>
</div>
<div className="border border-stone-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-stone-50 text-stone-500 text-xs">
<th className="px-3 py-2 text-left font-medium">Segmento</th>
<th className="px-3 py-2 text-left font-medium">Tiempo</th>
<th className="px-3 py-2 text-right font-medium">Duración</th>
<th className="px-3 py-2 text-right font-medium">Planos</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{preEscaleta.segmentos.map((seg) => (
<tr key={seg.segmentoId} className="hover:bg-stone-50/50">
<td className="px-3 py-2 font-medium text-stone-700">
<span className="text-stone-400 mr-1">{seg.segmentoId}</span>
{seg.nombre}
</td>
<td className="px-3 py-2 text-stone-500">{seg.tiempo}</td>
<td className="px-3 py-2 text-right text-stone-600">{seg.duracionSegundos}s</td>
<td className="px-3 py-2 text-right">
<span className="inline-flex items-center px-2 py-0.5 bg-indigo-50 text-indigo-700 rounded-full text-xs font-medium">
{seg.totalPlanos}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Escaleta section */}
{!preEscaleta ? (
<div className="text-center py-8">
<p className="text-stone-400 text-sm">Genera la pre-escaleta primero para definir la estructura de planos.</p>
</div>
) : !escaleta ? (
<div className="text-center py-8">
<p className="text-stone-400 text-sm mb-5">La pre-escaleta define {preEscaleta.totalPlanos} planos. Ahora la IA rellenará cada plano con descripción detallada.</p>
<Button
onClick={handleGenerateEscaleta}
loading={generating}
>
<Sparkles className="w-4 h-4" />
Generar escaleta ({preEscaleta.totalPlanos} planos)
</Button>
</div>
) : (
<>
{/* Warning for characters without assets */}
{escaleta.planos && (() => {
const allIds = new Set<string>();
for (const p of escaleta.planos) {
for (const pd of p.personajesDetalle || []) {
if (!assetsMap[pd.personajeId]?.hasAssets) allIds.add(pd.nombre);
}
}
if (allIds.size === 0) return null;
return (
<div className="flex items-center gap-2.5 px-4 py-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
<span className="text-sm text-amber-800">
Personajes sin assets generados: <strong>{Array.from(allIds).join(', ')}</strong>
</span>
<Link
href={`/project/${slug}`}
className="ml-auto text-sm font-medium text-amber-600 hover:text-amber-800 transition-colors"
>
Ir a Personajes
</Link>
</div>
);
})()}
{/* Summary bar */}
{escaleta.planos && escaleta.planos.length > 0 && (() => {
const totalSeg = escaleta.planos.reduce((sum, p) => sum + (p.tiempoSegundos || 0), 0);
const mins = Math.floor(totalSeg / 60);
const secs = totalSeg % 60;
return (
<div className="flex items-center gap-6 px-4 py-3 bg-white border border-stone-200 rounded-lg">
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Planos</span>
<p className="text-lg font-semibold text-stone-800">{escaleta.planos.length}</p>
</div>
<div className="w-px h-8 bg-stone-200" />
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Duración total</span>
<p className="text-lg font-semibold text-stone-800">{mins}:{secs.toString().padStart(2, '0')} <span className="text-sm font-normal text-stone-400">({totalSeg}s)</span></p>
</div>
{escaleta.duracionTotalSegundos && totalSeg !== escaleta.duracionTotalSegundos && (
<>
<div className="w-px h-8 bg-stone-200" />
<div>
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Objetivo</span>
<p className="text-lg font-semibold text-stone-800">{Math.floor(escaleta.duracionTotalSegundos / 60)}:{(escaleta.duracionTotalSegundos % 60).toString().padStart(2, '0')}</p>
</div>
</>
)}
</div>
);
})()}
<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">
<div className="flex items-center gap-3 min-w-0">
<span className="text-sm font-medium text-stone-800 truncate">{escaleta.capitulo || 'Escaleta'}</span>
{escaleta.virtudGanada && <span className="text-xs text-stone-400 flex-shrink-0">{escaleta.virtudGanada}</span>}
</div>
<div className="flex gap-1.5 flex-shrink-0">
{/* View toggle */}
<div className="flex border border-stone-200 rounded-md overflow-hidden mr-1">
<button
onClick={() => { setEscaletaView('cards'); setEditingEscaleta(false); }}
className={`px-2 py-1 text-xs font-medium transition-colors ${escaletaView === 'cards' ? 'bg-indigo-50 text-indigo-600' : 'text-stone-400 hover:text-stone-600'}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setEscaletaView('markdown'); setEditingEscaleta(false); }}
className={`px-2 py-1 text-xs font-medium transition-colors ${escaletaView === 'markdown' ? 'bg-indigo-50 text-indigo-600' : 'text-stone-400 hover:text-stone-600'}`}
>
<FileText className="w-3.5 h-3.5" />
</button>
</div>
{escaletaView === 'markdown' && !editingEscaleta && (
<Button variant="ghost" size="sm" onClick={() => { setEscaletaDraft(escaleta.contenidoMarkdown || ''); setEditingEscaleta(true); }}>
<Edit3 className="w-3.5 h-3.5" />
Editar
</Button>
)}
{editingEscaleta && (
<>
<Button variant="ghost" size="sm" onClick={() => setEditingEscaleta(false)}>
<Eye className="w-3.5 h-3.5" />
Vista previa
</Button>
<Button size="sm" onClick={handleSaveEscaletaMarkdown} loading={generating}>
<Save className="w-3.5 h-3.5" />
Guardar
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={handleGenerateEscaleta} loading={generating}>
<Sparkles className="w-3.5 h-3.5" />
Regenerar
</Button>
</div>
</div>
{/* Cards view */}
{escaletaView === 'cards' && escaleta.planos && (
<div className="p-4 space-y-3">
{escaleta.planos.map((plano, i) => (
<PlanoCard
key={plano.id || i}
plano={plano}
index={i}
assetsMap={assetsMap}
slug={slug}
/>
))}
</div>
)}
{/* Markdown view */}
{escaletaView === 'markdown' && (
<div className="px-5 py-4">
{editingEscaleta ? (
<textarea
value={escaletaDraft}
onChange={(e) => setEscaletaDraft(e.target.value)}
className="w-full h-[500px] px-4 py-3 border border-stone-200 rounded-lg font-mono text-sm bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 resize-y transition-colors"
/>
) : escaleta.contenidoMarkdown ? (
<div className="prose prose-sm max-w-none prose-headings:text-stone-900 prose-h2:text-lg prose-h2:border-b prose-h2:border-stone-100 prose-h2:pb-2 prose-h3:text-base prose-p:text-stone-600 prose-table:text-sm prose-td:p-2 prose-th:p-2 prose-th:bg-stone-50 prose-th:text-stone-600">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{escaleta.contenidoMarkdown}</ReactMarkdown>
</div>
) : null}
</div>
)}
</div>
</>
)}
</div>
)}
{/* Step 2: Prompts + Generar Imagen */}
{currentStep === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-stone-900">Prompts de Imágenes</h2>
<p className="mt-1 text-stone-500 text-sm">Prompts con referencias visuales. Genera la imagen de cada plano aquí.</p>
</div>
{!imagenesData ? (
<div className="text-center py-16">
<p className="text-stone-400 text-sm mb-5">
{!escaleta ? 'Primero debes generar la escaleta.' : promptsData ? 'Los prompts necesitan regenerarse para incluir datos estructurados.' : 'La IA creará prompts optimizados para cada plano.'}
</p>
<Button onClick={handleGeneratePrompts} loading={generating} disabled={!escaleta}>
<Sparkles className="w-4 h-4" />
{promptsData ? 'Regenerar prompts' : 'Generar prompts'}
</Button>
</div>
) : (
<>
{/* Header with stats + actions */}
<div className="bg-white rounded-lg border border-stone-200 px-5 py-3 flex items-center justify-between">
<div className="flex items-baseline gap-3">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wide">Progreso</span>
<span className="text-sm font-medium text-stone-700">
{imagenesData.withPrompt ?? 0}/{imagenesData.total} prompts · {imagenesData.generated}/{imagenesData.total} imágenes
{imagenesData.total > 0 && (
<span className="text-stone-400 font-normal ml-1">
({(imagenesData.total * 0.134).toFixed(2).replace('.', ',')})
</span>
)}
</span>
{imagenesData.total > 0 && (
<div className="w-24 h-1.5 bg-stone-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
style={{ width: `${(imagenesData.generated / imagenesData.total) * 100}%` }}
/>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleGeneratePrompts} loading={generating} disabled={!!generatingPlano}>
<Sparkles className="w-3.5 h-3.5" />
Regenerar prompts
</Button>
{(() => {
const pendingCount = imagenesData.planos.filter(p => p.promptImagen && !p.imagenUrl).length;
return (
<>
{pendingCount > 0 && (
<Button
size="sm"
onClick={() => handleGenerateAllImages(true)}
loading={generating}
disabled={!!generatingPlano}
>
<Image className="w-3.5 h-3.5" />
Generar pendientes ({pendingCount})
</Button>
)}
<Button
size="sm"
variant={pendingCount > 0 ? 'outline' : 'primary'}
onClick={() => handleGenerateAllImages(false)}
loading={generating}
disabled={!!generatingPlano || (imagenesData.withPrompt ?? 0) === 0}
>
<Image className="w-3.5 h-3.5" />
Regenerar todas
</Button>
</>
);
})()}
</div>
</div>
{/* Fondos de Referencia */}
{fondosData && fondosData.fondos.length > 0 && (
<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">
<div className="flex items-center gap-2.5">
<Palette className="w-4 h-4 text-indigo-500" />
<span className="text-sm font-medium text-stone-800">Fondos de Referencia</span>
<span className="text-xs text-stone-400">{fondosData.generated}/{fondosData.total} generados</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleRefreshFondos}
disabled={!!generatingFondo || generatingAllFondos}
>
<RefreshCw className="w-3.5 h-3.5" />
Sincronizar
</Button>
<Button
size="sm"
variant="outline"
onClick={handleGenerateAllFondos}
loading={generatingAllFondos}
disabled={!!generatingFondo}
>
<Sparkles className="w-3.5 h-3.5" />
{fondosData.generated === fondosData.total && fondosData.generated > 0 ? 'Regenerar todos' : 'Generar todos'}
</Button>
</div>
</div>
<div className="p-4 grid grid-cols-2 md:grid-cols-3 gap-3">
{fondosData.fondos.map((fondo) => (
<FondoCard
key={fondo.id}
fondo={fondo}
onGenerate={handleGenerateFondo}
onUpdateDescription={handleUpdateFondoDescription}
generating={generatingFondo === fondo.id}
disabled={!!generatingFondo || generatingAllFondos}
cacheKey={fondosVersion}
/>
))}
</div>
</div>
)}
{/* Plano cards with prompts + generate */}
<div className="space-y-4">
{imagenesData.planos.map((plano, i) => (
<div key={plano.id} id={`plano-${plano.id}`}>
<PlanoAssetCard
plano={plano}
index={i}
slug={slug}
capNumero={numero}
estiloVisual={imagenesData.estiloVisual || ''}
fondoImagenUrl={plano.fondoId ? fondoImagenMap[plano.fondoId] : undefined}
imgVersion={imgVersion}
personajes={personajesList}
onGeneratePrompt={handleGenerateSinglePrompt}
onGenerateImage={handleGeneratePlanoImage}
onSaveComposition={handleSaveComposition}
onSavePersonajes={handleSavePersonajes}
generatingPrompt={generatingPromptSingle}
generatingImage={generatingPlano}
/>
</div>
))}
</div>
{/* Fixed vertical plano navigator */}
<PlanoNavigator planos={imagenesData.planos} />
</>
)}
</div>
)}
{/* Step 3: Assets (imágenes y videos) */}
{currentStep === 3 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-stone-900">Assets</h2>
<p className="mt-1 text-stone-500 text-sm">Imágenes y videos generados para el capítulo.</p>
</div>
{!imagenesData || imagenesData.generated === 0 ? (
<div className="text-center py-16">
<Image className="w-8 h-8 text-stone-300 mx-auto mb-3" />
<p className="text-stone-400 text-sm mb-5">
{!imagenesData ? 'Primero genera los prompts en el paso anterior.' : 'Aún no hay imágenes generadas. Ve al paso Prompts para generarlas.'}
</p>
<Button variant="outline" size="sm" onClick={() => setCurrentStep(2)}>
Ir a Prompts
</Button>
</div>
) : (
<>
{/* Stats header with bulk generation */}
<div className="bg-white rounded-lg border border-stone-200 px-5 py-3 flex items-center justify-between">
<div className="flex items-baseline gap-3">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wide">Progreso</span>
<span className="text-sm font-medium text-stone-700">
{imagenesData.generated}/{imagenesData.total} imágenes · {videosData?.generated || 0}/{imagenesData.generated} videos
{videosData && videosData.planos.length > 0 && (() => {
const totalTime = videosData.planos
.flatMap(p => p.videoVariations || [])
.reduce((sum, v) => sum + (v.generationTimeSeconds || 0), 0);
const totalCost = videosData.planos
.flatMap(p => p.videoVariations || [])
.reduce((sum, v) => sum + (v.costUsd || 0), 0);
return totalTime > 0 || totalCost > 0 ? (
<span className="text-stone-400 font-normal ml-2">
({totalTime > 0 && `${totalTime.toFixed(0)}s`}
{totalTime > 0 && totalCost > 0 && ' · '}
{totalCost > 0 && `$${totalCost.toFixed(2)}`})
</span>
) : null;
})()}
</span>
</div>
<div className="flex items-center gap-2">
{videosData && (() => {
const pendingCount = videosData.planos.filter(p => p.imagenUrl && (!p.videoVariations || p.videoVariations.length === 0)).length;
return (
<>
{pendingCount > 0 && (
<Button
size="sm"
onClick={() => handleGenerateAllVideos(true)}
loading={generating}
disabled={!!generatingVideo}
>
<Video className="w-3.5 h-3.5" />
Generar videos pendientes ({pendingCount})
</Button>
)}
<Button
size="sm"
variant={pendingCount > 0 ? 'outline' : 'primary'}
onClick={() => handleGenerateAllVideos(false)}
loading={generating}
disabled={!!generatingVideo || imagenesData.generated === 0}
>
<Video className="w-3.5 h-3.5" />
Generar variaciones en todos
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleStartRender}
disabled={!videosData || videosData.generated === 0 || !!renderJobId}
loading={renderProgress?.phase === 'rendering' || renderProgress?.phase === 'bundling'}
>
<Film className="w-3.5 h-3.5" />
Renderizar Video Final
</Button>
</>
);
})()}
</div>
</div>
{/* Render progress indicator */}
{renderProgress && renderProgress.phase !== 'complete' && (
<div className="bg-indigo-50 border border-indigo-200 rounded-lg px-5 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-indigo-900">{renderProgress.message}</span>
<span className="text-sm font-semibold text-indigo-700">{renderProgress.progress}%</span>
</div>
<div className="w-full bg-indigo-100 rounded-full h-2 overflow-hidden">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${renderProgress.progress}%` }}
/>
</div>
</div>
)}
{/* Video variations gallery */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{imagenesData.planos.filter(p => p.imagenUrl).map((plano) => {
const videoPlano = videosData?.planos.find(vp => vp.id === plano.id);
const mergedPlano = { ...plano, videoVariations: videoPlano?.videoVariations };
return (
<VideoVariationsCard
key={plano.id}
plano={mergedPlano}
onGenerateVariation={handleGeneratePlanoVideo}
onSelectVariation={handleSelectVideoVariation}
onDeleteVariation={handleDeleteVideoVariation}
generatingVideo={generatingVideo}
/>
);
})}
</div>
</>
)}
</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={() => setCurrentStep(Math.max(0, currentStep - 1))}
disabled={currentStep === 0}
>
<ArrowLeft className="w-3.5 h-3.5" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentStep(Math.min(CAPITULO_STEPS.length - 1, currentStep + 1))}
disabled={currentStep === CAPITULO_STEPS.length - 1}
>
Siguiente
<ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</footer>
</div>
);
}
/** Fixed vertical sidebar for navigating between planos */
function PlanoNavigator({ planos }: { planos: PlanoConPrompt[] }) {
const [activeId, setActiveId] = useState<string | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id.replace('plano-', ''));
break;
}
}
},
{ rootMargin: '-80px 0px -60% 0px', threshold: 0.1 }
);
for (const p of planos) {
const el = document.getElementById(`plano-${p.id}`);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [planos]);
const scrollTo = (id: string) => {
const el = document.getElementById(`plano-${id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Group planos by segment prefix (e.g. "1.1" from "1.1.3")
const groups: { segment: string; items: PlanoConPrompt[] }[] = [];
for (const p of planos) {
const parts = (p.numeroPlano || '').split('.');
const seg = parts.length >= 2 ? parts.slice(0, 2).join('.') : parts[0] || '?';
const last = groups[groups.length - 1];
if (last && last.segment === seg) {
last.items.push(p);
} else {
groups.push({ segment: seg, items: [p] });
}
}
return (
<nav className="fixed right-3 top-1/2 -translate-y-1/2 z-20 flex flex-col items-end gap-0.5 max-h-[70vh] overflow-y-auto py-2 pr-1 scrollbar-thin">
{groups.map((g) => (
<div key={g.segment} className="flex flex-col items-end">
<span className="text-[8px] font-semibold text-stone-400 uppercase tracking-wider mr-1 mb-0.5 mt-1.5 first:mt-0">
{g.segment}
</span>
{g.items.map((p) => {
const isActive = activeId === p.id;
const hasImage = !!p.imagenUrl;
const hasPrompt = !!p.promptImagen;
const planoNum = (p.numeroPlano || '').split('.').pop() || '?';
return (
<button
key={p.id}
onClick={() => scrollTo(p.id)}
title={`${p.numeroPlano}${p.segmentoGuion || ''}`}
className={`
flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium transition-all
${isActive
? 'bg-indigo-100 text-indigo-700 scale-110'
: 'text-stone-400 hover:text-stone-600 hover:bg-stone-100'
}
`}
>
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
hasImage ? 'bg-emerald-400' : hasPrompt ? 'bg-indigo-400' : 'bg-stone-300'
}`}
/>
{planoNum}
</button>
);
})}
</div>
))}
</nav>
);
}