'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(null); const [preEscaleta, setPreEscaleta] = useState(null); const [escaleta, setEscaleta] = useState(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>({}); 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(null); const [generatingPromptSingle, setGeneratingPromptSingle] = useState(null); const [fondosData, setFondosData] = useState<{ fondos: FondoReferencia[]; total: number; generated: number } | null>(null); const [generatingFondo, setGeneratingFondo] = useState(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(null); // Remotion render state const [renderJobId, setRenderJobId] = useState(null); const [renderProgress, setRenderProgress] = useState(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(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 => { 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 = {}; if (fondosData) { for (const f of fondosData.fondos) { if (f.imagenUrl) fondoImagenMap[f.id] = `${f.imagenUrl}?v=${fondosVersion}`; } } if (loading) { return (
); } return (
{toast && setToast(null)} />}

Cap. {numero} {guion?.titulo ? `— ${guion.titulo}` : ''}

setCurrentStep(index)} />
{/* Step 0: Guión Conductor */} {currentStep === 0 && (

Guión Conductor

Estructura narrativa del capítulo.

{!guion ? (

La IA creará la estructura narrativa basada en tu Idea Motriz.

) : (
{guion.titulo} {guion.virtudGanada && {guion.virtudGanada}}
{!editingGuion && !editingGuionFields && ( <> )} {editingGuion && ( <> )}
{editingGuionFields ? ( setEditingGuionFields(false)} /> ) : ( <>
{guion.duracionTotal && (
Duración

{guion.duracionTotal}

)} {guion.aliado?.nombre && (
Aliado

{guion.aliado.nombre}

)} {guion.gemaGanada && (
Gema

{guion.gemaGanada}

)}
{guion.nudoDelMundo && (
Nudo del Mundo

{guion.nudoDelMundo}

)} {editingGuion ? (