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:
@@ -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`, {
|
||||
|
||||
Reference in New Issue
Block a user