| |
| |
| |
| |
| |
|
|
| const state = { |
| jobId: null, |
| file: null, |
| segments: [], |
| isOnline: false, |
| mode: "music", |
| karaokeStyle: { |
| font: "Bangers", |
| color: "#FFFFFF", |
| highlight: "#FFD700", |
| outline: "#000000", |
| outlineWidth: 2, |
| sizePct: 100, |
| positionPct: 90, |
| karaokeMode: false, |
| }, |
| cvnssMode: false, |
| originalTexts: [], |
| subtitlePreviewVisible: false, |
| }; |
|
|
| |
| const IS_FILE_PROTOCOL = window.location.protocol === "file:"; |
|
|
| function getApiBase() { |
| if (IS_FILE_PROTOCOL) return null; |
| return ""; |
| } |
|
|
| |
| 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"), |
| |
| offlineBanner: $("offlineBanner"), |
| offlineBannerText: $("offlineBannerText"), |
| offlineBannerClose:$("offlineBannerClose"), |
| badgeEnv: $("badgeEnv"), |
| badgeEnvText: $("badgeEnvText"), |
| pulseDot: $("pulseDot"), |
| |
| modeToggle: $("modeToggle"), |
| modeMusic: $("modeMusic"), |
| modeSpeech: $("modeSpeech"), |
| modeHint: $("modeHint"), |
| |
| coverageBar: $("coverageBar"), |
| coveragePct: $("coveragePct"), |
| coverageFill: $("coverageFill"), |
| |
| btnToggleCvnss: $("btnToggleCvnss"), |
| cvnssToggleLabel:$("cvnssToggleLabel"), |
| |
| ksFont: $("ksFont"), |
| ksColor: $("ksColor"), |
| ksColorHex: $("ksColorHex"), |
| ksHighlight: $("ksHighlight"), |
| ksHighlightHex: $("ksHighlightHex"), |
| ksOutline: $("ksOutline"), |
| ksOutlineHex: $("ksOutlineHex"), |
| ksOutlineWidth: $("ksOutlineWidth"), |
| ksOutlineWidthVal: $("ksOutlineWidthVal"), |
| ksSize: $("ksSize"), |
| ksSizeVal: $("ksSizeVal"), |
| ksPosition: $("ksPosition"), |
| ksPositionVal: $("ksPositionVal"), |
| ksKaraokeMode: $("ksKaraokeMode"), |
| ksKaraokeHint: $("ksKaraokeHint"), |
| btnPreviewStyle: $("btnPreviewStyle"), |
| videoWrap: $("videoWrap"), |
| subPreviewOverlay: $("subPreviewOverlay"), |
| subPreviewText: $("subPreviewText"), |
| }; |
|
|
| |
| const MODE_HINTS = { |
| music: "Tối ưu cho Vietsub lời bài hát, nhận diện toàn bộ lyrics khớp timeline.", |
| speech: "Tối ưu cho giọng nói, thuyết trình, podcast. Lọc tiếng ồn nền.", |
| }; |
|
|
| function setMode(mode) { |
| state.mode = mode; |
| |
| if (els.modeMusic) els.modeMusic.classList.toggle("active", mode === "music"); |
| if (els.modeSpeech) els.modeSpeech.classList.toggle("active", mode === "speech"); |
| if (els.modeHint) els.modeHint.textContent = MODE_HINTS[mode] || ""; |
| } |
|
|
| |
| if (els.modeToggle) { |
| els.modeToggle.addEventListener("click", (e) => { |
| const btn = e.target.closest(".mode-btn"); |
| if (!btn) return; |
| const mode = btn.dataset.mode; |
| if (mode) setMode(mode); |
| }); |
| } |
|
|
| |
| function showCoverage(pct) { |
| if (!els.coverageBar) return; |
| els.coverageBar.hidden = false; |
| const val = Math.min(100, Math.max(0, pct)); |
| if (els.coveragePct) els.coveragePct.textContent = val + "%"; |
| if (els.coverageFill) els.coverageFill.style.width = val + "%"; |
|
|
| |
| if (els.coverageFill) { |
| els.coverageFill.classList.remove("cov-low", "cov-mid", "cov-high"); |
| if (val >= 80) els.coverageFill.classList.add("cov-high"); |
| else if (val >= 50) els.coverageFill.classList.add("cov-mid"); |
| else els.coverageFill.classList.add("cov-low"); |
| } |
| } |
|
|
| function hideCoverage() { |
| if (els.coverageBar) els.coverageBar.hidden = true; |
| } |
|
|
| |
| let healthRetryTimer = null; |
|
|
| async function checkHealth() { |
| 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"); |
| 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; |
|
|
| 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"; |
| } |
|
|
| 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..."; |
| } |
| if (!IS_FILE_PROTOCOL && !healthRetryTimer) { |
| healthRetryTimer = setInterval(checkHealth, 30000); |
| } |
| } else { |
| if (els.offlineBanner) els.offlineBanner.hidden = true; |
| if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; } |
| } |
| } |
|
|
| if (els.offlineBannerClose) { |
| els.offlineBannerClose.addEventListener("click", () => { |
| if (els.offlineBanner) els.offlineBanner.hidden = true; |
| }); |
| } |
|
|
| |
| 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); |
| }); |
| } |
|
|
| |
| function setStatus(message, type = "idle") { |
| els.status.className = `status-box status-${type}`; |
| els.statusText.textContent = message; |
| } |
|
|
| |
| function setEditButtons(enabled) { |
| els.btnAddRow.disabled = !enabled; |
| els.btnExportSrt.disabled = !enabled; |
| els.btnExportMp4.disabled = !enabled; |
| } |
|
|
| |
| function showDownload(el, url, visible) { |
| el.href = visible ? url : "#"; |
| el.classList.toggle("disabled", !visible); |
| } |
| function showDownloadGroup(show) { |
| els.downloadGroup.hidden = !show; |
| } |
|
|
| |
| 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%"; |
| } |
|
|
| |
| function formatSize(bytes) { |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; |
| return (bytes / (1024 * 1024)).toFixed(1) + " MB"; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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(), |
| })); |
| } |
|
|
| |
| 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); |
|
|
| const tdStart = document.createElement("td"); |
| tdStart.appendChild(createInput(seg.start, "start-input time-input")); |
|
|
| const tdEnd = document.createElement("td"); |
| tdEnd.appendChild(createInput(seg.end, "end-input time-input")); |
|
|
| const tdText = document.createElement("td"); |
| tdText.appendChild(createTextArea(seg.text)); |
|
|
| 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); |
| } |
|
|
| |
| async function transcribeVideo() { |
| if (!state.file) { |
| setStatus("Hãy chọn video trước.", "error"); |
| return; |
| } |
|
|
| 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); |
| fd.append("mode", state.mode); |
|
|
| els.btnTranscribe.disabled = true; |
| els.btnTranscribe.classList.add("btn-loading"); |
| const modeLabel = state.mode === "music" ? "lời bài hát" : "giọng nói"; |
| setStatus(`Đang nhận diện ${modeLabel} tiếng Việt...`, "loading"); |
| setStep(2); |
| startProgress(`Đang upload và nhận diện ${modeLabel}...`); |
| showDownload(els.downloadSrt, "#", false); |
| showDownload(els.downloadMp4, "#", false); |
| showDownloadGroup(false); |
| hideCoverage(); |
|
|
| 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(); |
|
|
| |
| if (data.coverage_pct !== undefined) { |
| showCoverage(data.coverage_pct); |
| } |
|
|
| const coverageInfo = data.coverage_pct ? ` (phủ ${data.coverage_pct}% timeline)` : ""; |
| setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng Vietsub${coverageInfo}.`, "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); |
| checkHealth(); |
| } finally { |
| els.btnTranscribe.disabled = false; |
| els.btnTranscribe.classList.remove("btn-loading"); |
| } |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| async function exportResult(burnIn) { |
| if (!burnIn && (!state.isOnline || !state.jobId)) { |
| downloadSrtOffline(); |
| return; |
| } |
|
|
| 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; |
| } |
|
|
| const ks = getKaraokeStyle(); |
| const payload = { |
| job_id: state.jobId, |
| burn_in: burnIn, |
| segments: collectSegmentsFromTable(), |
| style: { |
| font_name: ks.font, |
| font_color: ks.color, |
| highlight_color: ks.highlight, |
| outline_color: ks.outline, |
| outline_width: ks.outlineWidth, |
| font_size_pct: ks.sizePct, |
| position_pct: ks.positionPct, |
| karaoke_mode: ks.karaokeMode, |
| }, |
| }; |
|
|
| 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); |
| } |
| } |
|
|
| |
| 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); |
| hideCoverage(); |
|
|
| const url = URL.createObjectURL(file); |
| els.preview.src = url; |
| els.preview.classList.add("has-src"); |
| els.videoPlaceholder.classList.add("hidden"); |
|
|
| 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); |
| hideCoverage(); |
| setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle"); |
| setStep(1); |
| } |
|
|
| |
|
|
| els.fileInput.addEventListener("change", (e) => { |
| const [file] = e.target.files || []; |
| if (file) handleFile(file); |
| }); |
|
|
| els.dropZone.addEventListener("click", () => els.fileInput.click()); |
|
|
| 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); |
| } |
| }); |
|
|
| els.btnClearFile.addEventListener("click", clearFile); |
| els.btnTranscribe.addEventListener("click", transcribeVideo); |
| els.btnExportSrt.addEventListener("click", () => exportResult(false)); |
| els.btnExportMp4.addEventListener("click", () => exportResult(true)); |
|
|
| 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; |
| }); |
|
|
| 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)"; |
| }); |
| } |
|
|
| |
|
|
| function getKaraokeStyle() { |
| return { |
| font: els.ksFont ? els.ksFont.value : "Bangers", |
| color: els.ksColor ? els.ksColor.value : "#FFFFFF", |
| highlight: els.ksHighlight ? els.ksHighlight.value : "#FFD700", |
| outline: els.ksOutline ? els.ksOutline.value : "#000000", |
| outlineWidth: els.ksOutlineWidth ? parseInt(els.ksOutlineWidth.value, 10) : 2, |
| sizePct: els.ksSize ? parseInt(els.ksSize.value, 10) : 100, |
| positionPct: els.ksPosition ? parseInt(els.ksPosition.value, 10) : 90, |
| karaokeMode: els.ksKaraokeMode ? els.ksKaraokeMode.checked : false, |
| }; |
| } |
|
|
| |
| function srtTimeToSeconds(timeStr) { |
| if (!timeStr) return 0; |
| |
| const parts = timeStr.replace(',', '.').split(':'); |
| if (parts.length !== 3) return 0; |
| const h = parseFloat(parts[0]) || 0; |
| const m = parseFloat(parts[1]) || 0; |
| const s = parseFloat(parts[2]) || 0; |
| return h * 3600 + m * 60 + s; |
| } |
|
|
| |
| function getSubtitleAtTime(currentTime) { |
| const segs = state.segments; |
| if (!segs || !segs.length) return null; |
| for (let i = 0; i < segs.length; i++) { |
| const start = srtTimeToSeconds(segs[i].start); |
| const end = srtTimeToSeconds(segs[i].end); |
| if (currentTime >= start && currentTime <= end) { |
| return { text: segs[i].text, index: i, start, end }; |
| } |
| } |
| return null; |
| } |
|
|
| function updatePreviewOverlay() { |
| const ks = getKaraokeStyle(); |
| state.karaokeStyle = ks; |
|
|
| const overlay = els.subPreviewOverlay; |
| const textEl = els.subPreviewText; |
| if (!overlay || !textEl) return; |
|
|
| |
| |
| const bottomPct = Math.max(2, 90 - (ks.positionPct / 100) * 88); |
| overlay.style.bottom = bottomPct + "%"; |
|
|
| |
| textEl.style.fontFamily = "'" + ks.font + "', sans-serif"; |
|
|
| |
| textEl.style.fontSize = (1.3 * ks.sizePct / 100) + "rem"; |
|
|
| |
| textEl.style.color = ks.color; |
|
|
| |
| const ow = ks.outlineWidth; |
| const oc = ks.outline; |
| if (ow > 0) { |
| textEl.style.textShadow = [ |
| `${ow}px ${ow}px 0 ${oc}`, |
| `-${ow}px -${ow}px 0 ${oc}`, |
| `${ow}px -${ow}px 0 ${oc}`, |
| `-${ow}px ${ow}px 0 ${oc}`, |
| `0 0 8px rgba(0,0,0,0.7)`, |
| ].join(", "); |
| } else { |
| textEl.style.textShadow = "0 0 8px rgba(0,0,0,0.7)"; |
| } |
|
|
| |
| const video = els.preview; |
| const currentTime = video && video.duration ? video.currentTime : 0; |
| const activeSub = getSubtitleAtTime(currentTime); |
|
|
| if (activeSub && activeSub.text) { |
| |
| if (ks.karaokeMode && activeSub.start !== undefined) { |
| |
| const words = activeSub.text.split(/\s+/); |
| const duration = activeSub.end - activeSub.start; |
| const elapsed = currentTime - activeSub.start; |
| const progress = duration > 0 ? elapsed / duration : 0; |
| const highlightCount = Math.ceil(progress * words.length); |
|
|
| let html = ''; |
| words.forEach((word, i) => { |
| if (i < highlightCount) { |
| html += '<span class="ks-word-active" style="color:' + ks.highlight + '">' + escapeHtml(word) + '</span> '; |
| } else { |
| html += escapeHtml(word) + ' '; |
| } |
| }); |
| textEl.innerHTML = html.trim(); |
| } else { |
| textEl.textContent = activeSub.text; |
| } |
| overlay.hidden = false; |
| } else if (state.segments.length > 0) { |
| |
| textEl.textContent = ''; |
| overlay.hidden = true; |
| } else { |
| |
| if (ks.karaokeMode) { |
| textEl.innerHTML = 'Ph\u1EE5 \u0111\u1EC1 <span class="ks-word-active" style="color:' + ks.highlight + '">m\u1EABu</span> — Xem tr\u01B0\u1EDBc <span class="ks-word-active" style="color:' + ks.highlight + '">Karaoke</span>'; |
| } else { |
| textEl.textContent = "Ph\u1EE5 \u0111\u1EC1 m\u1EABu — Xem tr\u01B0\u1EDBc Karaoke"; |
| } |
| } |
| } |
|
|
| |
| function escapeHtml(text) { |
| const div = document.createElement('div'); |
| div.textContent = text; |
| return div.innerHTML; |
| } |
|
|
| function showSubtitlePreview() { |
| state.subtitlePreviewVisible = true; |
| if (els.subPreviewOverlay) { |
| els.subPreviewOverlay.hidden = false; |
| updatePreviewOverlay(); |
| } |
| startSubtitleSync(); |
| } |
|
|
| function hideSubtitlePreview() { |
| state.subtitlePreviewVisible = false; |
| if (els.subPreviewOverlay) els.subPreviewOverlay.hidden = true; |
| stopSubtitleSync(); |
| } |
|
|
| |
| let subtitleSyncRAF = null; |
| let lastSyncCollectTime = 0; |
|
|
| function syncSubtitleLoop() { |
| if (!state.subtitlePreviewVisible) return; |
| |
| const now = Date.now(); |
| if (now - lastSyncCollectTime > 500) { |
| const tableSegs = collectSegmentsFromTable(); |
| if (tableSegs.length) state.segments = tableSegs; |
| lastSyncCollectTime = now; |
| } |
| updatePreviewOverlay(); |
| subtitleSyncRAF = requestAnimationFrame(syncSubtitleLoop); |
| } |
|
|
| function startSubtitleSync() { |
| stopSubtitleSync(); |
| lastSyncCollectTime = 0; |
| subtitleSyncRAF = requestAnimationFrame(syncSubtitleLoop); |
| } |
|
|
| function stopSubtitleSync() { |
| if (subtitleSyncRAF) { |
| cancelAnimationFrame(subtitleSyncRAF); |
| subtitleSyncRAF = null; |
| } |
| } |
|
|
| |
| function setupKaraokeEvents() { |
| |
| if (els.ksFont) { |
| els.ksFont.addEventListener("change", () => { |
| els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif"; |
| updatePreviewOverlay(); |
| }); |
| |
| els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif"; |
| } |
|
|
| |
| if (els.ksColor) { |
| els.ksColor.addEventListener("input", () => { |
| if (els.ksColorHex) els.ksColorHex.textContent = els.ksColor.value.toUpperCase(); |
| updatePreviewOverlay(); |
| }); |
| } |
| if (els.ksHighlight) { |
| els.ksHighlight.addEventListener("input", () => { |
| if (els.ksHighlightHex) els.ksHighlightHex.textContent = els.ksHighlight.value.toUpperCase(); |
| updatePreviewOverlay(); |
| }); |
| } |
| if (els.ksOutline) { |
| els.ksOutline.addEventListener("input", () => { |
| if (els.ksOutlineHex) els.ksOutlineHex.textContent = els.ksOutline.value.toUpperCase(); |
| updatePreviewOverlay(); |
| }); |
| } |
| if (els.ksOutlineWidth) { |
| els.ksOutlineWidth.addEventListener("input", () => { |
| if (els.ksOutlineWidthVal) els.ksOutlineWidthVal.textContent = els.ksOutlineWidth.value + "px"; |
| updatePreviewOverlay(); |
| }); |
| } |
|
|
| |
| if (els.ksSize) { |
| els.ksSize.addEventListener("input", () => { |
| if (els.ksSizeVal) els.ksSizeVal.textContent = els.ksSize.value + "%"; |
| updatePreviewOverlay(); |
| }); |
| } |
|
|
| |
| if (els.ksPosition) { |
| els.ksPosition.addEventListener("input", () => { |
| if (els.ksPositionVal) els.ksPositionVal.textContent = els.ksPosition.value + "%"; |
| updatePreviewOverlay(); |
| }); |
| } |
|
|
| |
| if (els.ksKaraokeMode) { |
| els.ksKaraokeMode.addEventListener("change", () => { |
| updatePreviewOverlay(); |
| }); |
| } |
|
|
| |
| if (els.btnPreviewStyle) { |
| let previewVisible = false; |
| els.btnPreviewStyle.addEventListener("click", () => { |
| previewVisible = !previewVisible; |
| if (previewVisible) { |
| showSubtitlePreview(); |
| els.btnPreviewStyle.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd"/><path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z"/></svg> Ẩn phụ đề'; |
| } else { |
| hideSubtitlePreview(); |
| els.btnPreviewStyle.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg> Xem trước phụ đề'; |
| } |
| }); |
| } |
| } |
|
|
| |
|
|
| function toggleCvnss() { |
| if (typeof CVNSSConverter === 'undefined') { |
| setStatus('Th\u01B0 vi\u1EC7n CVNSS4.0 ch\u01B0a \u0111\u01B0\u1EE3c t\u1EA3i. Vui l\u00F2ng refresh trang.', 'error'); |
| return; |
| } |
|
|
| |
| state.segments = collectSegmentsFromTable(); |
|
|
| if (!state.segments.length) { |
| setStatus('Ch\u01B0a c\u00F3 subtitle \u0111\u1EC3 chuy\u1EC3n \u0111\u1ED5i.', 'error'); |
| return; |
| } |
|
|
| if (!state.cvnssMode) { |
| |
| |
| state.originalTexts = state.segments.map(s => s.text); |
| state.segments = state.segments.map(seg => { |
| try { |
| const result = CVNSSConverter.convert(seg.text, 'cqn'); |
| return { ...seg, text: result.cvss }; |
| } catch (_) { |
| return seg; |
| } |
| }); |
| state.cvnssMode = true; |
| } else { |
| |
| |
| if (state.originalTexts.length === state.segments.length) { |
| state.segments = state.segments.map((seg, i) => ({ |
| ...seg, |
| text: state.originalTexts[i], |
| })); |
| } else { |
| |
| state.segments = state.segments.map(seg => { |
| try { |
| const result = CVNSSConverter.convert(seg.text, 'cvss'); |
| return { ...seg, text: result.cqn }; |
| } catch (_) { |
| return seg; |
| } |
| }); |
| } |
| state.cvnssMode = false; |
| state.originalTexts = []; |
| } |
|
|
| renderTable(); |
| updateCvnssUI(); |
| } |
|
|
| function updateCvnssUI() { |
| const btn = els.btnToggleCvnss; |
| const label = els.cvnssToggleLabel; |
| if (btn) btn.classList.toggle('cvnss-active', state.cvnssMode); |
| if (label) label.textContent = state.cvnssMode ? 'Ti\u1EBFng Vi\u1EC7t' : 'CVNSS4.0'; |
| } |
|
|
| |
| if (els.btnToggleCvnss) { |
| els.btnToggleCvnss.addEventListener('click', toggleCvnss); |
| } |
|
|
| |
| setStep(1); |
| setMode("music"); |
| renderTable(); |
| checkHealth(); |
| setupKaraokeEvents(); |
| updateCvnssUI(); |
|
|