VietAutoSub2 / static /app.js
CVNSS's picture
Upload 7 files
afb047e verified
/* ============================================================
Viet AutoSub Editor – Dashboard JavaScript
Tương thích cả offline (file://) và online (HF Spaces)
Hỗ trợ 2 chế độ: Lời bài hát (music) + Giọng nói (speech)
============================================================ */
const state = {
jobId: null,
file: null,
segments: [],
isOnline: false,
mode: "music", // "music" | "speech"
};
/* --- Detect environment ------------------------------------ */
const IS_FILE_PROTOCOL = window.location.protocol === "file:";
function getApiBase() {
if (IS_FILE_PROTOCOL) return null;
return "";
}
/* --- 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"),
// Offline banner + badge
offlineBanner: $("offlineBanner"),
offlineBannerText: $("offlineBannerText"),
offlineBannerClose:$("offlineBannerClose"),
badgeEnv: $("badgeEnv"),
badgeEnvText: $("badgeEnvText"),
pulseDot: $("pulseDot"),
// Mode selector
modeToggle: $("modeToggle"),
modeMusic: $("modeMusic"),
modeSpeech: $("modeSpeech"),
modeHint: $("modeHint"),
// Coverage
coverageBar: $("coverageBar"),
coveragePct: $("coveragePct"),
coverageFill: $("coverageFill"),
};
/* --- Mode selector ----------------------------------------- */
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;
// Update toggle buttons
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] || "";
}
// Mode toggle event listeners
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);
});
}
/* --- Coverage display -------------------------------------- */
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 + "%";
// Color coding
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;
}
/* --- Health check ------------------------------------------ */
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;
});
}
/* --- 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);
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);
}
/* --- Transcribe -------------------------------------------- */
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();
// Show coverage
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");
}
}
/* --- 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 (!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 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);
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);
}
/* --- Event listeners --------------------------------------- */
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)";
});
}
/* --- Init -------------------------------------------------- */
setStep(1);
setMode("music");
renderTable();
checkHealth();