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 (
-