Compare commits
2 Commits
master
...
a698c6b225
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a698c6b225 | ||
|
|
68c164db97 |
@@ -1,8 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 {
|
import {
|
||||||
getPromptsPlanos, updatePromptPlano, getCapDirName,
|
getPromptsPlanos, updatePromptPlano, getCapDirName,
|
||||||
getVideoVariations, saveVideoVariation, selectVideoVariation, deleteVideoVariation,
|
getVideoVariations, saveVideoVariation, selectVideoVariation, deleteVideoVariation,
|
||||||
|
getPendingVideoVariations, updateVideoVariationProgress,
|
||||||
} from '@/lib/db-service';
|
} from '@/lib/db-service';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
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;
|
const withPrompt = planos.filter(p => p.promptVideo).length;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
planos: planosWithVariations,
|
planos: planosWithVariations,
|
||||||
total: planos.length,
|
total: planos.length,
|
||||||
generated,
|
generated,
|
||||||
|
pending,
|
||||||
withPrompt,
|
withPrompt,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -82,7 +88,8 @@ export async function POST(
|
|||||||
return NextResponse.json({ plano: { ...plano, promptVideo } });
|
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 (action === 'generate') {
|
||||||
if (!plano.imagenUrl) {
|
if (!plano.imagenUrl) {
|
||||||
return NextResponse.json({ error: 'El plano no tiene imagen generada' }, { status: 400 });
|
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
|
// Use custom prompt if provided, otherwise compose from plano data
|
||||||
const videoPrompt = promptVideo || plano.promptVideo || composeVideoPrompt(plano);
|
const videoPrompt = promptVideo || plano.promptVideo || composeVideoPrompt(plano);
|
||||||
|
|
||||||
// Read image from disk and convert to base64 data URL
|
// Read image from disk and convert to base64 data URL (la API H3 no ve URLs de localhost)
|
||||||
// This is needed because localhost URLs are not accessible by Seedance API
|
const imagePath = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'imagenes', `${planoId}.png`);
|
||||||
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
|
|
||||||
const imagePath = path.join(capitulosDir, capDirName, 'imagenes', `${planoId}.png`);
|
|
||||||
|
|
||||||
let imageDataUrl: string;
|
let imageDataUrl: string;
|
||||||
try {
|
try {
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
const base64Image = imageBuffer.toString('base64');
|
imageDataUrl = `data:image/png;base64,${imageBuffer.toString('base64')}`;
|
||||||
imageDataUrl = `data:image/png;base64,${base64Image}`;
|
|
||||||
console.log(`[Videos API] Image loaded from disk: ${imagePath.length} chars (base64)`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: 'No se pudo leer la imagen del plano' }, { status: 500 });
|
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
|
console.log(`[Videos API] Encolando vídeo H3 para plano ${planoId} (${aspect})`);
|
||||||
// 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] 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
|
// Persistir la variación en estado 'queued' (sin vídeo aún). NO se auto-selecciona hasta
|
||||||
const videoBuffer = await downloadVideo(result.videoUrl);
|
// que esté 'ready' (ver sync-jobs).
|
||||||
const videosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'videos');
|
|
||||||
await fs.mkdir(videosDir, { recursive: true });
|
|
||||||
|
|
||||||
// Generate unique filename for this variation
|
|
||||||
const variationId = randomUUID();
|
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, {
|
saveVideoVariation(slug, num, {
|
||||||
id: variationId,
|
id: variationId,
|
||||||
planoId,
|
planoId,
|
||||||
videoUrl,
|
videoUrl: '',
|
||||||
videoPath,
|
promptVideo: fullPrompt,
|
||||||
promptVideo: result.fullPrompt,
|
costUsd: H3_COST_USD,
|
||||||
generationTimeSeconds: result.generationTimeSeconds,
|
isSelected: false,
|
||||||
costUsd: result.costUsd,
|
|
||||||
isSelected: isFirstVariation,
|
|
||||||
createdAt: new Date().toISOString(),
|
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 variations = getVideoVariations(slug, num, planoId);
|
||||||
const selectedVariation = variations.find(v => v.isSelected);
|
const selectedVariation = variations.find(v => v.isSelected);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
plano: {
|
plano: {
|
||||||
...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)
|
// Action: select-variation (select a specific variation)
|
||||||
if (action === 'select-variation') {
|
if (action === 'select-variation') {
|
||||||
const { variationId } = body;
|
const { variationId } = body;
|
||||||
|
|||||||
@@ -674,47 +674,43 @@ export default function CapituloPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGeneratePlanoVideo = async (planoId: string, customPrompt?: string) => {
|
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);
|
setGeneratingVideo(planoId);
|
||||||
startProgress('Generando Video', 0, `Generando video para plano ${planoId}...`);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'generate', planoId, promptVideo: customPrompt }),
|
body: JSON.stringify({ action: 'generate', planoId, promptVideo: customPrompt }),
|
||||||
signal: abortRef.current?.signal,
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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) {
|
if (videosData) {
|
||||||
const updatedPlanos = videosData.planos.map(p => p.id === planoId ? data.plano : p);
|
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 });
|
||||||
setVideosData({ ...videosData, planos: updatedPlanos, generated });
|
|
||||||
}
|
}
|
||||||
addProgressLog(`Video generado para ${planoId}`, 'success');
|
showToast('Vídeo en cola — se generará en la ventana nocturna', 'success');
|
||||||
finishProgress(1, 1);
|
} catch {
|
||||||
showToast(`Video generado para plano ${planoId}`, 'success');
|
showToast('Error al encolar el vídeo', 'error');
|
||||||
} catch (error: any) {
|
|
||||||
if (error.name !== 'AbortError') { showToast('Error al generar video', 'error'); }
|
|
||||||
closeProgress();
|
|
||||||
} finally { setGeneratingVideo(null); }
|
} finally { setGeneratingVideo(null); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateAllVideos = async (onlyPending = false) => {
|
const handleGenerateAllVideos = async (onlyPending = false) => {
|
||||||
if (!videosData) return;
|
if (!videosData) return;
|
||||||
const pending = onlyPending
|
const targets = onlyPending
|
||||||
? videosData.planos.filter(p => p.imagenUrl && (!p.videoVariations || p.videoVariations.length === 0))
|
? 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);
|
: 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 successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let localPlanos = [...videosData.planos];
|
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;
|
if (abortRef.current?.signal.aborted) break;
|
||||||
const plano = pending[i];
|
const plano = targets[i];
|
||||||
updateProgress(i, pending.length, `Video ${i + 1}/${pending.length} — ${plano.numeroPlano || plano.id}`);
|
updateProgress(i, targets.length, `Encolando ${i + 1}/${targets.length} — ${plano.numeroPlano || plano.id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
||||||
@@ -726,8 +722,7 @@ export default function CapituloPage({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
localPlanos = localPlanos.map(p => p.id === plano.id ? data.plano : p);
|
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 });
|
||||||
setVideosData({ ...videosData, planos: localPlanos, generated });
|
|
||||||
addProgressLog(`${plano.numeroPlano || plano.id}`, 'success');
|
addProgressLog(`${plano.numeroPlano || plano.id}`, 'success');
|
||||||
successCount++;
|
successCount++;
|
||||||
} else {
|
} else {
|
||||||
@@ -741,14 +736,43 @@ export default function CapituloPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finishProgress(pending.length, pending.length);
|
finishProgress(targets.length, targets.length);
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
showToast(`${successCount} variaciones generadas, ${errorCount} con error`, 'error');
|
showToast(`${successCount} vídeos encolados, ${errorCount} con error`, 'error');
|
||||||
} else {
|
} 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) => {
|
const handleSaveVideoPrompt = async (planoId: string, promptVideo: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/Button';
|
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';
|
import type { PlanoConPrompt, VideoVariation } from '@/types/project';
|
||||||
|
|
||||||
interface VideoVariationsCardProps {
|
interface VideoVariationsCardProps {
|
||||||
@@ -167,6 +167,8 @@ export function VideoVariationsCard({
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{variations.map((variation) => {
|
{variations.map((variation) => {
|
||||||
const isSelected = variation.isSelected;
|
const isSelected = variation.isSelected;
|
||||||
|
const isReady = variation.status === 'ready';
|
||||||
|
const isError = variation.status === 'error';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={variation.id}
|
key={variation.id}
|
||||||
@@ -174,6 +176,7 @@ export function VideoVariationsCard({
|
|||||||
isSelected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-stone-200 hover:border-stone-300'
|
isSelected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-stone-200 hover:border-stone-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{isReady ? (
|
||||||
<video
|
<video
|
||||||
src={variation.videoUrl}
|
src={variation.videoUrl}
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
@@ -186,7 +189,27 @@ export function VideoVariationsCard({
|
|||||||
vid.currentTime = 0;
|
vid.currentTime = 0;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
) : (
|
||||||
|
<div
|
||||||
|
className={`w-full flex flex-col items-center justify-center gap-1 text-center px-2 ${
|
||||||
|
isError ? 'bg-red-50 text-red-500' : 'bg-stone-50 text-stone-400'
|
||||||
|
}`}
|
||||||
|
style={{ aspectRatio: '16/9' }}
|
||||||
|
title={variation.error || undefined}
|
||||||
|
>
|
||||||
|
{isError ? (
|
||||||
|
<span className="text-[10px] font-medium">Error</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-[9px] font-medium">
|
||||||
|
{variation.status === 'processing' ? 'Generando…' : 'En cola'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSelected && isReady && (
|
||||||
<div className="absolute top-1 left-1 bg-indigo-500 text-white rounded-full p-0.5">
|
<div className="absolute top-1 left-1 bg-indigo-500 text-white rounded-full p-0.5">
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
@@ -198,17 +221,16 @@ export function VideoVariationsCard({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
{isReady && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1">
|
||||||
<div className="flex items-center justify-between text-[8px] text-white">
|
<div className="flex items-center justify-between text-[8px] text-white">
|
||||||
{variation.generationTimeSeconds && (
|
{variation.generationTimeSeconds && (
|
||||||
<span>{variation.generationTimeSeconds.toFixed(1)}s</span>
|
<span>{variation.generationTimeSeconds.toFixed(1)}s</span>
|
||||||
)}
|
)}
|
||||||
{variation.costUsd !== undefined && (
|
</div>
|
||||||
<span>${variation.costUsd.toFixed(3)}</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -836,13 +836,17 @@ export function getVideoVariations(slug: string, numero: number, planoId: string
|
|||||||
|
|
||||||
const rows = getDb().prepare(`
|
const rows = getDb().prepare(`
|
||||||
SELECT id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
SELECT id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
||||||
generation_time_seconds, cost_usd, is_selected, created_at
|
generation_time_seconds, cost_usd, is_selected, created_at, status, h3_job_id, error
|
||||||
FROM video_variations
|
FROM video_variations
|
||||||
WHERE capitulo_id = ? AND plano_id = ?
|
WHERE capitulo_id = ? AND plano_id = ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`).all(capId, planoId) as any[];
|
`).all(capId, planoId) as any[];
|
||||||
|
|
||||||
return rows.map(r => ({
|
return rows.map(mapVideoVariationRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapVideoVariationRow(r: any): VideoVariation {
|
||||||
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
planoId: r.plano_id,
|
planoId: r.plano_id,
|
||||||
capituloId: r.capitulo_id,
|
capituloId: r.capitulo_id,
|
||||||
@@ -850,10 +854,51 @@ export function getVideoVariations(slug: string, numero: number, planoId: string
|
|||||||
videoPath: r.video_path || undefined,
|
videoPath: r.video_path || undefined,
|
||||||
promptVideo: r.prompt_video,
|
promptVideo: r.prompt_video,
|
||||||
generationTimeSeconds: r.generation_time_seconds || undefined,
|
generationTimeSeconds: r.generation_time_seconds || undefined,
|
||||||
costUsd: r.cost_usd || undefined,
|
costUsd: r.cost_usd ?? undefined,
|
||||||
isSelected: r.is_selected === 1,
|
isSelected: r.is_selected === 1,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
}));
|
status: (r.status || 'ready') as VideoVariation['status'],
|
||||||
|
h3JobId: r.h3_job_id || undefined,
|
||||||
|
error: r.error || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get non-terminal video variations (queued|processing) across a whole chapter.
|
||||||
|
* Used by the async sync endpoint to advance jobs.
|
||||||
|
*/
|
||||||
|
export function getPendingVideoVariations(slug: string, numero: number): VideoVariation[] {
|
||||||
|
const capId = resolveCapituloId(slug, numero);
|
||||||
|
if (!capId) return [];
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
||||||
|
generation_time_seconds, cost_usd, is_selected, created_at, status, h3_job_id, error
|
||||||
|
FROM video_variations
|
||||||
|
WHERE capitulo_id = ? AND status IN ('queued', 'processing')
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`).all(capId) as any[];
|
||||||
|
return rows.map(mapVideoVariationRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the progress/result of a video variation (async H3 lifecycle).
|
||||||
|
* Only overwrites the fields provided.
|
||||||
|
*/
|
||||||
|
export function updateVideoVariationProgress(
|
||||||
|
variationId: string,
|
||||||
|
patch: Partial<Pick<VideoVariation, 'status' | 'videoUrl' | 'videoPath' | 'generationTimeSeconds' | 'costUsd' | 'error'>>
|
||||||
|
): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: any[] = [];
|
||||||
|
if (patch.status !== undefined) { sets.push('status = ?'); vals.push(patch.status); }
|
||||||
|
if (patch.videoUrl !== undefined) { sets.push('video_url = ?'); vals.push(patch.videoUrl); }
|
||||||
|
if (patch.videoPath !== undefined) { sets.push('video_path = ?'); vals.push(patch.videoPath); }
|
||||||
|
if (patch.generationTimeSeconds !== undefined) { sets.push('generation_time_seconds = ?'); vals.push(patch.generationTimeSeconds); }
|
||||||
|
if (patch.costUsd !== undefined) { sets.push('cost_usd = ?'); vals.push(patch.costUsd); }
|
||||||
|
if (patch.error !== undefined) { sets.push('error = ?'); vals.push(patch.error); }
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(variationId);
|
||||||
|
getDb().prepare(`UPDATE video_variations SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -871,19 +916,22 @@ export function saveVideoVariation(
|
|||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO video_variations
|
INSERT INTO video_variations
|
||||||
(id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
(id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
||||||
generation_time_seconds, cost_usd, is_selected, created_at)
|
generation_time_seconds, cost_usd, is_selected, created_at, status, h3_job_id, error)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
variation.id,
|
variation.id,
|
||||||
variation.planoId,
|
variation.planoId,
|
||||||
capId,
|
capId,
|
||||||
variation.videoUrl,
|
variation.videoUrl || '',
|
||||||
variation.videoPath || null,
|
variation.videoPath || null,
|
||||||
variation.promptVideo,
|
variation.promptVideo,
|
||||||
variation.generationTimeSeconds || null,
|
variation.generationTimeSeconds ?? null,
|
||||||
variation.costUsd || null,
|
variation.costUsd ?? null,
|
||||||
variation.isSelected ? 1 : 0,
|
variation.isSelected ? 1 : 0,
|
||||||
variation.createdAt
|
variation.createdAt,
|
||||||
|
variation.status || 'ready',
|
||||||
|
variation.h3JobId || null,
|
||||||
|
variation.error || null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,13 +155,17 @@ function initSchema(db: Database.Database): void {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
plano_id TEXT NOT NULL,
|
plano_id TEXT NOT NULL,
|
||||||
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
|
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
|
||||||
video_url TEXT NOT NULL,
|
video_url TEXT NOT NULL DEFAULT '',
|
||||||
video_path TEXT,
|
video_path TEXT,
|
||||||
prompt_video TEXT NOT NULL,
|
prompt_video TEXT NOT NULL,
|
||||||
generation_time_seconds REAL,
|
generation_time_seconds REAL,
|
||||||
cost_usd REAL,
|
cost_usd REAL,
|
||||||
is_selected INTEGER NOT NULL DEFAULT 0,
|
is_selected INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
-- Generación async (H3): job remoto y estado del ciclo de vida.
|
||||||
|
status TEXT NOT NULL DEFAULT 'ready',
|
||||||
|
h3_job_id TEXT,
|
||||||
|
error TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create indexes if they don't exist
|
-- Create indexes if they don't exist
|
||||||
@@ -202,4 +206,32 @@ function runMigrations(db: Database.Database): void {
|
|||||||
console.warn('[Migration] ALTER prompts_planos falló (posiblemente ya aplicada):', e);
|
console.warn('[Migration] ALTER prompts_planos falló (posiblemente ya aplicada):', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: async video generation (H3) columns on video_variations.
|
||||||
|
const vvCols = (db.pragma('table_info(video_variations)') as Array<{ name: string }>).map(c => c.name);
|
||||||
|
if (!vvCols.includes('status')) {
|
||||||
|
try {
|
||||||
|
// Filas existentes ya tienen vídeo → 'ready'.
|
||||||
|
db.exec("ALTER TABLE video_variations ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'");
|
||||||
|
console.log('[Migration] Added status column to video_variations');
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists or other error - safe to ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!vvCols.includes('h3_job_id')) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE video_variations ADD COLUMN h3_job_id TEXT');
|
||||||
|
console.log('[Migration] Added h3_job_id column to video_variations');
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists or other error - safe to ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!vvCols.includes('error')) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE video_variations ADD COLUMN error TEXT');
|
||||||
|
console.log('[Migration] Added error column to video_variations');
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists or other error - safe to ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +1,158 @@
|
|||||||
/**
|
/**
|
||||||
* Video generation service using BytePlus Ark API with Seedance 1.5 Pro
|
* Video generation service — H3 async video API (h3api.essia.coop).
|
||||||
|
*
|
||||||
|
* Sustituye a BytePlus Ark / Seedance. Contrato definido por essia-server (2026-08):
|
||||||
|
* POST /v1/jobs (multipart o json) -> 202 { job_id, status:"queued" }
|
||||||
|
* GET /v1/jobs/{id} -> { status, video_url?, generation_time_seconds?, error? }
|
||||||
|
* GET /v1/jobs/{id}/video -> binario mp4 (con audio nativo)
|
||||||
|
* Auth: header X-API-Key. Coste 0 (GPU self-hosted, sin tarifa por clip).
|
||||||
|
*
|
||||||
|
* La cola + worker viven SERVER-SIDE (h3api): `submitVideoJob` devuelve un job_id al instante
|
||||||
|
* (status=queued) y el server lo drena en la ventana nocturna. Aquí NO se bloquea la request:
|
||||||
|
* el ciclo submit → poll → descarga lo orquesta el route de vídeos de forma async (persistiendo
|
||||||
|
* el job_id en `video_variations` y refrescando estado bajo demanda desde el cliente).
|
||||||
|
*
|
||||||
|
* ESTADO: dormante hasta que essia-server confirme "live" + provea H3API_KEY. Sin esas env vars,
|
||||||
|
* submitVideoJob() lanza error solo si se invoca (no rompe el arranque).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ARK_API_KEY = process.env.ARK_API_KEY || '';
|
const H3_BASE_URL = (process.env.H3API_BASE_URL || 'https://h3api.essia.coop').replace(/\/$/, '');
|
||||||
const ARK_BASE_URL = 'https://ark.ap-southeast.bytepluses.com/api/v3';
|
const H3_API_KEY = process.env.H3API_KEY || '';
|
||||||
const MODEL = 'seedance-1-5-pro-251215';
|
|
||||||
|
|
||||||
// Pricing - Update based on BytePlus Ark actual pricing
|
// Modo i2v del MVP: ref2va (imagen->vídeo, validado). fl2va (primer frame exacto, más fiel a
|
||||||
// Placeholder: $0.10 per video generation (adjust as needed)
|
// Seedance) se activará pasando mode:'fl2va' cuando essia-server lo valide — mismo contrato.
|
||||||
const COST_PER_VIDEO_USD = 0.10;
|
const DEFAULT_I2V_MODE = 'ref2va';
|
||||||
|
|
||||||
interface ArkTaskResponse {
|
// GPU propia self-hosted: sin tarifa por clip.
|
||||||
id: string;
|
const COST_PER_VIDEO_USD = 0;
|
||||||
model: string;
|
|
||||||
status: 'pending' | 'processing' | 'succeeded' | 'failed';
|
export type H3JobStatus = 'queued' | 'processing' | 'done' | 'error';
|
||||||
created_at: number;
|
|
||||||
updated_at: number;
|
export interface H3JobStatusResult {
|
||||||
content?: Array<{
|
status: H3JobStatus;
|
||||||
type: string;
|
videoUrl?: string;
|
||||||
video_url?: { url: string };
|
generationTimeSeconds?: number;
|
||||||
}> | {
|
error?: string;
|
||||||
video_url?: { url: string };
|
|
||||||
};
|
|
||||||
error?: {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoGenerationResult {
|
interface H3JobResponse {
|
||||||
videoUrl: string;
|
job_id: string;
|
||||||
generationTimeSeconds: number;
|
status: H3JobStatus;
|
||||||
costUsd: number;
|
video_url?: string;
|
||||||
fullPrompt: string;
|
generation_time_seconds?: number;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitVideoOptions {
|
||||||
|
duration?: number; // segundos, 4-15 (default 5)
|
||||||
|
cameraFixed?: boolean; // añade indicación de movimiento mínimo al prompt
|
||||||
|
width?: number; // múltiplo de 32 (default 832 = 16:9)
|
||||||
|
height?: number; // múltiplo de 32 (default 480 = 16:9)
|
||||||
|
mode?: string; // 'ref2va' (default) | 'fl2va' | 't2va'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const H3_COST_USD = COST_PER_VIDEO_USD;
|
||||||
|
|
||||||
|
function ensureConfigured(): void {
|
||||||
|
if (!H3_API_KEY) {
|
||||||
|
throw new Error('H3API_KEY no configurada en variables de entorno');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
|
return { 'X-API-Key': H3_API_KEY, ...(extra || {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convierte un data URL (data:image/png;base64,...) a Blob para multipart. */
|
||||||
|
function dataUrlToBlob(dataUrl: string): Blob {
|
||||||
|
const match = /^data:([^;]+);base64,([\s\S]*)$/.exec(dataUrl);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('imageUrl no es un data URL base64 válido');
|
||||||
|
}
|
||||||
|
const [, mime, b64] = match;
|
||||||
|
return new Blob([Buffer.from(b64, 'base64')], { type: mime });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate video from image using Seedance 1.5 Pro
|
* Encola un job de image-to-video en la H3 API y devuelve su id al instante (NO espera al render).
|
||||||
* @param imageUrl - URL or data URL (base64) of the reference image
|
*
|
||||||
* @param prompt - Text prompt describing the action/movement
|
* @param imageUrl data URL base64 de la imagen del plano, o URL https accesible por el server.
|
||||||
* @param duration - Video duration in seconds (default 5)
|
* @param prompt Prompt de movimiento (SOLO positivo; se recomienda movimiento mínimo).
|
||||||
* @param cameraFixed - Whether camera should be fixed (default false)
|
* @returns { jobId, fullPrompt } — persiste jobId y consulta el estado con getJobStatus().
|
||||||
* @param generateAudio - Whether to generate audio (default true)
|
|
||||||
* @returns Video generation result with URL, time, and cost
|
|
||||||
*/
|
*/
|
||||||
export async function generateVideo(
|
export async function submitVideoJob(
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
duration: number = 5,
|
options: SubmitVideoOptions = {}
|
||||||
cameraFixed: boolean = false,
|
): Promise<{ jobId: string; fullPrompt: string }> {
|
||||||
generateAudio: boolean = true
|
ensureConfigured();
|
||||||
): Promise<VideoGenerationResult> {
|
|
||||||
const startTime = Date.now();
|
const dur = Math.min(15, Math.max(4, options.duration || 5));
|
||||||
if (!ARK_API_KEY) {
|
const width = options.width ?? 832;
|
||||||
throw new Error('ARK_API_KEY no configurada en variables de entorno');
|
const height = options.height ?? 480;
|
||||||
|
const mode = options.mode || DEFAULT_I2V_MODE;
|
||||||
|
const fullPrompt = options.cameraFixed
|
||||||
|
? `${prompt} Minimal camera movement, subtle motion only.`
|
||||||
|
: prompt;
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('mode', mode);
|
||||||
|
form.append('prompt', fullPrompt);
|
||||||
|
form.append('width', String(width));
|
||||||
|
form.append('height', String(height));
|
||||||
|
form.append('duration', String(dur));
|
||||||
|
if (imageUrl.startsWith('data:')) {
|
||||||
|
form.append('image', dataUrlToBlob(imageUrl), 'plano.png');
|
||||||
|
} else {
|
||||||
|
form.append('image_url', imageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhance prompt to request only natural sounds, no background music
|
const res = await fetch(`${H3_BASE_URL}/v1/jobs`, {
|
||||||
const audioInstruction = generateAudio
|
|
||||||
? 'Natural ambient sounds and action sounds only, no background music.'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Compose the text prompt with audio instructions
|
|
||||||
const textPrompt = audioInstruction
|
|
||||||
? `${prompt} ${audioInstruction} --duration ${duration} --camerafixed ${cameraFixed}`
|
|
||||||
: `${prompt} --duration ${duration} --camerafixed ${cameraFixed}`;
|
|
||||||
|
|
||||||
// Create video generation task
|
|
||||||
const createResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders(), // no fijar Content-Type: fetch pone el boundary del multipart
|
||||||
'Content-Type': 'application/json',
|
body: form,
|
||||||
'Authorization': `Bearer ${ARK_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: MODEL,
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: textPrompt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: imageUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Additional parameters for video generation
|
|
||||||
duration,
|
|
||||||
resolution: '720p',
|
|
||||||
aspect_ratio: '16:9',
|
|
||||||
fps: 24,
|
|
||||||
camera_fixed: cameraFixed,
|
|
||||||
generate_audio: generateAudio,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await createResponse.json().catch(() => ({}));
|
const errorData = await res.text().catch(() => '');
|
||||||
throw new Error(`Error creando tarea de video: ${createResponse.status} ${JSON.stringify(errorData)}`);
|
throw new Error(`Error creando job de vídeo H3: ${res.status} ${errorData}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createData: ArkTaskResponse = await createResponse.json();
|
const data = (await res.json()) as H3JobResponse;
|
||||||
const taskId = createData.id;
|
if (!data.job_id) {
|
||||||
|
throw new Error(`Respuesta de H3 sin job_id: ${JSON.stringify(data)}`);
|
||||||
console.log(`[Video Service] Task created: ${taskId}`);
|
|
||||||
|
|
||||||
// Poll task status until completed (max 5 minutes)
|
|
||||||
const maxAttempts = 300; // 5 minutes with 1s intervals
|
|
||||||
let attempts = 0;
|
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
||||||
|
|
||||||
const statusResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks/${taskId}`, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${ARK_API_KEY}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!statusResponse.ok) {
|
|
||||||
throw new Error(`Error consultando estado de tarea: ${statusResponse.status}`);
|
|
||||||
}
|
}
|
||||||
|
console.log(`[Video Service/H3] Job encolado: ${data.job_id} (status ${data.status})`);
|
||||||
const statusData: ArkTaskResponse = await statusResponse.json();
|
return { jobId: data.job_id, fullPrompt };
|
||||||
console.log(`[Video Service] Task ${taskId} status: ${statusData.status}`);
|
|
||||||
|
|
||||||
if (statusData.status === 'succeeded') {
|
|
||||||
// Log the full response to understand its structure
|
|
||||||
console.log('[Video Service] Success response:', JSON.stringify(statusData, null, 2));
|
|
||||||
|
|
||||||
// Extract video URL from response
|
|
||||||
// Handle both array and object formats
|
|
||||||
let videoUrl: string | undefined;
|
|
||||||
|
|
||||||
if (Array.isArray(statusData.content)) {
|
|
||||||
// Array format: [{ type: 'video_url', video_url: { url: '...' } }]
|
|
||||||
const videoContent = statusData.content.find(c => c.type === 'video_url' && c.video_url);
|
|
||||||
videoUrl = videoContent?.video_url?.url;
|
|
||||||
} else if (statusData.content?.video_url) {
|
|
||||||
// Object format: { video_url: "https://..." } (direct string)
|
|
||||||
const vurl = (statusData.content as any).video_url;
|
|
||||||
videoUrl = typeof vurl === 'string' ? vurl : vurl?.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!videoUrl) {
|
|
||||||
throw new Error(`Video URL no encontrada en respuesta exitosa. Response: ${JSON.stringify(statusData)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Video Service] Task ${taskId} succeeded: ${videoUrl}`);
|
|
||||||
|
|
||||||
const endTime = Date.now();
|
|
||||||
const generationTimeSeconds = (endTime - startTime) / 1000;
|
|
||||||
|
|
||||||
return {
|
|
||||||
videoUrl,
|
|
||||||
generationTimeSeconds,
|
|
||||||
costUsd: COST_PER_VIDEO_USD,
|
|
||||||
fullPrompt: textPrompt,
|
|
||||||
};
|
|
||||||
} else if (statusData.status === 'failed') {
|
|
||||||
const errorMsg = statusData.error?.message || 'Error desconocido';
|
|
||||||
throw new Error(`Generación de video falló: ${errorMsg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Timeout esperando generación de video (5 minutos)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download video from URL and return as Buffer
|
* Consulta el estado de un job H3 (una sola llamada, sin polling). El polling lo hace el cliente
|
||||||
|
* llamando al route de sync bajo demanda (no bloquea ninguna request de servidor).
|
||||||
|
*/
|
||||||
|
export async function getJobStatus(jobId: string): Promise<H3JobStatusResult> {
|
||||||
|
ensureConfigured();
|
||||||
|
const res = await fetch(`${H3_BASE_URL}/v1/jobs/${jobId}`, { headers: authHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Error consultando job H3 ${jobId}: ${res.status}`);
|
||||||
|
}
|
||||||
|
const job = (await res.json()) as H3JobResponse;
|
||||||
|
return {
|
||||||
|
status: job.status,
|
||||||
|
videoUrl: job.video_url,
|
||||||
|
generationTimeSeconds: job.generation_time_seconds,
|
||||||
|
error: job.error || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descarga el vídeo generado. El video_url de H3 requiere la X-API-Key.
|
||||||
*/
|
*/
|
||||||
export async function downloadVideo(url: string): Promise<Buffer> {
|
export async function downloadVideo(url: string): Promise<Buffer> {
|
||||||
const response = await fetch(url);
|
const isH3 = url.startsWith(H3_BASE_URL);
|
||||||
|
const response = await fetch(url, isH3 ? { headers: authHeaders() } : undefined);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error descargando video: ${response.status}`);
|
throw new Error(`Error descargando vídeo: ${response.status}`);
|
||||||
}
|
}
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
return Buffer.from(arrayBuffer);
|
return Buffer.from(arrayBuffer);
|
||||||
|
|||||||
@@ -216,6 +216,8 @@ export interface ReferenceAsset {
|
|||||||
emotionUrl?: string;
|
emotionUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VideoVariationStatus = 'queued' | 'processing' | 'ready' | 'error';
|
||||||
|
|
||||||
export interface VideoVariation {
|
export interface VideoVariation {
|
||||||
id: string;
|
id: string;
|
||||||
planoId: string;
|
planoId: string;
|
||||||
@@ -227,6 +229,10 @@ export interface VideoVariation {
|
|||||||
costUsd?: number;
|
costUsd?: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
// Generación async (H3): estado del job y su id remoto.
|
||||||
|
status: VideoVariationStatus;
|
||||||
|
h3JobId?: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanoConPrompt extends PlanoEscaleta {
|
export interface PlanoConPrompt extends PlanoEscaleta {
|
||||||
|
|||||||
Reference in New Issue
Block a user