diff --git a/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts b/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts index 5972876..e481bac 100644 --- a/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts +++ b/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { generateVideo, downloadVideo } from '@/lib/video-service'; +import { submitVideoJob, getJobStatus, downloadVideo, H3_COST_USD } from '@/lib/video-service'; import { getPromptsPlanos, updatePromptPlano, getCapDirName, getVideoVariations, saveVideoVariation, selectVideoVariation, deleteVideoVariation, + getPendingVideoVariations, updateVideoVariationProgress, } from '@/lib/db-service'; import fs from 'fs/promises'; import path from 'path'; @@ -34,13 +35,18 @@ export async function GET( }; }); - const generated = planosWithVariations.filter(p => p.videoVariations && p.videoVariations.length > 0).length; + const generated = planosWithVariations.filter(p => p.videoVariations.some(v => v.status === 'ready')).length; + const pending = planosWithVariations.reduce( + (n, p) => n + p.videoVariations.filter(v => v.status === 'queued' || v.status === 'processing').length, + 0, + ); const withPrompt = planos.filter(p => p.promptVideo).length; return NextResponse.json({ planos: planosWithVariations, total: planos.length, generated, + pending, withPrompt, }); } catch (error: any) { @@ -82,7 +88,8 @@ export async function POST( return NextResponse.json({ plano: { ...plano, promptVideo } }); } - // Action: generate (generate new video variation) + // Action: generate — encola un job de vídeo en H3 y devuelve al instante. + // El render lo drena el worker de H3 (ventana nocturna); el estado se refresca con 'sync-jobs'. if (action === 'generate') { if (!plano.imagenUrl) { return NextResponse.json({ error: 'El plano no tiene imagen generada' }, { status: 400 }); @@ -91,75 +98,51 @@ export async function POST( // Use custom prompt if provided, otherwise compose from plano data const videoPrompt = promptVideo || plano.promptVideo || composeVideoPrompt(plano); - // Read image from disk and convert to base64 data URL - // This is needed because localhost URLs are not accessible by Seedance API - const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos'); - const imagePath = path.join(capitulosDir, capDirName, 'imagenes', `${planoId}.png`); - + // Read image from disk and convert to base64 data URL (la API H3 no ve URLs de localhost) + const imagePath = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'imagenes', `${planoId}.png`); let imageDataUrl: string; try { const imageBuffer = await fs.readFile(imagePath); - const base64Image = imageBuffer.toString('base64'); - imageDataUrl = `data:image/png;base64,${base64Image}`; - console.log(`[Videos API] Image loaded from disk: ${imagePath.length} chars (base64)`); + imageDataUrl = `data:image/png;base64,${imageBuffer.toString('base64')}`; } catch (err) { return NextResponse.json({ error: 'No se pudo leer la imagen del plano' }, { status: 500 }); } - console.log(`[Videos API] Generating video for plano ${planoId} with prompt: "${videoPrompt}"`); + // Resolución por plano: 16:9 (default) o 9:16 según body.aspect. + const aspect = body.aspect === '9:16' ? '9:16' : '16:9'; + const [width, height] = aspect === '9:16' ? [480, 832] : [832, 480]; - // Generate video using Seedance 1.5 Pro with base64 image - // generateAudio=true with prompt instruction for natural sounds only (no music) - const result = await generateVideo( - imageDataUrl, - videoPrompt, - plano.tiempoSegundos || 5, - false, // cameraFixed - true // generateAudio (natural sounds only via prompt) - ); + console.log(`[Videos API] Encolando vídeo H3 para plano ${planoId} (${aspect})`); - console.log(`[Videos API] Video generated in ${result.generationTimeSeconds}s, cost: $${result.costUsd}`); + let jobId: string; + let fullPrompt: string; + try { + ({ jobId, fullPrompt } = await submitVideoJob(imageDataUrl, videoPrompt, { + duration: plano.tiempoSegundos || 5, + width, + height, + })); + } catch (e: any) { + return NextResponse.json({ error: e.message || 'Error encolando el vídeo' }, { status: 502 }); + } - // Download video and save to disk - const videoBuffer = await downloadVideo(result.videoUrl); - const videosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'videos'); - await fs.mkdir(videosDir, { recursive: true }); - - // Generate unique filename for this variation + // Persistir la variación en estado 'queued' (sin vídeo aún). NO se auto-selecciona hasta + // que esté 'ready' (ver sync-jobs). const variationId = randomUUID(); - const videoFileName = `${planoId}-${variationId}.mp4`; - const videoPath = path.join(videosDir, videoFileName); - await fs.writeFile(videoPath, videoBuffer); - - console.log(`[Videos API] Video saved to: ${videoPath}`); - - // Get existing variations to determine if this should be selected - const existingVariations = getVideoVariations(slug, num, planoId); - const isFirstVariation = existingVariations.length === 0; - - // Save variation to DB - const videoUrl = `/api/projects/${slug}/capitulos/${capDirName}/videos/${videoFileName.replace('.mp4', '')}`; saveVideoVariation(slug, num, { id: variationId, planoId, - videoUrl, - videoPath, - promptVideo: result.fullPrompt, - generationTimeSeconds: result.generationTimeSeconds, - costUsd: result.costUsd, - isSelected: isFirstVariation, + videoUrl: '', + promptVideo: fullPrompt, + costUsd: H3_COST_USD, + isSelected: false, createdAt: new Date().toISOString(), + status: 'queued', + h3JobId: jobId, }); - // Update legacy fields if this is the first variation - if (isFirstVariation) { - updatePromptPlano(slug, num, planoId, { videoUrl, promptVideo: result.fullPrompt }); - } - - // Return updated variations const variations = getVideoVariations(slug, num, planoId); const selectedVariation = variations.find(v => v.isSelected); - return NextResponse.json({ plano: { ...plano, @@ -170,6 +153,75 @@ export async function POST( }); } + // Action: sync-jobs — avanza los jobs de vídeo pendientes del capítulo (queued/processing). + // Lo llama el cliente periódicamente mientras haya variaciones pendientes. Sin lógica de + // ventana ni pings: una sola consulta por job; si 'done' descarga el mp4 y marca 'ready'. + if (action === 'sync-jobs') { + const pending = getPendingVideoVariations(slug, num); + const videosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'videos'); + + for (const v of pending) { + if (!v.h3JobId) continue; + try { + const job = await getJobStatus(v.h3JobId); + + if (job.status === 'processing' && v.status !== 'processing') { + updateVideoVariationProgress(v.id, { status: 'processing' }); + } else if (job.status === 'error') { + updateVideoVariationProgress(v.id, { status: 'error', error: job.error || 'Error en el render H3' }); + } else if (job.status === 'done') { + if (!job.videoUrl) { + updateVideoVariationProgress(v.id, { status: 'error', error: 'H3 done sin video_url' }); + continue; + } + // Descargar y guardar el mp4 en disco. + const videoBuffer = await downloadVideo(job.videoUrl); + await fs.mkdir(videosDir, { recursive: true }); + const videoFileName = `${v.planoId}-${v.id}.mp4`; + const videoPath = path.join(videosDir, videoFileName); + await fs.writeFile(videoPath, videoBuffer); + + const localUrl = `/api/projects/${slug}/capitulos/${capDirName}/videos/${videoFileName.replace('.mp4', '')}`; + updateVideoVariationProgress(v.id, { + status: 'ready', + videoUrl: localUrl, + videoPath, + generationTimeSeconds: job.generationTimeSeconds, + }); + + // Si el plano no tiene ninguna variación 'ready' seleccionada, seleccionar ésta. + const siblings = getVideoVariations(slug, num, v.planoId); + const hasSelectedReady = siblings.some(s => s.isSelected && s.status === 'ready' && s.id !== v.id); + if (!hasSelectedReady) { + selectVideoVariation(slug, num, v.planoId, v.id); + updatePromptPlano(slug, num, v.planoId, { videoUrl: localUrl, promptVideo: v.promptVideo }); + } + } + // 'queued' → sin cambios + } catch (e: any) { + // Fallo transitorio consultando/descargando: dejar la variación como está y reintentar + // en el próximo sync. No la marcamos 'error' por un fallo de red puntual. + console.warn(`[Videos API] sync-jobs: fallo con job ${v.h3JobId}: ${e.message}`); + } + } + + // Devolver el estado actualizado de todos los planos del capítulo. + const allPlanos = getPromptsPlanos(slug, num).map(p => { + const variations = getVideoVariations(slug, num, p.id); + const selectedVariation = variations.find(vv => vv.isSelected); + return { + ...p, + videoVariations: variations, + videoUrl: selectedVariation?.videoUrl || p.videoUrl, + promptVideo: selectedVariation?.promptVideo || p.promptVideo, + }; + }); + const generated = allPlanos.filter(p => p.videoVariations.some(vv => vv.status === 'ready')).length; + const stillPending = getPendingVideoVariations(slug, num).length; + + return NextResponse.json({ planos: allPlanos, generated, pending: stillPending }); + } + // Action: select-variation (select a specific variation) if (action === 'select-variation') { const { variationId } = body; diff --git a/src/app/project/[slug]/capitulo/[numero]/page.tsx b/src/app/project/[slug]/capitulo/[numero]/page.tsx index c8dc2d4..fc19862 100644 --- a/src/app/project/[slug]/capitulo/[numero]/page.tsx +++ b/src/app/project/[slug]/capitulo/[numero]/page.tsx @@ -674,47 +674,43 @@ export default function CapituloPage({ }; 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); - startProgress('Generando Video', 0, `Generando video para plano ${planoId}...`); try { const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'generate', planoId, promptVideo: customPrompt }), - signal: abortRef.current?.signal, }); const data = await res.json(); - if (!res.ok) { showToast(data.error || 'Error generando video', 'error'); closeProgress(); return; } + if (!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); - const generated = updatedPlanos.filter(p => p.videoVariations && p.videoVariations.length > 0).length; - setVideosData({ ...videosData, planos: updatedPlanos, generated }); + setVideosData({ ...videosData, planos: updatedPlanos }); } - addProgressLog(`Video generado para ${planoId}`, 'success'); - finishProgress(1, 1); - showToast(`Video generado para plano ${planoId}`, 'success'); - } catch (error: any) { - if (error.name !== 'AbortError') { showToast('Error al generar video', 'error'); } - closeProgress(); + 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 pending = onlyPending - ? videosData.planos.filter(p => p.imagenUrl && (!p.videoVariations || p.videoVariations.length === 0)) + 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 (pending.length === 0) return; + if (targets.length === 0) return; - startProgress('Generando Videos', pending.length, `Video 1/${pending.length}...`); + 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 < pending.length; i++) { + for (let i = 0; i < targets.length; i++) { if (abortRef.current?.signal.aborted) break; - const plano = pending[i]; - updateProgress(i, pending.length, `Video ${i + 1}/${pending.length} — ${plano.numeroPlano || plano.id}`); + 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`, { @@ -726,8 +722,7 @@ export default function CapituloPage({ const data = await res.json(); if (res.ok) { localPlanos = localPlanos.map(p => p.id === plano.id ? data.plano : p); - const generated = localPlanos.filter(p => p.videoVariations && p.videoVariations.length > 0).length; - setVideosData({ ...videosData, planos: localPlanos, generated }); + setVideosData({ ...videosData, planos: localPlanos }); addProgressLog(`${plano.numeroPlano || plano.id}`, 'success'); successCount++; } else { @@ -741,14 +736,43 @@ export default function CapituloPage({ } } - finishProgress(pending.length, pending.length); + finishProgress(targets.length, targets.length); if (errorCount > 0) { - showToast(`${successCount} variaciones generadas, ${errorCount} con error`, 'error'); + showToast(`${successCount} vídeos encolados, ${errorCount} con error`, 'error'); } else { - showToast(`${successCount} variaciones generadas correctamente`, 'success'); + 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`, { diff --git a/src/components/capitulo/VideoVariationsCard.tsx b/src/components/capitulo/VideoVariationsCard.tsx index 8090f7b..a7ab01b 100644 --- a/src/components/capitulo/VideoVariationsCard.tsx +++ b/src/components/capitulo/VideoVariationsCard.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Button } from '@/components/ui/Button'; -import { Video, Edit3, Save, Trash2, Check } from 'lucide-react'; +import { Video, Edit3, Trash2, Check, Loader2 } from 'lucide-react'; import type { PlanoConPrompt, VideoVariation } from '@/types/project'; interface VideoVariationsCardProps { @@ -167,6 +167,8 @@ export function VideoVariationsCard({
{variations.map((variation) => { const isSelected = variation.isSelected; + const isReady = variation.status === 'ready'; + const isError = variation.status === 'error'; return (
-