/* Tab: Producción v16 */
function IconKanbanCols(p) {
const { size = 18, stroke = 1.6 } = p || {};
return (
);
}
function _triggerDownload(url) {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function HistoryTab({ videos, channels, refreshJobs }) {
const [view, setView] = React.useState("grid");
const [sort, setSort] = React.useState("recent");
const [reviewVideo, setReviewVideo] = React.useState(null);
const [galleryVideo, setGalleryVideo] = React.useState(null);
const [menuFor, setMenuFor] = React.useState(null);
const [reassembleVideo, setReassembleVideo] = React.useState(null);
const [channelFilterOpen, setChannelFilterOpen] = React.useState(false);
const [channelFilterIds, setChannelFilterIds] = React.useState([]); // [] = todos
React.useEffect(() => {
if (!channelFilterOpen) return;
function onDoc() { setChannelFilterOpen(false); }
document.addEventListener("click", onDoc);
return () => document.removeEventListener("click", onDoc);
}, [channelFilterOpen]);
function toggleChannelFilter(id) {
setChannelFilterIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
}
// 'Creación en lista' se revisa/gestiona en su propia pestaña, no aquí — pero los
// vídeos lanzados desde 'Video Avatar' → Modo Creación (misma pipeline, launch_broll)
// sí se revisan en 'Producción', como el resto de 'Video Avatar'.
let list = videos.filter((v) => !v.launchBroll || v.videoAvatarCreacion);
if (channelFilterIds.length > 0) list = list.filter((v) => channelFilterIds.includes(v.channelId));
if (sort === "duration") list = [...list].sort((a, b) => b.durationSec - a.durationSec);
async function handleAction(actionId, v) {
switch (actionId) {
case "dl_zip":
_triggerDownload(`/api/videos/jobs/${v.id}/files/zip`);
break;
case "dl_premiere":
_triggerDownload(`/api/videos/jobs/${v.id}/export-premiere`);
break;
case "dl_video":
_triggerDownload(`/api/videos/jobs/${v.id}/download`);
break;
case "dl_audio":
_triggerDownload(`/api/videos/jobs/${v.id}/files/audio`);
break;
case "dl_script":
_triggerDownload(`/api/videos/jobs/${v.id}/files/script`);
break;
case "dl_prompts":
_triggerDownload(`/api/videos/jobs/${v.id}/files/prompts`);
break;
case "view_imgs":
setMenuFor(null); setGalleryVideo(v);
break;
case "retry":
await fetch(`/api/videos/jobs/${v.id}/retry`, { method: "POST", credentials: "same-origin" });
if (refreshJobs) refreshJobs();
break;
case "cancel":
if (!confirm("¿Cancelar este job? El proceso se detendrá.")) return;
try {
const cr = await fetch(`/api/videos/jobs/${v.id}/cancel`, { method: "POST", credentials: "same-origin" });
if (!cr.ok) {
const err = await cr.json().catch(() => ({}));
alert("No se pudo cancelar: " + (err.detail || cr.status));
}
} catch (e) { alert("Error de red al cancelar"); }
if (refreshJobs) refreshJobs();
break;
case "reassemble":
setMenuFor(null);
setReassembleVideo(v);
break;
case "delete":
if (!confirm("¿Eliminar este job y sus archivos del servidor?")) return;
await fetch(`/api/videos/jobs/${v.id}`, { method: "DELETE", credentials: "same-origin" });
if (refreshJobs) refreshJobs();
break;
}
}
// Al cerrar el menú si se hace click fuera
React.useEffect(() => {
function onClick() { setMenuFor(null); }
if (menuFor) {
document.addEventListener("click", onClick);
return () => document.removeEventListener("click", onClick);
}
}, [menuFor]);
return (
{/* 48h notice */}
Los vídeos se eliminan automáticamente a las 48 horas. Descarga el ZIP para conservarlos en tu almacenamiento local.
{sel.avatar_kind === "split"
? "Avatar hablando (InfiniTalk) en split screen — usa el audio original de esa ventana, no un prompt de movimiento."
: "Avatar hablando (InfiniTalk) — usa el audio original de esa ventana, no un prompt de movimiento."}
{errorCount > 0 ? (
⚠ Regenera las {errorCount} imagen{errorCount !== 1 ? "es" : ""} con error antes de aprobar.
) : (
{isVA && !isFinal
? "Revisa las imágenes y sus prompts de animación. Al aprobar se generarán los clips."
: isFinal
? "Revisión final: oculta lo que no quieras, activa split screen por hueco y aprueba para montar."
: "Revisa clips e imágenes. Cuando estén bien, aprueba para iniciar el montaje con FFmpeg."}
)}
);
}
/* ========== Galería de imágenes (solo lectura) ========== */
function ImageGalleryModal({ video, onClose }) {
const [images, setImages] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [selected, setSelected] = React.useState(0);
const thumbRowRef = React.useRef(null);
React.useEffect(() => {
fetch(`/api/videos/jobs/${video.id}/images`, { credentials: "same-origin" })
.then((r) => r.json())
.then((d) => { if (d.images) setImages(d.images); setLoading(false); })
.catch(() => setLoading(false));
}, [video.id]);
// Scroll the thumbnail strip so the active thumb stays visible
React.useEffect(() => {
if (!thumbRowRef.current) return;
const btn = thumbRowRef.current.children[selected];
if (btn) btn.scrollIntoView({ inline: "center", behavior: "smooth", block: "nearest" });
}, [selected]);
// Keyboard nav
React.useEffect(() => {
function onKey(e) {
if (e.key === "ArrowRight") setSelected((s) => Math.min(s + 1, images.length - 1));
if (e.key === "ArrowLeft") setSelected((s) => Math.max(s - 1, 0));
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [images.length]);
const sel = images[selected];
return (
e.stopPropagation()}>
{/* Header */}
{video.title}
{loading ? "Cargando…" : `${images.length} imágenes · usa ← → para navegar`}