feat(video): generación de vídeo H3 async (no bloqueante) con estados queued→ready

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>
This commit is contained in:
Carlos Narro
2026-08-12 12:18:51 +02:00
parent 659edfa68e
commit f58ab93646
7 changed files with 367 additions and 196 deletions

View File

@@ -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 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`, {