Spaces:
Sleeping
Sleeping
| let currentRunId = null; | |
| let pollTimer = null; | |
| let allRuns = []; | |
| let lastRunFingerprint = null; | |
| let lastPipelinePhase = null; | |
| let cachedVideoRunId = null; | |
| let estimateDebounceTimer = null; | |
| let listItemCache = new Map(); | |
| const ACTIVE_STATUSES = new Set(["queued", "running"]); | |
| const POLL_INTERVAL_MS = 5000; | |
| const $ = (id) => document.getElementById(id); | |
| async function fetchJSON(url, options = {}) { | |
| const response = await fetch(url, options); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(data.error || `Request failed (${response.status})`); | |
| } | |
| return data; | |
| } | |
| function toast(message, type = "info") { | |
| const container = $("toast-container"); | |
| const el = document.createElement("div"); | |
| el.className = `toast toast-${type}`; | |
| el.textContent = message; | |
| container.appendChild(el); | |
| setTimeout(() => el.remove(), 4000); | |
| } | |
| function statusBadge(status) { | |
| const normalized = (status || "unknown").toLowerCase(); | |
| return `<span class="badge badge-${normalized}">${status}</span>`; | |
| } | |
| function formatDate(dateStr) { | |
| if (!dateStr) return ""; | |
| const d = new Date(dateStr.replace(" ", "T")); | |
| if (Number.isNaN(d.getTime())) return dateStr; | |
| return d.toLocaleString(undefined, { | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| } | |
| function runFingerprint(run) { | |
| const segments = (run.segments || []) | |
| .map((s) => `${s.index}:${s.status}`) | |
| .join(","); | |
| return `${run.status}|${segments}|${run.error || ""}`; | |
| } | |
| function listItemFingerprint(run) { | |
| const lang = run.settings?.output_language || ""; | |
| return `${run.status}|${lang}|${run.input_filename || run.id}|${run.created_at}`; | |
| } | |
| function computeProgress(run) { | |
| const segments = run.segments || []; | |
| if (!segments.length) { | |
| if (run.status === "queued") return { percent: 5, label: "Preparing…" }; | |
| return { percent: 0, label: "Starting…" }; | |
| } | |
| const done = segments.filter((s) => s.status === "succeeded").length; | |
| const total = segments.length; | |
| const percent = Math.round((done / total) * 100); | |
| if (run.status === "completed") return { percent: 100, label: "Done" }; | |
| if (run.status === "running") return { percent, label: `Translating… ${percent}%` }; | |
| if (run.status === "failed") return { percent, label: "Failed" }; | |
| if (run.status === "cancelled") return { percent, label: "Cancelled" }; | |
| return { percent, label: `${percent}%` }; | |
| } | |
| function pipelinePhase(run) { | |
| const status = run.status; | |
| const segments = run.segments || []; | |
| if (status === "completed") return "done"; | |
| if (!segments.length) return "split"; | |
| const allSucceeded = segments.every((s) => s.status === "succeeded"); | |
| const anyActive = segments.some((s) => | |
| ["uploading", "processing", "downloading"].includes(s.status) | |
| ); | |
| if (allSucceeded && status === "running") return "merge"; | |
| if (anyActive || segments.some((s) => s.status === "succeeded")) return "translate"; | |
| return "split"; | |
| } | |
| function updatePipeline(run) { | |
| const phase = pipelinePhase(run); | |
| if (phase === lastPipelinePhase) return; | |
| lastPipelinePhase = phase; | |
| const order = ["split", "translate", "merge", "done"]; | |
| const currentIdx = order.indexOf(phase); | |
| document.querySelectorAll(".pipeline-step").forEach((step) => { | |
| const idx = order.indexOf(step.dataset.step); | |
| step.classList.remove("active", "done"); | |
| if (idx < currentIdx) step.classList.add("done"); | |
| else if (idx === currentIdx) step.classList.add("active"); | |
| }); | |
| document.querySelectorAll(".pipeline-line").forEach((line, i) => { | |
| line.classList.toggle("done", i < currentIdx); | |
| }); | |
| } | |
| function renderEstimatePanel(estimate) { | |
| const panel = $("estimate-panel"); | |
| if (!estimate) { | |
| panel.classList.add("hidden"); | |
| return; | |
| } | |
| $("estimate-segments").textContent = estimate.segments; | |
| $("estimate-time").textContent = estimate.estimated_time_label; | |
| $("estimate-cost").textContent = `~$${estimate.estimated_cost_usd}`; | |
| $("estimate-disclaimer").textContent = estimate.disclaimer || ""; | |
| panel.classList.remove("hidden"); | |
| } | |
| function renderRunEstimate(run) { | |
| const panel = $("run-estimate"); | |
| if (!run.estimate) { | |
| panel.classList.add("hidden"); | |
| return; | |
| } | |
| $("run-estimate-segments").textContent = run.estimate.segments; | |
| $("run-estimate-time").textContent = run.estimate.estimated_time_label; | |
| $("run-estimate-cost").textContent = `~$${run.estimate.estimated_cost_usd}`; | |
| panel.classList.remove("hidden"); | |
| } | |
| function getVideoDuration(file) { | |
| return new Promise((resolve) => { | |
| const video = document.createElement("video"); | |
| video.preload = "metadata"; | |
| video.onloadedmetadata = () => { | |
| URL.revokeObjectURL(video.src); | |
| resolve(Number.isFinite(video.duration) ? video.duration : null); | |
| }; | |
| video.onerror = () => { | |
| URL.revokeObjectURL(video.src); | |
| resolve(null); | |
| }; | |
| video.src = URL.createObjectURL(file); | |
| }); | |
| } | |
| async function updateFileEstimate(file) { | |
| if (!file) { | |
| renderEstimatePanel(null); | |
| return; | |
| } | |
| const duration = await getVideoDuration(file); | |
| if (!duration) { | |
| renderEstimatePanel(null); | |
| return; | |
| } | |
| const mode = $("quality-mode").value; | |
| try { | |
| const estimate = await fetchJSON( | |
| `/api/estimate?duration_seconds=${encodeURIComponent(duration)}&mode=${encodeURIComponent(mode)}` | |
| ); | |
| renderEstimatePanel(estimate); | |
| } catch { | |
| renderEstimatePanel(null); | |
| } | |
| } | |
| function scheduleEstimateUpdate(file) { | |
| clearTimeout(estimateDebounceTimer); | |
| estimateDebounceTimer = setTimeout(() => updateFileEstimate(file), 350); | |
| } | |
| function setMessage(message) { | |
| const node = $("run-message"); | |
| if (node.textContent !== message) node.textContent = message; | |
| } | |
| function setSubmitLoading(loading) { | |
| const btn = $("submit-btn"); | |
| btn.disabled = loading; | |
| btn.querySelector(".btn-text").textContent = loading ? "Starting…" : "Start Translation"; | |
| btn.querySelector(".btn-spinner").classList.toggle("hidden", !loading); | |
| } | |
| function bindRunListItem(li, run) { | |
| li.dataset.runId = run.id; | |
| li.addEventListener("click", () => selectRun(run.id)); | |
| li.querySelector(".run-delete-btn").addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| deleteRun(run.id); | |
| }); | |
| listItemCache.set(run.id, li); | |
| } | |
| function updateRunListItem(li, run) { | |
| const filename = run.input_filename || run.id; | |
| const lang = run.settings?.output_language || ""; | |
| const isActive = run.id === currentRunId; | |
| li.classList.toggle("active", isActive); | |
| li.querySelector(".run-item-name").textContent = filename; | |
| li.querySelector(".run-item-name").title = filename; | |
| li.querySelector(".run-item-meta").textContent = `${lang}${lang ? " · " : ""}${formatDate(run.created_at)}`; | |
| li.querySelector(".run-item-badge").innerHTML = statusBadge(run.status); | |
| } | |
| function createRunListItem(run) { | |
| const li = document.createElement("li"); | |
| li.className = "run-item"; | |
| const filename = run.input_filename || run.id; | |
| const lang = run.settings?.output_language || ""; | |
| li.innerHTML = ` | |
| <div class="run-item-info"> | |
| <div class="run-item-name" title="${filename}">${filename}</div> | |
| <div class="run-item-meta">${lang}${lang ? " · " : ""}${formatDate(run.created_at)}</div> | |
| </div> | |
| <div class="run-item-actions"> | |
| <span class="run-item-badge">${statusBadge(run.status)}</span> | |
| <button type="button" class="btn btn-ghost btn-sm run-delete-btn" title="Delete">✕</button> | |
| </div> | |
| `; | |
| bindRunListItem(li, run); | |
| updateRunListItem(li, run); | |
| return li; | |
| } | |
| function renderRunList(runs, { force = false } = {}) { | |
| allRuns = runs; | |
| const list = $("run-list"); | |
| const runIds = new Set(runs.map((r) => r.id)); | |
| if (!runs.length) { | |
| list.innerHTML = '<li class="run-list-empty muted">No translations yet.</li>'; | |
| listItemCache.clear(); | |
| return; | |
| } | |
| const empty = list.querySelector(".run-list-empty"); | |
| if (empty) empty.remove(); | |
| if (force || listItemCache.size === 0) { | |
| list.innerHTML = ""; | |
| listItemCache.clear(); | |
| for (const run of runs) { | |
| list.appendChild(createRunListItem(run)); | |
| } | |
| return; | |
| } | |
| for (const [id, li] of listItemCache.entries()) { | |
| if (!runIds.has(id)) { | |
| li.remove(); | |
| listItemCache.delete(id); | |
| } | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| for (const run of runs) { | |
| let li = listItemCache.get(run.id); | |
| const fp = listItemFingerprint(run); | |
| const prevFp = li?.dataset.fp; | |
| if (!li) { | |
| li = createRunListItem(run); | |
| fragment.appendChild(li); | |
| } else if (force || fp !== prevFp || run.id === currentRunId) { | |
| updateRunListItem(li, run); | |
| } | |
| li.dataset.fp = fp; | |
| } | |
| if (fragment.childNodes.length) list.appendChild(fragment); | |
| const ordered = runs.map((r) => listItemCache.get(r.id)).filter(Boolean); | |
| for (const li of ordered) { | |
| if (li.parentNode === list && list.lastElementChild !== li) { | |
| list.appendChild(li); | |
| } | |
| } | |
| } | |
| function updateVideoPreview(run) { | |
| const previewWrap = $("video-preview-wrap"); | |
| const video = $("video-preview"); | |
| if (run.status === "completed") { | |
| previewWrap.classList.remove("hidden"); | |
| if (cachedVideoRunId !== run.id) { | |
| video.src = `/api/runs/${run.id}/output`; | |
| cachedVideoRunId = run.id; | |
| } | |
| return; | |
| } | |
| previewWrap.classList.add("hidden"); | |
| if (cachedVideoRunId !== null) { | |
| video.removeAttribute("src"); | |
| video.load(); | |
| cachedVideoRunId = null; | |
| } | |
| } | |
| function renderRunDetail(run, { force = false } = {}) { | |
| const fingerprint = runFingerprint(run); | |
| if (!force && fingerprint === lastRunFingerprint && run.id === currentRunId) { | |
| return; | |
| } | |
| lastRunFingerprint = fingerprint; | |
| $("run-overview").classList.remove("hidden"); | |
| $("active-run-title").textContent = run.input_filename || "Your translation"; | |
| $("meta-status").innerHTML = statusBadge(run.status); | |
| const progress = computeProgress(run); | |
| $("progress-fill").style.width = `${progress.percent}%`; | |
| $("progress-label").textContent = progress.label; | |
| const progressBar = $("progress-bar"); | |
| if (progressBar) progressBar.setAttribute("aria-valuenow", progress.percent); | |
| updatePipeline(run); | |
| renderRunEstimate(run); | |
| const errorEl = $("run-error"); | |
| const showError = Boolean(run.error && run.status === "failed"); | |
| errorEl.classList.toggle("hidden", !showError); | |
| if (showError) errorEl.textContent = run.error; | |
| const lang = run.settings?.output_language || "another language"; | |
| const messages = { | |
| completed: `Translated to ${lang}.`, | |
| running: `Translating to ${lang}…`, | |
| failed: "Something went wrong. You can retry.", | |
| cancelled: "Translation cancelled.", | |
| }; | |
| setMessage(messages[run.status] || "Waiting to start…"); | |
| const failedCount = (run.segments || []).filter((s) => s.status === "failed").length; | |
| $("actions").classList.remove("hidden"); | |
| $("cancel-btn").classList.toggle("hidden", !ACTIVE_STATUSES.has(run.status)); | |
| $("retry-btn").classList.toggle("hidden", failedCount === 0 || run.status === "running"); | |
| $("download-btn").classList.toggle("hidden", run.status !== "completed"); | |
| updateVideoPreview(run); | |
| } | |
| function renderRun(run, { force = false } = {}) { | |
| const runChanged = run.id !== currentRunId; | |
| currentRunId = run.id; | |
| if (runChanged) { | |
| lastRunFingerprint = null; | |
| lastPipelinePhase = null; | |
| } | |
| renderRunList(allRuns, { force: force || runChanged }); | |
| renderRunDetail(run, { force: force || runChanged }); | |
| managePolling(run); | |
| } | |
| function needsPolling(run) { | |
| return ( | |
| ACTIVE_STATUSES.has(run.status) || | |
| (run.status === "failed" && | |
| run.segments?.some((s) => ["uploading", "processing", "downloading"].includes(s.status))) | |
| ); | |
| } | |
| function stopPolling() { | |
| if (pollTimer) { | |
| clearInterval(pollTimer); | |
| pollTimer = null; | |
| } | |
| } | |
| function startPolling() { | |
| if (pollTimer || document.hidden) return; | |
| pollTimer = setInterval(refreshRun, POLL_INTERVAL_MS); | |
| } | |
| function managePolling(run) { | |
| if (needsPolling(run)) startPolling(); | |
| else stopPolling(); | |
| } | |
| async function refreshRun() { | |
| if (!currentRunId || document.hidden) return; | |
| try { | |
| const run = await fetchJSON(`/api/runs/${currentRunId}`); | |
| const idx = allRuns.findIndex((r) => r.id === run.id); | |
| if (idx >= 0) allRuns[idx] = run; | |
| else allRuns.unshift(run); | |
| renderRun(run); | |
| } catch (error) { | |
| setMessage(`Failed to refresh: ${error.message}`); | |
| } | |
| } | |
| async function selectRun(runId) { | |
| if (runId === currentRunId) return; | |
| currentRunId = runId; | |
| lastRunFingerprint = null; | |
| lastPipelinePhase = null; | |
| try { | |
| const run = await fetchJSON(`/api/runs/${runId}`); | |
| renderRun(run, { force: true }); | |
| } catch (error) { | |
| toast(error.message, "error"); | |
| } | |
| } | |
| async function refreshHealth() { | |
| const node = $("health-pills"); | |
| try { | |
| const health = await fetchJSON("/api/health"); | |
| if (!health.token_present || !health.ffmpeg_present) { | |
| node.innerHTML = `<span class="pill bad">Setup incomplete — check API token and ffmpeg</span>`; | |
| toast("Fix API token or ffmpeg before translating.", "error"); | |
| } else { | |
| node.innerHTML = ""; | |
| } | |
| } catch { | |
| node.innerHTML = `<span class="pill bad">Server unavailable</span>`; | |
| } | |
| } | |
| function setupDropZone() { | |
| const zone = $("drop-zone"); | |
| const input = $("video-input"); | |
| const fileName = $("file-name"); | |
| function showFile(file) { | |
| if (!file) return; | |
| fileName.textContent = `${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`; | |
| fileName.classList.remove("hidden"); | |
| zone.classList.add("dragover"); | |
| scheduleEstimateUpdate(file); | |
| } | |
| zone.addEventListener("click", () => input.click()); | |
| zone.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| input.click(); | |
| } | |
| }); | |
| input.addEventListener("change", () => { | |
| if (input.files[0]) showFile(input.files[0]); | |
| }); | |
| zone.addEventListener("dragover", (e) => { | |
| e.preventDefault(); | |
| zone.classList.add("dragover"); | |
| }); | |
| zone.addEventListener("dragleave", () => zone.classList.remove("dragover")); | |
| zone.addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| zone.classList.remove("dragover"); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type.startsWith("video/")) { | |
| const dt = new DataTransfer(); | |
| dt.items.add(file); | |
| input.files = dt.files; | |
| showFile(file); | |
| } else { | |
| toast("Please drop a video file.", "error"); | |
| } | |
| }); | |
| } | |
| function setupLanguageSelect() { | |
| $("output-language").addEventListener("change", () => { | |
| const isCustom = $("output-language").value === "custom"; | |
| $("custom-language-wrap").classList.toggle("hidden", !isCustom); | |
| }); | |
| $("quality-mode").addEventListener("change", () => { | |
| const file = $("video-input").files[0]; | |
| if (file) scheduleEstimateUpdate(file); | |
| }); | |
| } | |
| function setupVisibilityHandling() { | |
| document.addEventListener("visibilitychange", () => { | |
| if (document.hidden) { | |
| stopPolling(); | |
| return; | |
| } | |
| const current = allRuns.find((r) => r.id === currentRunId); | |
| if (current && needsPolling(current)) { | |
| refreshRun(); | |
| startPolling(); | |
| } | |
| }); | |
| } | |
| async function startRun(event) { | |
| event.preventDefault(); | |
| const data = new FormData(event.target); | |
| if ($("output-language").value === "custom") { | |
| const custom = $("custom-language").value.trim(); | |
| if (!custom) { | |
| toast("Enter a language name.", "error"); | |
| return; | |
| } | |
| data.set("output_language", custom); | |
| } | |
| if (!data.get("video")?.name) { | |
| toast("Choose a video first.", "error"); | |
| return; | |
| } | |
| setSubmitLoading(true); | |
| try { | |
| const run = await fetchJSON("/api/runs", { method: "POST", body: data }); | |
| const idx = allRuns.findIndex((r) => r.id === run.id); | |
| if (idx >= 0) allRuns[idx] = run; | |
| else allRuns.unshift(run); | |
| renderRun(run, { force: true }); | |
| toast("Translation started.", "success"); | |
| startPolling(); | |
| } catch (error) { | |
| toast(error.message, "error"); | |
| } finally { | |
| setSubmitLoading(false); | |
| } | |
| } | |
| async function retryFailed() { | |
| if (!currentRunId) return; | |
| try { | |
| const run = await fetchJSON(`/api/runs/${currentRunId}/retry`, { method: "POST" }); | |
| const idx = allRuns.findIndex((r) => r.id === run.id); | |
| if (idx >= 0) allRuns[idx] = run; | |
| renderRun(run, { force: true }); | |
| toast("Retrying…", "success"); | |
| startPolling(); | |
| } catch (error) { | |
| toast(error.message, "error"); | |
| } | |
| } | |
| function downloadOutput() { | |
| if (!currentRunId) return; | |
| window.location.href = `/api/runs/${currentRunId}/download`; | |
| } | |
| async function cancelRun() { | |
| if (!currentRunId || !window.confirm("Cancel this translation?")) return; | |
| try { | |
| const run = await fetchJSON(`/api/runs/${currentRunId}/cancel`, { method: "POST" }); | |
| const idx = allRuns.findIndex((r) => r.id === run.id); | |
| if (idx >= 0) allRuns[idx] = run; | |
| renderRun(run, { force: true }); | |
| toast("Cancelled.", "success"); | |
| } catch (error) { | |
| toast(error.message, "error"); | |
| } | |
| } | |
| async function deleteRun(runId = currentRunId) { | |
| if (!runId || !window.confirm("Delete this translation and its files?")) return; | |
| try { | |
| await fetchJSON(`/api/runs/${runId}`, { method: "DELETE" }); | |
| allRuns = allRuns.filter((run) => run.id !== runId); | |
| listItemCache.delete(runId); | |
| if (currentRunId === runId) { | |
| currentRunId = null; | |
| lastRunFingerprint = null; | |
| lastPipelinePhase = null; | |
| cachedVideoRunId = null; | |
| $("run-overview").classList.add("hidden"); | |
| setMessage("Upload a video to get started."); | |
| stopPolling(); | |
| } | |
| renderRunList(allRuns, { force: true }); | |
| if (!currentRunId && allRuns.length > 0) await selectRun(allRuns[0].id); | |
| toast("Deleted.", "success"); | |
| } catch (error) { | |
| toast(error.message, "error"); | |
| } | |
| } | |
| async function init() { | |
| setupDropZone(); | |
| setupLanguageSelect(); | |
| setupVisibilityHandling(); | |
| await refreshHealth(); | |
| $("run-form").addEventListener("submit", startRun); | |
| $("cancel-btn").addEventListener("click", cancelRun); | |
| $("retry-btn").addEventListener("click", retryFailed); | |
| $("download-btn").addEventListener("click", downloadOutput); | |
| try { | |
| const { runs } = await fetchJSON("/api/runs"); | |
| allRuns = runs; | |
| renderRunList(runs, { force: true }); | |
| if (runs.length > 0) renderRun(runs[0], { force: true }); | |
| } catch (error) { | |
| setMessage(`Failed to load: ${error.message}`); | |
| } | |
| } | |
| init(); | |