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>
1692 lines
77 KiB
TypeScript
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>
|
|
);
|
|
}
|