Spaces:
Sleeping
Sleeping
| /* ============================================================ | |
| Viet AutoSub Editor – Dashboard JavaScript | |
| Tương thích cả offline (file://) và online (HF Spaces) | |
| ============================================================ */ | |
| const state = { | |
| jobId: null, | |
| file: null, | |
| segments: [], | |
| isOnline: false, // server reachable? | |
| }; | |
| /* --- Detect environment ------------------------------------ */ | |
| const IS_FILE_PROTOCOL = window.location.protocol === "file:"; | |
| /** | |
| * Determine the API base URL. | |
| * - file:// → cannot reach backend at all | |
| * - http(s):// → use same origin (relative paths) | |
| */ | |
| function getApiBase() { | |
| if (IS_FILE_PROTOCOL) return null; | |
| return ""; // same-origin: "/api/transcribe" etc. | |
| } | |
| /* --- DOM refs ---------------------------------------------- */ | |
| const $ = (id) => document.getElementById(id); | |
| const els = { | |
| fileInput: $("videoFile"), | |
| preview: $("preview"), | |
| videoPlaceholder:$("videoPlaceholder"), | |
| status: $("status"), | |
| statusText: $("statusText"), | |
| btnTranscribe: $("btnTranscribe"), | |
| btnAddRow: $("btnAddRow"), | |
| btnExportSrt: $("btnExportSrt"), | |
| btnExportMp4: $("btnExportMp4"), | |
| btnClearFile: $("btnClearFile"), | |
| subtitleBody: $("subtitleBody"), | |
| segmentCount: $("segmentCount"), | |
| downloadSrt: $("downloadSrt"), | |
| downloadMp4: $("downloadMp4"), | |
| downloadGroup: $("downloadGroup"), | |
| dropZone: $("dropZone"), | |
| uploadPanel: $("uploadPanel"), | |
| fileInfo: $("fileInfo"), | |
| fileName: $("fileName"), | |
| fileSize: $("fileSize"), | |
| progressWrap: $("progressWrap"), | |
| progressFill: $("progressFill"), | |
| progressText: $("progressText"), | |
| // New: offline banner + badge | |
| offlineBanner: $("offlineBanner"), | |
| offlineBannerText: $("offlineBannerText"), | |
| offlineBannerClose:$("offlineBannerClose"), | |
| badgeEnv: $("badgeEnv"), | |
| badgeEnvText: $("badgeEnvText"), | |
| pulseDot: $("pulseDot"), | |
| }; | |
| /* --- Health check ------------------------------------------ */ | |
| let healthRetryTimer = null; | |
| async function checkHealth() { | |
| // file:// → always offline | |
| if (IS_FILE_PROTOCOL) { | |
| setOnlineState(false, "Offline (file://)"); | |
| return; | |
| } | |
| try { | |
| const res = await fetch("/health", { method: "GET", cache: "no-store" }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setOnlineState(true, "HF Space"); | |
| // Stop retrying | |
| if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; } | |
| } else { | |
| setOnlineState(false, "Server lỗi"); | |
| } | |
| } catch (_) { | |
| setOnlineState(false, "Không kết nối"); | |
| } | |
| } | |
| function setOnlineState(online, label) { | |
| state.isOnline = online; | |
| // Badge | |
| if (els.badgeEnv) { | |
| els.badgeEnv.classList.toggle("badge-online", online); | |
| els.badgeEnv.classList.toggle("badge-offline", !online); | |
| } | |
| if (els.badgeEnvText) { | |
| els.badgeEnvText.textContent = label || (online ? "Online" : "Offline"); | |
| } | |
| if (els.pulseDot) { | |
| els.pulseDot.className = online ? "pulse-dot pulse-online" : "pulse-dot pulse-offline"; | |
| } | |
| // Offline banner | |
| if (!online) { | |
| if (els.offlineBanner) els.offlineBanner.hidden = false; | |
| if (els.offlineBannerText) { | |
| els.offlineBannerText.textContent = IS_FILE_PROTOCOL | |
| ? "Đang chạy offline (file://) — Bạn có thể sửa subtitle và xuất SRT. Auto sub & xuất MP4 cần deploy lên HF Space." | |
| : "Không kết nối được server — Đang thử lại mỗi 30 giây..."; | |
| } | |
| // Auto-retry every 30s if on http but server is down | |
| if (!IS_FILE_PROTOCOL && !healthRetryTimer) { | |
| healthRetryTimer = setInterval(checkHealth, 30000); | |
| } | |
| } else { | |
| if (els.offlineBanner) els.offlineBanner.hidden = true; | |
| if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; } | |
| } | |
| } | |
| // Close banner button | |
| if (els.offlineBannerClose) { | |
| els.offlineBannerClose.addEventListener("click", () => { | |
| if (els.offlineBanner) els.offlineBanner.hidden = true; | |
| }); | |
| } | |
| /* --- Steps ------------------------------------------------- */ | |
| function setStep(num) { | |
| document.querySelectorAll(".step").forEach((el) => { | |
| const s = parseInt(el.dataset.step, 10); | |
| el.classList.toggle("active", s === num); | |
| el.classList.toggle("done", s < num); | |
| }); | |
| } | |
| /* --- Status ------------------------------------------------ */ | |
| function setStatus(message, type = "idle") { | |
| els.status.className = `status-box status-${type}`; | |
| els.statusText.textContent = message; | |
| } | |
| /* --- Buttons state ----------------------------------------- */ | |
| function setEditButtons(enabled) { | |
| els.btnAddRow.disabled = !enabled; | |
| els.btnExportSrt.disabled = !enabled; | |
| els.btnExportMp4.disabled = !enabled; | |
| } | |
| /* --- Download link helpers --------------------------------- */ | |
| function showDownload(el, url, visible) { | |
| el.href = visible ? url : "#"; | |
| el.classList.toggle("disabled", !visible); | |
| } | |
| function showDownloadGroup(show) { | |
| els.downloadGroup.hidden = !show; | |
| } | |
| /* --- Progress simulation ----------------------------------- */ | |
| let progressTimer = null; | |
| function startProgress(label) { | |
| els.progressWrap.hidden = false; | |
| els.progressFill.style.width = "0%"; | |
| els.progressText.textContent = label || "Đang xử lý..."; | |
| let pct = 0; | |
| clearInterval(progressTimer); | |
| progressTimer = setInterval(() => { | |
| const remaining = 90 - pct; | |
| const step = Math.max(0.3, remaining * 0.04); | |
| pct = Math.min(90, pct + step); | |
| els.progressFill.style.width = pct + "%"; | |
| }, 300); | |
| } | |
| function finishProgress() { | |
| clearInterval(progressTimer); | |
| els.progressFill.style.width = "100%"; | |
| setTimeout(() => { | |
| els.progressWrap.hidden = true; | |
| els.progressFill.style.width = "0%"; | |
| }, 600); | |
| } | |
| function cancelProgress() { | |
| clearInterval(progressTimer); | |
| els.progressWrap.hidden = true; | |
| els.progressFill.style.width = "0%"; | |
| } | |
| /* --- File size formatter ----------------------------------- */ | |
| function formatSize(bytes) { | |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; | |
| return (bytes / (1024 * 1024)).toFixed(1) + " MB"; | |
| } | |
| /* --- Create table cell inputs ------------------------------ */ | |
| function createInput(value, className) { | |
| const input = document.createElement("input"); | |
| input.type = "text"; | |
| input.value = value || ""; | |
| input.className = className; | |
| input.spellcheck = false; | |
| return input; | |
| } | |
| function createTextArea(value) { | |
| const textarea = document.createElement("textarea"); | |
| textarea.value = value || ""; | |
| textarea.rows = 2; | |
| textarea.className = "text-input"; | |
| return textarea; | |
| } | |
| /* --- Collect segments from table --------------------------- */ | |
| function collectSegmentsFromTable() { | |
| const rows = Array.from(els.subtitleBody.querySelectorAll("tr[data-row='1']")); | |
| return rows.map((row, index) => ({ | |
| id: index + 1, | |
| start: row.querySelector(".start-input").value.trim(), | |
| end: row.querySelector(".end-input").value.trim(), | |
| text: row.querySelector(".text-input").value.trim(), | |
| })); | |
| } | |
| /* --- Render table ------------------------------------------ */ | |
| function renderTable() { | |
| els.subtitleBody.innerHTML = ""; | |
| if (!state.segments.length) { | |
| els.subtitleBody.innerHTML = ` | |
| <tr class="empty-row"> | |
| <td colspan="5"> | |
| <div class="empty-state"> | |
| <svg viewBox="0 0 48 48" fill="none" class="empty-icon"> | |
| <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/> | |
| <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/> | |
| <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/> | |
| <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/> | |
| </svg> | |
| <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p> | |
| </div> | |
| </td> | |
| </tr>`; | |
| els.segmentCount.textContent = "0 dòng"; | |
| setEditButtons(false); | |
| return; | |
| } | |
| state.segments.forEach((seg, index) => { | |
| const tr = document.createElement("tr"); | |
| tr.dataset.row = "1"; | |
| // # | |
| const tdIdx = document.createElement("td"); | |
| tdIdx.className = "idx-cell"; | |
| tdIdx.textContent = String(index + 1); | |
| // Start | |
| const tdStart = document.createElement("td"); | |
| tdStart.appendChild(createInput(seg.start, "start-input time-input")); | |
| // End | |
| const tdEnd = document.createElement("td"); | |
| tdEnd.appendChild(createInput(seg.end, "end-input time-input")); | |
| // Text | |
| const tdText = document.createElement("td"); | |
| tdText.appendChild(createTextArea(seg.text)); | |
| // Delete | |
| const tdAct = document.createElement("td"); | |
| tdAct.style.textAlign = "center"; | |
| const delBtn = document.createElement("button"); | |
| delBtn.className = "btn btn-danger-sm"; | |
| delBtn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>`; | |
| delBtn.title = "Xóa dòng"; | |
| delBtn.addEventListener("click", () => { | |
| state.segments = collectSegmentsFromTable(); | |
| state.segments.splice(index, 1); | |
| renderTable(); | |
| }); | |
| tdAct.appendChild(delBtn); | |
| tr.append(tdIdx, tdStart, tdEnd, tdText, tdAct); | |
| els.subtitleBody.appendChild(tr); | |
| }); | |
| els.segmentCount.textContent = `${state.segments.length} dòng`; | |
| setEditButtons(true); | |
| } | |
| /* --- Transcribe -------------------------------------------- */ | |
| async function transcribeVideo() { | |
| if (!state.file) { | |
| setStatus("Hãy chọn video trước.", "error"); | |
| return; | |
| } | |
| // Offline guard | |
| if (!state.isOnline) { | |
| setStatus( | |
| IS_FILE_PROTOCOL | |
| ? "Đang offline — Auto sub cần chạy trên HF Space (server). Hãy upload ứng dụng lên HF Space trước." | |
| : "Server không phản hồi. Đang thử kết nối lại...", | |
| "error" | |
| ); | |
| if (!IS_FILE_PROTOCOL) checkHealth(); | |
| return; | |
| } | |
| const fd = new FormData(); | |
| fd.append("file", state.file); | |
| els.btnTranscribe.disabled = true; | |
| els.btnTranscribe.classList.add("btn-loading"); | |
| setStatus("Đang nhận diện lời nói tiếng Việt...", "loading"); | |
| setStep(2); | |
| startProgress("Đang upload và nhận diện giọng nói..."); | |
| showDownload(els.downloadSrt, "#", false); | |
| showDownload(els.downloadMp4, "#", false); | |
| showDownloadGroup(false); | |
| try { | |
| const res = await fetch("/api/transcribe", { | |
| method: "POST", | |
| body: fd, | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || "Không thể nhận diện subtitle."); | |
| state.jobId = data.job_id; | |
| state.segments = data.segments || []; | |
| renderTable(); | |
| finishProgress(); | |
| setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng subtitle.`, "success"); | |
| setStep(3); | |
| } catch (err) { | |
| cancelProgress(); | |
| const msg = err.message.includes("Failed to fetch") | |
| ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space." | |
| : (err.message || "Có lỗi khi auto sub."); | |
| setStatus(msg, "error"); | |
| setStep(1); | |
| // Re-check health | |
| checkHealth(); | |
| } finally { | |
| els.btnTranscribe.disabled = false; | |
| els.btnTranscribe.classList.remove("btn-loading"); | |
| } | |
| } | |
| /* --- Client-side SRT generation (offline-capable) ---------- */ | |
| function generateSrtString(segments) { | |
| let lines = []; | |
| segments.forEach((seg, idx) => { | |
| const start = seg.start || "00:00:00,000"; | |
| const end = seg.end || "00:00:02,000"; | |
| const text = (seg.text || "").trim(); | |
| if (!text) return; | |
| lines.push(String(idx + 1)); | |
| lines.push(`${start} --> ${end}`); | |
| lines.push(text); | |
| lines.push(""); | |
| }); | |
| return lines.join("\n"); | |
| } | |
| function downloadSrtOffline() { | |
| const segments = collectSegmentsFromTable(); | |
| if (!segments.length) { | |
| setStatus("Chưa có subtitle để xuất.", "error"); | |
| return; | |
| } | |
| const srtContent = generateSrtString(segments); | |
| const blob = new Blob([srtContent], { type: "text/plain;charset=utf-8" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "subtitle.srt"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| setStatus("Đã xuất file SRT thành công (offline).", "success"); | |
| setStep(4); | |
| } | |
| /* --- Export ------------------------------------------------- */ | |
| async function exportResult(burnIn) { | |
| // If offline or no jobId and requesting SRT only → use client-side export | |
| if (!burnIn && (!state.isOnline || !state.jobId)) { | |
| downloadSrtOffline(); | |
| return; | |
| } | |
| // MP4 burn-in requires server | |
| if (burnIn && !state.isOnline) { | |
| setStatus( | |
| IS_FILE_PROTOCOL | |
| ? "Xuất MP4 burn sub cần server HF Space. Hãy deploy ứng dụng lên HF Space trước." | |
| : "Server không phản hồi. Xuất MP4 cần kết nối server.", | |
| "error" | |
| ); | |
| return; | |
| } | |
| if (!state.jobId) { | |
| setStatus("Chưa có job để xuất file. Hãy bấm Auto sub trước.", "error"); | |
| return; | |
| } | |
| // Collect latest from table | |
| const payload = { | |
| job_id: state.jobId, | |
| burn_in: burnIn, | |
| segments: collectSegmentsFromTable(), | |
| }; | |
| const label = burnIn ? "Đang xuất MP4 có sub..." : "Đang tạo file SRT..."; | |
| setStatus(label, "loading"); | |
| startProgress(label); | |
| setStep(4); | |
| els.btnExportSrt.disabled = true; | |
| els.btnExportMp4.disabled = true; | |
| try { | |
| const res = await fetch("/api/export", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || "Xuất file thất bại."); | |
| finishProgress(); | |
| showDownloadGroup(true); | |
| showDownload(els.downloadSrt, data.srt_url, true); | |
| if (data.mp4_url) { | |
| showDownload(els.downloadMp4, data.mp4_url, true); | |
| } | |
| const msg = burnIn | |
| ? `Xuất MP4 thành công${data.mp4_size_mb ? ` (${data.mp4_size_mb} MB)` : ""}.` | |
| : "Đã tạo file SRT thành công."; | |
| setStatus(msg, "success"); | |
| } catch (err) { | |
| cancelProgress(); | |
| const msg = err.message.includes("Failed to fetch") | |
| ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space." | |
| : (err.message || "Có lỗi khi xuất file."); | |
| setStatus(msg, "error"); | |
| checkHealth(); | |
| } finally { | |
| setEditButtons(true); | |
| } | |
| } | |
| /* --- File selection ---------------------------------------- */ | |
| function handleFile(file) { | |
| if (!file) return; | |
| state.file = file; | |
| state.jobId = null; | |
| state.segments = []; | |
| renderTable(); | |
| showDownload(els.downloadSrt, "#", false); | |
| showDownload(els.downloadMp4, "#", false); | |
| showDownloadGroup(false); | |
| // Show video preview | |
| const url = URL.createObjectURL(file); | |
| els.preview.src = url; | |
| els.preview.classList.add("has-src"); | |
| els.videoPlaceholder.classList.add("hidden"); | |
| // Show file info | |
| els.fileInfo.hidden = false; | |
| els.fileName.textContent = file.name; | |
| els.fileSize.textContent = formatSize(file.size); | |
| setStatus(`Đã chọn: ${file.name}`, "idle"); | |
| setStep(1); | |
| } | |
| function clearFile() { | |
| state.file = null; | |
| state.jobId = null; | |
| state.segments = []; | |
| renderTable(); | |
| els.preview.removeAttribute("src"); | |
| els.preview.classList.remove("has-src"); | |
| els.videoPlaceholder.classList.remove("hidden"); | |
| els.fileInfo.hidden = true; | |
| els.fileInput.value = ""; | |
| showDownloadGroup(false); | |
| setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle"); | |
| setStep(1); | |
| } | |
| /* --- Event listeners --------------------------------------- */ | |
| // File input | |
| els.fileInput.addEventListener("change", (e) => { | |
| const [file] = e.target.files || []; | |
| if (file) handleFile(file); | |
| }); | |
| // Click on drop zone | |
| els.dropZone.addEventListener("click", () => els.fileInput.click()); | |
| // Drag & drop | |
| els.dropZone.addEventListener("dragover", (e) => { | |
| e.preventDefault(); | |
| els.dropZone.classList.add("drag-over"); | |
| }); | |
| els.dropZone.addEventListener("dragleave", () => { | |
| els.dropZone.classList.remove("drag-over"); | |
| }); | |
| els.dropZone.addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| els.dropZone.classList.remove("drag-over"); | |
| const file = e.dataTransfer.files[0]; | |
| if (file) { | |
| const dt = new DataTransfer(); | |
| dt.items.add(file); | |
| els.fileInput.files = dt.files; | |
| handleFile(file); | |
| } | |
| }); | |
| // Clear file | |
| els.btnClearFile.addEventListener("click", clearFile); | |
| // Transcribe | |
| els.btnTranscribe.addEventListener("click", transcribeVideo); | |
| // Export | |
| els.btnExportSrt.addEventListener("click", () => exportResult(false)); | |
| els.btnExportMp4.addEventListener("click", () => exportResult(true)); | |
| // Add row | |
| els.btnAddRow.addEventListener("click", () => { | |
| state.segments = collectSegmentsFromTable(); | |
| state.segments.push({ | |
| id: state.segments.length + 1, | |
| start: "00:00:00,000", | |
| end: "00:00:02,000", | |
| text: "Subtitle mới", | |
| }); | |
| renderTable(); | |
| const scroll = $("tableScroll"); | |
| if (scroll) scroll.scrollTop = scroll.scrollHeight; | |
| }); | |
| // Collapse table toggle | |
| const btnCollapse = $("btnCollapseTable"); | |
| const tableScroll = $("tableScroll"); | |
| if (btnCollapse && tableScroll) { | |
| btnCollapse.addEventListener("click", () => { | |
| const collapsed = tableScroll.style.display === "none"; | |
| tableScroll.style.display = collapsed ? "" : "none"; | |
| btnCollapse.querySelector("svg").style.transform = collapsed ? "" : "rotate(180deg)"; | |
| }); | |
| } | |
| /* --- Init -------------------------------------------------- */ | |
| setStep(1); | |
| renderTable(); | |
| checkHealth(); | |