/* 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.
{/* Toolbar */}
{channelFilterOpen && (
e.stopPropagation()} style={{ left: 0, top: 36, minWidth: 220, maxHeight: 320, overflowY: "auto", padding: 4, zIndex: 50, }}> {(channels || []).map((c) => ( ))}
)}
{/* Grid view */} {view === "grid" && (
{list.map((v) => ( { e.stopPropagation(); setMenuFor(menuFor === v.id ? null : v.id); }} onReview={(type) => setReviewVideo({ ...v, _reviewType: type })} onAction={handleAction}/> ))}
)} {/* List view */} {view === "list" && (
VIDEO TÍTULO CANAL ESTADO DURACIÓN
{list.map((v) => ( { e.stopPropagation(); setMenuFor(menuFor === v.id ? null : v.id); }} onReview={(type) => setReviewVideo({ ...v, _reviewType: type })} onAction={handleAction}/> ))}
)} {/* Kanban view */} {view === "kanban" && ( (e) => { e.stopPropagation(); setMenuFor(menuFor === id ? null : id); }} onReview={(v, type) => setReviewVideo({ ...v, _reviewType: type })} onAction={handleAction}/> )} {list.length === 0 && (
Sin resultados
Prueba con otros filtros o crea tu primer video.
)} {reviewVideo && ( setReviewVideo(null)} onApproved={() => { setReviewVideo(null); if (refreshJobs) refreshJobs(); }}/> )} {galleryVideo && ( setGalleryVideo(null)}/> )} {reassembleVideo && ( setReassembleVideo(null)} onDone={() => { setReassembleVideo(null); if (refreshJobs) refreshJobs(); }}/> )}
); } /* ========== Vista Kanban (columnas = canales, filas = estados) ========== */ const KANBAN_GROUPS = [ { key: "review", label: "Esperando revisión", match: (v) => ["pending_approval", "pending_image_approval", "pending_final_approval"].includes(v._rawStatus) }, { key: "active", label: "Procesando", match: (v) => ["queued", "queued_resume", "queued_render", "pending", "processing"].includes(v._rawStatus) }, { key: "ready", label: "Listo", match: (v) => v.status === "ready" }, { key: "failed", label: "Fallido", match: (v) => v.status === "failed" }, ]; function KanbanBoard({ list, channels, menuFor, onMenu, onReview, onAction }) { const activeChannels = channels.filter((c) => c.active !== false); const knownIds = new Set(activeChannels.map((c) => c.id)); const orphanVideos = list.filter((v) => !knownIds.has(v.channelId)); const columns = activeChannels.map((ch) => ({ channel: ch, videos: list.filter((v) => v.channelId === ch.id), })); if (orphanVideos.length > 0) { columns.push({ channel: null, videos: orphanVideos }); } if (columns.length === 0) { return (
Crea un canal para empezar a ver el tablero Kanban.
); } return (
{columns.map((col) => ( ))}
); } function KanbanColumn({ channel, videos, menuFor, onMenu, onReview, onAction }) { return (
{channel && channel.logo ? : (channel ? channel.initials : "?")} {channel ? channel.name : "Sin canal"} {videos.length}
{videos.length === 0 && (
Sin vídeos
)} {KANBAN_GROUPS.map((g) => { const groupVideos = videos.filter(g.match); if (groupVideos.length === 0) return null; return (
{g.label.toUpperCase()} · {groupVideos.length}
{groupVideos.map((v) => ( onReview(v, type)} onAction={onAction}/> ))}
); })}
); } /* ========== Menú de acciones ========== */ const ACTION_ITEMS = [ { id: "dl_zip", label: "Descargar todo (.zip)", Icon: IconDownload }, { id: "dl_premiere", label: "Exportar a Premiere Pro", Icon: IconClap, onlyReady: true }, { id: "divider" }, { id: "dl_video", label: "Descargar vídeo", Icon: IconDownload }, { id: "dl_audio", label: "Descargar audio", Icon: IconWave }, { id: "dl_script", label: "Descargar guion", Icon: IconScript }, { id: "dl_prompts", label: "Descargar prompts", Icon: IconScript }, { id: "view_imgs", label: "Ver imágenes", Icon: IconImage }, { id: "reassemble", label: "Remontar vídeo", Icon: IconVideo }, { id: "divider" }, { id: "retry", label: "Reintentar", Icon: IconSparkle, onlyFailed: true }, { id: "cancel", label: "Cancelar (forzar error)", Icon: IconX, onlyActive: true, danger: true }, { id: "delete", label: "Eliminar del servidor", Icon: IconTrash, danger: true }, ]; function ActionMenu({ onAction, style, isFailed, isActive, isReady, excludeIds }) { const items = ACTION_ITEMS.filter((it) => { if (excludeIds && excludeIds.includes(it.id)) return false; if (it.onlyFailed && !isFailed) return false; if (it.onlyActive && !isActive) return false; if (it.onlyReady && !isReady) return false; return true; }); return (
e.stopPropagation()} style={{ right: 8, top: 32, minWidth: 220, padding: 4, ...style, }}> {items.map((it, i) => it.id === "divider" ? (
) : ( ))}
); } const THUMB_STATUS_MAP = { ready: { bg: "rgba(22,90,50,0.55)", border: "rgba(74,222,128,0.4)", fg: "#86efac", label: "Listo", Icon: IconCheck }, processing: { bg: "rgba(66,40,140,0.55)", border: "rgba(167,139,250,0.4)", fg: "#c4b5fd", label: "Procesando", Icon: IconClock }, queued: { bg: "rgba(120,80,10,0.55)", border: "rgba(251,191,36,0.4)", fg: "#fde68a", label: "En cola", Icon: IconClock }, draft: { bg: "rgba(40,40,46,0.6)", border: "rgba(255,255,255,0.15)", fg: "#d4d4d8", label: "Borrador", Icon: IconEdit }, failed: { bg: "rgba(110,25,25,0.55)", border: "rgba(248,113,113,0.4)", fg: "#fca5a5", label: "Fallido", Icon: IconX }, }; function Thumbnail({ v, size = "normal", ch, menuOpen, onMenu, onAction, isReady, roundBottom = true }) { const chSrc = ch ? (ch.logo || ch.avatarUrl) : null; const h = size === "list" ? 60 : 158; return (
{v.thumbnailUrl ? ( ) : ( <>
{size !== "list" && (
{v.glyph}
)} )} {size !== "list" && (
)} {size !== "list" && (
{chSrc ? : (ch ? ch.initials : "?")} {ch ? ch.name : v.channelId}
{v.title}
)} {size !== "list" && (() => { const s = THUMB_STATUS_MAP[v.status] || THUMB_STATUS_MAP.draft; return ( {s.label} ); })()} {size !== "list" && v.expiresIn != null ? ( {v.expiresIn}h ) : ( {v.duration} )} {(v._rawStatus === "processing" || v._rawStatus === "pending") && (
{v.progress}%
)}
{size !== "list" && onMenu && (
{menuOpen && onAction(id, v)} isFailed={v.status === "failed"} isActive={["processing", "pending", "pending_approval", "pending_image_approval", "pending_final_approval"].includes(v._rawStatus)} isReady={isReady} excludeIds={["dl_video"]} style={{ top: 26 }}/>}
)}
); } function StatusPill({ status, errorMsg }) { const [open, setOpen] = React.useState(false); const map = { ready: { cls: "ok", label: "Listo" }, queued: { cls: "warn", label: "En cola" }, processing: { cls: "info", label: "Procesando" }, draft: { cls: "warn", label: "Borrador" }, failed: { cls: "danger", label: "Fallido" }, }; const s = map[status] || map.draft; React.useEffect(() => { if (!open) return; function onClick() { setOpen(false); } document.addEventListener("click", onClick); return () => document.removeEventListener("click", onClick); }, [open]); return ( { e.stopPropagation(); setOpen((v) => !v); } : undefined}> {s.label} {open && errorMsg && (
e.stopPropagation()}> {errorMsg}
)}
); } function CostBreakdownBadge({ jobId, align = "left" }) { const [open, setOpen] = React.useState(false); const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(false); function load() { if (data || loading) return; setLoading(true); fetch(`/api/videos/jobs/${jobId}/costs`, { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => setData(d)) .catch(() => setData({ total: 0, rows: [] })) .finally(() => setLoading(false)); } React.useEffect(() => { load(); }, [jobId]); React.useEffect(() => { if (!open) return; function onClick() { setOpen(false); } document.addEventListener("click", onClick); return () => document.removeEventListener("click", onClick); }, [open]); const grouped = React.useMemo(() => { if (!data) return []; const m = {}; for (const r of data.rows) { if (!m[r.provider]) m[r.provider] = { provider: r.provider, total: 0, calls: 0 }; m[r.provider].total += r.cost_usd || 0; m[r.provider].calls += 1; } return Object.values(m).sort((a, b) => b.total - a.total); }, [data]); return ( { e.stopPropagation(); load(); setOpen((o) => !o); }}> $ {data ? data.total.toFixed(3) : "coste"} {open && (
e.stopPropagation()}>
Desglose de gastos
{loading &&
Cargando…
} {!loading && data && data.rows.length === 0 && (
Sin gastos registrados para este vídeo.
)} {!loading && grouped.length > 0 && (
{grouped.map((g) => (
{(typeof COST_PROVIDER_LABELS !== "undefined" && COST_PROVIDER_LABELS[g.provider]) || g.provider} ${g.total.toFixed(4)}
))}
Total ${data.total.toFixed(4)}
)}
)}
); } function VideoCard({ v, channels, menuOpen, onMenu, onReview, onAction }) { const ch = channels.find((c) => c.id === v.channelId); const needsScriptReview = v._rawStatus === "pending_approval"; const needsImageReview = ["pending_image_approval", "pending_final_approval"].includes(v._rawStatus); const isFinalReview = v._rawStatus === "pending_final_approval"; const isFailed = v.status === "failed"; const isReady = v.status === "ready"; const isActive = ["processing", "pending", "pending_approval", "pending_image_approval", "pending_final_approval"].includes(v._rawStatus); const hasFooter = needsScriptReview || needsImageReview || isFailed; return (
{ e.currentTarget.style.transform = "translateY(-2px)"; e.currentTarget.style.borderColor = "var(--border-strong)"; }} onMouseLeave={(e) => { e.currentTarget.style.transform = "none"; e.currentTarget.style.borderColor = "var(--border)"; }}> {hasFooter && (
{needsScriptReview && ( )} {needsImageReview && ( )} {isFailed && ( )}
)}
); } function VideoRow({ v, channels, menuOpen, onMenu, onReview, onAction }) { const ch = channels.find((c) => c.id === v.channelId); const needsScriptReview = v._rawStatus === "pending_approval"; const needsImageReview = ["pending_image_approval", "pending_final_approval"].includes(v._rawStatus); const isFinalReview = v._rawStatus === "pending_final_approval"; const isFailed = v.status === "failed"; const isReady = v.status === "ready"; const isActive = ["processing", "pending", "pending_approval", "pending_image_approval", "pending_final_approval"].includes(v._rawStatus); return (
e.currentTarget.style.background = "var(--bg-hover)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
{v.title}
{v.ago} {needsScriptReview && ( )} {needsImageReview && ( )} {isFailed && ( )}
{ch ? ch.name : v.channelId}
{v.duration} {v.creditCost > 0 && (
{v.creditCost.toLocaleString()}
)}
{v.expiresIn != null && (
⏳ {v.expiresIn}h
)}
{menuOpen && onAction(id, v)} isFailed={isFailed} isActive={isActive} isReady={isReady}/>}
); } /* ========== Modal de revisión ========== */ function ReviewModal({ video, channels, onClose, onApproved }) { const reviewType = video._reviewType || "script"; // "script" | "images" return reviewType === "script" ? : ; } function ScriptReviewModal({ video, onClose, onApproved }) { const [script, setScript] = React.useState(""); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); React.useEffect(() => { fetch(`/api/videos/jobs/${video.id}/script`, { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => { if (d.script) setScript(d.script); setLoading(false); }) .catch(() => setLoading(false)); }, [video.id]); async function approve() { setSaving(true); await fetch(`/api/videos/jobs/${video.id}/approve`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approved: true, edited_script: script }), }); if (onApproved) onApproved(); } async function reject() { if (!confirm("¿Rechazar el guion? El job se marcará como fallido.")) return; await fetch(`/api/videos/jobs/${video.id}/approve`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approved: false }), }); if (onApproved) onApproved(); } return (
e.stopPropagation()}>

Revisar guion generado

{video.title}
Esperando aprobación
{loading ? (
Cargando guion…
) : (
{(script.trim() ? script.trim().split(/\s+/).length : 0)} palabras {script.length} caracteres