Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- static/app.js +566 -0
- static/styles.css +702 -0
- templates/index.html +242 -0
static/app.js
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Viet AutoSub Editor – Dashboard JavaScript
|
| 3 |
+
Tương thích cả offline (file://) và online (HF Spaces)
|
| 4 |
+
============================================================ */
|
| 5 |
+
|
| 6 |
+
const state = {
|
| 7 |
+
jobId: null,
|
| 8 |
+
file: null,
|
| 9 |
+
segments: [],
|
| 10 |
+
isOnline: false, // server reachable?
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
/* --- Detect environment ------------------------------------ */
|
| 14 |
+
const IS_FILE_PROTOCOL = window.location.protocol === "file:";
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Determine the API base URL.
|
| 18 |
+
* - file:// → cannot reach backend at all
|
| 19 |
+
* - http(s):// → use same origin (relative paths)
|
| 20 |
+
*/
|
| 21 |
+
function getApiBase() {
|
| 22 |
+
if (IS_FILE_PROTOCOL) return null;
|
| 23 |
+
return ""; // same-origin: "/api/transcribe" etc.
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* --- DOM refs ---------------------------------------------- */
|
| 27 |
+
const $ = (id) => document.getElementById(id);
|
| 28 |
+
|
| 29 |
+
const els = {
|
| 30 |
+
fileInput: $("videoFile"),
|
| 31 |
+
preview: $("preview"),
|
| 32 |
+
videoPlaceholder:$("videoPlaceholder"),
|
| 33 |
+
status: $("status"),
|
| 34 |
+
statusText: $("statusText"),
|
| 35 |
+
btnTranscribe: $("btnTranscribe"),
|
| 36 |
+
btnAddRow: $("btnAddRow"),
|
| 37 |
+
btnExportSrt: $("btnExportSrt"),
|
| 38 |
+
btnExportMp4: $("btnExportMp4"),
|
| 39 |
+
btnClearFile: $("btnClearFile"),
|
| 40 |
+
subtitleBody: $("subtitleBody"),
|
| 41 |
+
segmentCount: $("segmentCount"),
|
| 42 |
+
downloadSrt: $("downloadSrt"),
|
| 43 |
+
downloadMp4: $("downloadMp4"),
|
| 44 |
+
downloadGroup: $("downloadGroup"),
|
| 45 |
+
dropZone: $("dropZone"),
|
| 46 |
+
uploadPanel: $("uploadPanel"),
|
| 47 |
+
fileInfo: $("fileInfo"),
|
| 48 |
+
fileName: $("fileName"),
|
| 49 |
+
fileSize: $("fileSize"),
|
| 50 |
+
progressWrap: $("progressWrap"),
|
| 51 |
+
progressFill: $("progressFill"),
|
| 52 |
+
progressText: $("progressText"),
|
| 53 |
+
// New: offline banner + badge
|
| 54 |
+
offlineBanner: $("offlineBanner"),
|
| 55 |
+
offlineBannerText: $("offlineBannerText"),
|
| 56 |
+
offlineBannerClose:$("offlineBannerClose"),
|
| 57 |
+
badgeEnv: $("badgeEnv"),
|
| 58 |
+
badgeEnvText: $("badgeEnvText"),
|
| 59 |
+
pulseDot: $("pulseDot"),
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
/* --- Health check ------------------------------------------ */
|
| 63 |
+
let healthRetryTimer = null;
|
| 64 |
+
|
| 65 |
+
async function checkHealth() {
|
| 66 |
+
// file:// → always offline
|
| 67 |
+
if (IS_FILE_PROTOCOL) {
|
| 68 |
+
setOnlineState(false, "Offline (file://)");
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
const res = await fetch("/health", { method: "GET", cache: "no-store" });
|
| 74 |
+
if (res.ok) {
|
| 75 |
+
const data = await res.json();
|
| 76 |
+
setOnlineState(true, "HF Space");
|
| 77 |
+
// Stop retrying
|
| 78 |
+
if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
|
| 79 |
+
} else {
|
| 80 |
+
setOnlineState(false, "Server lỗi");
|
| 81 |
+
}
|
| 82 |
+
} catch (_) {
|
| 83 |
+
setOnlineState(false, "Không kết nối");
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function setOnlineState(online, label) {
|
| 88 |
+
state.isOnline = online;
|
| 89 |
+
|
| 90 |
+
// Badge
|
| 91 |
+
if (els.badgeEnv) {
|
| 92 |
+
els.badgeEnv.classList.toggle("badge-online", online);
|
| 93 |
+
els.badgeEnv.classList.toggle("badge-offline", !online);
|
| 94 |
+
}
|
| 95 |
+
if (els.badgeEnvText) {
|
| 96 |
+
els.badgeEnvText.textContent = label || (online ? "Online" : "Offline");
|
| 97 |
+
}
|
| 98 |
+
if (els.pulseDot) {
|
| 99 |
+
els.pulseDot.className = online ? "pulse-dot pulse-online" : "pulse-dot pulse-offline";
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Offline banner
|
| 103 |
+
if (!online) {
|
| 104 |
+
if (els.offlineBanner) els.offlineBanner.hidden = false;
|
| 105 |
+
if (els.offlineBannerText) {
|
| 106 |
+
els.offlineBannerText.textContent = IS_FILE_PROTOCOL
|
| 107 |
+
? "Đ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."
|
| 108 |
+
: "Không kết nối được server — Đang thử lại mỗi 30 giây...";
|
| 109 |
+
}
|
| 110 |
+
// Auto-retry every 30s if on http but server is down
|
| 111 |
+
if (!IS_FILE_PROTOCOL && !healthRetryTimer) {
|
| 112 |
+
healthRetryTimer = setInterval(checkHealth, 30000);
|
| 113 |
+
}
|
| 114 |
+
} else {
|
| 115 |
+
if (els.offlineBanner) els.offlineBanner.hidden = true;
|
| 116 |
+
if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// Close banner button
|
| 121 |
+
if (els.offlineBannerClose) {
|
| 122 |
+
els.offlineBannerClose.addEventListener("click", () => {
|
| 123 |
+
if (els.offlineBanner) els.offlineBanner.hidden = true;
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/* --- Steps ------------------------------------------------- */
|
| 128 |
+
function setStep(num) {
|
| 129 |
+
document.querySelectorAll(".step").forEach((el) => {
|
| 130 |
+
const s = parseInt(el.dataset.step, 10);
|
| 131 |
+
el.classList.toggle("active", s === num);
|
| 132 |
+
el.classList.toggle("done", s < num);
|
| 133 |
+
});
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* --- Status ------------------------------------------------ */
|
| 137 |
+
function setStatus(message, type = "idle") {
|
| 138 |
+
els.status.className = `status-box status-${type}`;
|
| 139 |
+
els.statusText.textContent = message;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* --- Buttons state ----------------------------------------- */
|
| 143 |
+
function setEditButtons(enabled) {
|
| 144 |
+
els.btnAddRow.disabled = !enabled;
|
| 145 |
+
els.btnExportSrt.disabled = !enabled;
|
| 146 |
+
els.btnExportMp4.disabled = !enabled;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/* --- Download link helpers --------------------------------- */
|
| 150 |
+
function showDownload(el, url, visible) {
|
| 151 |
+
el.href = visible ? url : "#";
|
| 152 |
+
el.classList.toggle("disabled", !visible);
|
| 153 |
+
}
|
| 154 |
+
function showDownloadGroup(show) {
|
| 155 |
+
els.downloadGroup.hidden = !show;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* --- Progress simulation ----------------------------------- */
|
| 159 |
+
let progressTimer = null;
|
| 160 |
+
function startProgress(label) {
|
| 161 |
+
els.progressWrap.hidden = false;
|
| 162 |
+
els.progressFill.style.width = "0%";
|
| 163 |
+
els.progressText.textContent = label || "Đang xử lý...";
|
| 164 |
+
|
| 165 |
+
let pct = 0;
|
| 166 |
+
clearInterval(progressTimer);
|
| 167 |
+
progressTimer = setInterval(() => {
|
| 168 |
+
const remaining = 90 - pct;
|
| 169 |
+
const step = Math.max(0.3, remaining * 0.04);
|
| 170 |
+
pct = Math.min(90, pct + step);
|
| 171 |
+
els.progressFill.style.width = pct + "%";
|
| 172 |
+
}, 300);
|
| 173 |
+
}
|
| 174 |
+
function finishProgress() {
|
| 175 |
+
clearInterval(progressTimer);
|
| 176 |
+
els.progressFill.style.width = "100%";
|
| 177 |
+
setTimeout(() => {
|
| 178 |
+
els.progressWrap.hidden = true;
|
| 179 |
+
els.progressFill.style.width = "0%";
|
| 180 |
+
}, 600);
|
| 181 |
+
}
|
| 182 |
+
function cancelProgress() {
|
| 183 |
+
clearInterval(progressTimer);
|
| 184 |
+
els.progressWrap.hidden = true;
|
| 185 |
+
els.progressFill.style.width = "0%";
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/* --- File size formatter ----------------------------------- */
|
| 189 |
+
function formatSize(bytes) {
|
| 190 |
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
| 191 |
+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* --- Create table cell inputs ------------------------------ */
|
| 195 |
+
function createInput(value, className) {
|
| 196 |
+
const input = document.createElement("input");
|
| 197 |
+
input.type = "text";
|
| 198 |
+
input.value = value || "";
|
| 199 |
+
input.className = className;
|
| 200 |
+
input.spellcheck = false;
|
| 201 |
+
return input;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function createTextArea(value) {
|
| 205 |
+
const textarea = document.createElement("textarea");
|
| 206 |
+
textarea.value = value || "";
|
| 207 |
+
textarea.rows = 2;
|
| 208 |
+
textarea.className = "text-input";
|
| 209 |
+
return textarea;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/* --- Collect segments from table --------------------------- */
|
| 213 |
+
function collectSegmentsFromTable() {
|
| 214 |
+
const rows = Array.from(els.subtitleBody.querySelectorAll("tr[data-row='1']"));
|
| 215 |
+
return rows.map((row, index) => ({
|
| 216 |
+
id: index + 1,
|
| 217 |
+
start: row.querySelector(".start-input").value.trim(),
|
| 218 |
+
end: row.querySelector(".end-input").value.trim(),
|
| 219 |
+
text: row.querySelector(".text-input").value.trim(),
|
| 220 |
+
}));
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
/* --- Render table ------------------------------------------ */
|
| 224 |
+
function renderTable() {
|
| 225 |
+
els.subtitleBody.innerHTML = "";
|
| 226 |
+
|
| 227 |
+
if (!state.segments.length) {
|
| 228 |
+
els.subtitleBody.innerHTML = `
|
| 229 |
+
<tr class="empty-row">
|
| 230 |
+
<td colspan="5">
|
| 231 |
+
<div class="empty-state">
|
| 232 |
+
<svg viewBox="0 0 48 48" fill="none" class="empty-icon">
|
| 233 |
+
<rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
|
| 234 |
+
<line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
|
| 235 |
+
<line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
|
| 236 |
+
<line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
|
| 237 |
+
</svg>
|
| 238 |
+
<p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
|
| 239 |
+
</div>
|
| 240 |
+
</td>
|
| 241 |
+
</tr>`;
|
| 242 |
+
els.segmentCount.textContent = "0 dòng";
|
| 243 |
+
setEditButtons(false);
|
| 244 |
+
return;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
state.segments.forEach((seg, index) => {
|
| 248 |
+
const tr = document.createElement("tr");
|
| 249 |
+
tr.dataset.row = "1";
|
| 250 |
+
|
| 251 |
+
// #
|
| 252 |
+
const tdIdx = document.createElement("td");
|
| 253 |
+
tdIdx.className = "idx-cell";
|
| 254 |
+
tdIdx.textContent = String(index + 1);
|
| 255 |
+
|
| 256 |
+
// Start
|
| 257 |
+
const tdStart = document.createElement("td");
|
| 258 |
+
tdStart.appendChild(createInput(seg.start, "start-input time-input"));
|
| 259 |
+
|
| 260 |
+
// End
|
| 261 |
+
const tdEnd = document.createElement("td");
|
| 262 |
+
tdEnd.appendChild(createInput(seg.end, "end-input time-input"));
|
| 263 |
+
|
| 264 |
+
// Text
|
| 265 |
+
const tdText = document.createElement("td");
|
| 266 |
+
tdText.appendChild(createTextArea(seg.text));
|
| 267 |
+
|
| 268 |
+
// Delete
|
| 269 |
+
const tdAct = document.createElement("td");
|
| 270 |
+
tdAct.style.textAlign = "center";
|
| 271 |
+
const delBtn = document.createElement("button");
|
| 272 |
+
delBtn.className = "btn btn-danger-sm";
|
| 273 |
+
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>`;
|
| 274 |
+
delBtn.title = "Xóa dòng";
|
| 275 |
+
delBtn.addEventListener("click", () => {
|
| 276 |
+
state.segments = collectSegmentsFromTable();
|
| 277 |
+
state.segments.splice(index, 1);
|
| 278 |
+
renderTable();
|
| 279 |
+
});
|
| 280 |
+
tdAct.appendChild(delBtn);
|
| 281 |
+
|
| 282 |
+
tr.append(tdIdx, tdStart, tdEnd, tdText, tdAct);
|
| 283 |
+
els.subtitleBody.appendChild(tr);
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
els.segmentCount.textContent = `${state.segments.length} dòng`;
|
| 287 |
+
setEditButtons(true);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/* --- Transcribe -------------------------------------------- */
|
| 291 |
+
async function transcribeVideo() {
|
| 292 |
+
if (!state.file) {
|
| 293 |
+
setStatus("Hãy chọn video trước.", "error");
|
| 294 |
+
return;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// Offline guard
|
| 298 |
+
if (!state.isOnline) {
|
| 299 |
+
setStatus(
|
| 300 |
+
IS_FILE_PROTOCOL
|
| 301 |
+
? "Đ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."
|
| 302 |
+
: "Server không phản hồi. Đang thử kết nối lại...",
|
| 303 |
+
"error"
|
| 304 |
+
);
|
| 305 |
+
if (!IS_FILE_PROTOCOL) checkHealth();
|
| 306 |
+
return;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
const fd = new FormData();
|
| 310 |
+
fd.append("file", state.file);
|
| 311 |
+
|
| 312 |
+
els.btnTranscribe.disabled = true;
|
| 313 |
+
els.btnTranscribe.classList.add("btn-loading");
|
| 314 |
+
setStatus("Đang nhận diện lời nói tiếng Việt...", "loading");
|
| 315 |
+
setStep(2);
|
| 316 |
+
startProgress("Đang upload và nhận diện giọng nói...");
|
| 317 |
+
showDownload(els.downloadSrt, "#", false);
|
| 318 |
+
showDownload(els.downloadMp4, "#", false);
|
| 319 |
+
showDownloadGroup(false);
|
| 320 |
+
|
| 321 |
+
try {
|
| 322 |
+
const res = await fetch("/api/transcribe", {
|
| 323 |
+
method: "POST",
|
| 324 |
+
body: fd,
|
| 325 |
+
});
|
| 326 |
+
const data = await res.json();
|
| 327 |
+
if (!res.ok) throw new Error(data.detail || "Không thể nhận diện subtitle.");
|
| 328 |
+
|
| 329 |
+
state.jobId = data.job_id;
|
| 330 |
+
state.segments = data.segments || [];
|
| 331 |
+
renderTable();
|
| 332 |
+
finishProgress();
|
| 333 |
+
setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng subtitle.`, "success");
|
| 334 |
+
setStep(3);
|
| 335 |
+
} catch (err) {
|
| 336 |
+
cancelProgress();
|
| 337 |
+
const msg = err.message.includes("Failed to fetch")
|
| 338 |
+
? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
|
| 339 |
+
: (err.message || "Có lỗi khi auto sub.");
|
| 340 |
+
setStatus(msg, "error");
|
| 341 |
+
setStep(1);
|
| 342 |
+
// Re-check health
|
| 343 |
+
checkHealth();
|
| 344 |
+
} finally {
|
| 345 |
+
els.btnTranscribe.disabled = false;
|
| 346 |
+
els.btnTranscribe.classList.remove("btn-loading");
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
/* --- Client-side SRT generation (offline-capable) ---------- */
|
| 351 |
+
function generateSrtString(segments) {
|
| 352 |
+
let lines = [];
|
| 353 |
+
segments.forEach((seg, idx) => {
|
| 354 |
+
const start = seg.start || "00:00:00,000";
|
| 355 |
+
const end = seg.end || "00:00:02,000";
|
| 356 |
+
const text = (seg.text || "").trim();
|
| 357 |
+
if (!text) return;
|
| 358 |
+
lines.push(String(idx + 1));
|
| 359 |
+
lines.push(`${start} --> ${end}`);
|
| 360 |
+
lines.push(text);
|
| 361 |
+
lines.push("");
|
| 362 |
+
});
|
| 363 |
+
return lines.join("\n");
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function downloadSrtOffline() {
|
| 367 |
+
const segments = collectSegmentsFromTable();
|
| 368 |
+
if (!segments.length) {
|
| 369 |
+
setStatus("Chưa có subtitle để xuất.", "error");
|
| 370 |
+
return;
|
| 371 |
+
}
|
| 372 |
+
const srtContent = generateSrtString(segments);
|
| 373 |
+
const blob = new Blob([srtContent], { type: "text/plain;charset=utf-8" });
|
| 374 |
+
const url = URL.createObjectURL(blob);
|
| 375 |
+
const a = document.createElement("a");
|
| 376 |
+
a.href = url;
|
| 377 |
+
a.download = "subtitle.srt";
|
| 378 |
+
document.body.appendChild(a);
|
| 379 |
+
a.click();
|
| 380 |
+
document.body.removeChild(a);
|
| 381 |
+
URL.revokeObjectURL(url);
|
| 382 |
+
setStatus("Đã xuất file SRT thành công (offline).", "success");
|
| 383 |
+
setStep(4);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
/* --- Export ------------------------------------------------- */
|
| 387 |
+
async function exportResult(burnIn) {
|
| 388 |
+
// If offline or no jobId and requesting SRT only → use client-side export
|
| 389 |
+
if (!burnIn && (!state.isOnline || !state.jobId)) {
|
| 390 |
+
downloadSrtOffline();
|
| 391 |
+
return;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
// MP4 burn-in requires server
|
| 395 |
+
if (burnIn && !state.isOnline) {
|
| 396 |
+
setStatus(
|
| 397 |
+
IS_FILE_PROTOCOL
|
| 398 |
+
? "Xuất MP4 burn sub cần server HF Space. Hãy deploy ứng dụng lên HF Space trước."
|
| 399 |
+
: "Server không phản hồi. Xuất MP4 cần kết nối server.",
|
| 400 |
+
"error"
|
| 401 |
+
);
|
| 402 |
+
return;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
if (!state.jobId) {
|
| 406 |
+
setStatus("Chưa có job để xuất file. Hãy bấm Auto sub trước.", "error");
|
| 407 |
+
return;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// Collect latest from table
|
| 411 |
+
const payload = {
|
| 412 |
+
job_id: state.jobId,
|
| 413 |
+
burn_in: burnIn,
|
| 414 |
+
segments: collectSegmentsFromTable(),
|
| 415 |
+
};
|
| 416 |
+
|
| 417 |
+
const label = burnIn ? "Đang xuất MP4 có sub..." : "Đang tạo file SRT...";
|
| 418 |
+
setStatus(label, "loading");
|
| 419 |
+
startProgress(label);
|
| 420 |
+
setStep(4);
|
| 421 |
+
els.btnExportSrt.disabled = true;
|
| 422 |
+
els.btnExportMp4.disabled = true;
|
| 423 |
+
|
| 424 |
+
try {
|
| 425 |
+
const res = await fetch("/api/export", {
|
| 426 |
+
method: "POST",
|
| 427 |
+
headers: { "Content-Type": "application/json" },
|
| 428 |
+
body: JSON.stringify(payload),
|
| 429 |
+
});
|
| 430 |
+
const data = await res.json();
|
| 431 |
+
if (!res.ok) throw new Error(data.detail || "Xuất file thất bại.");
|
| 432 |
+
|
| 433 |
+
finishProgress();
|
| 434 |
+
showDownloadGroup(true);
|
| 435 |
+
showDownload(els.downloadSrt, data.srt_url, true);
|
| 436 |
+
if (data.mp4_url) {
|
| 437 |
+
showDownload(els.downloadMp4, data.mp4_url, true);
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
const msg = burnIn
|
| 441 |
+
? `Xuất MP4 thành công${data.mp4_size_mb ? ` (${data.mp4_size_mb} MB)` : ""}.`
|
| 442 |
+
: "Đã tạo file SRT thành công.";
|
| 443 |
+
setStatus(msg, "success");
|
| 444 |
+
} catch (err) {
|
| 445 |
+
cancelProgress();
|
| 446 |
+
const msg = err.message.includes("Failed to fetch")
|
| 447 |
+
? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
|
| 448 |
+
: (err.message || "Có lỗi khi xuất file.");
|
| 449 |
+
setStatus(msg, "error");
|
| 450 |
+
checkHealth();
|
| 451 |
+
} finally {
|
| 452 |
+
setEditButtons(true);
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
/* --- File selection ---------------------------------------- */
|
| 457 |
+
function handleFile(file) {
|
| 458 |
+
if (!file) return;
|
| 459 |
+
state.file = file;
|
| 460 |
+
state.jobId = null;
|
| 461 |
+
state.segments = [];
|
| 462 |
+
renderTable();
|
| 463 |
+
showDownload(els.downloadSrt, "#", false);
|
| 464 |
+
showDownload(els.downloadMp4, "#", false);
|
| 465 |
+
showDownloadGroup(false);
|
| 466 |
+
|
| 467 |
+
// Show video preview
|
| 468 |
+
const url = URL.createObjectURL(file);
|
| 469 |
+
els.preview.src = url;
|
| 470 |
+
els.preview.classList.add("has-src");
|
| 471 |
+
els.videoPlaceholder.classList.add("hidden");
|
| 472 |
+
|
| 473 |
+
// Show file info
|
| 474 |
+
els.fileInfo.hidden = false;
|
| 475 |
+
els.fileName.textContent = file.name;
|
| 476 |
+
els.fileSize.textContent = formatSize(file.size);
|
| 477 |
+
|
| 478 |
+
setStatus(`Đã chọn: ${file.name}`, "idle");
|
| 479 |
+
setStep(1);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
function clearFile() {
|
| 483 |
+
state.file = null;
|
| 484 |
+
state.jobId = null;
|
| 485 |
+
state.segments = [];
|
| 486 |
+
renderTable();
|
| 487 |
+
els.preview.removeAttribute("src");
|
| 488 |
+
els.preview.classList.remove("has-src");
|
| 489 |
+
els.videoPlaceholder.classList.remove("hidden");
|
| 490 |
+
els.fileInfo.hidden = true;
|
| 491 |
+
els.fileInput.value = "";
|
| 492 |
+
showDownloadGroup(false);
|
| 493 |
+
setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle");
|
| 494 |
+
setStep(1);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/* --- Event listeners --------------------------------------- */
|
| 498 |
+
|
| 499 |
+
// File input
|
| 500 |
+
els.fileInput.addEventListener("change", (e) => {
|
| 501 |
+
const [file] = e.target.files || [];
|
| 502 |
+
if (file) handleFile(file);
|
| 503 |
+
});
|
| 504 |
+
|
| 505 |
+
// Click on drop zone
|
| 506 |
+
els.dropZone.addEventListener("click", () => els.fileInput.click());
|
| 507 |
+
|
| 508 |
+
// Drag & drop
|
| 509 |
+
els.dropZone.addEventListener("dragover", (e) => {
|
| 510 |
+
e.preventDefault();
|
| 511 |
+
els.dropZone.classList.add("drag-over");
|
| 512 |
+
});
|
| 513 |
+
els.dropZone.addEventListener("dragleave", () => {
|
| 514 |
+
els.dropZone.classList.remove("drag-over");
|
| 515 |
+
});
|
| 516 |
+
els.dropZone.addEventListener("drop", (e) => {
|
| 517 |
+
e.preventDefault();
|
| 518 |
+
els.dropZone.classList.remove("drag-over");
|
| 519 |
+
const file = e.dataTransfer.files[0];
|
| 520 |
+
if (file) {
|
| 521 |
+
const dt = new DataTransfer();
|
| 522 |
+
dt.items.add(file);
|
| 523 |
+
els.fileInput.files = dt.files;
|
| 524 |
+
handleFile(file);
|
| 525 |
+
}
|
| 526 |
+
});
|
| 527 |
+
|
| 528 |
+
// Clear file
|
| 529 |
+
els.btnClearFile.addEventListener("click", clearFile);
|
| 530 |
+
|
| 531 |
+
// Transcribe
|
| 532 |
+
els.btnTranscribe.addEventListener("click", transcribeVideo);
|
| 533 |
+
|
| 534 |
+
// Export
|
| 535 |
+
els.btnExportSrt.addEventListener("click", () => exportResult(false));
|
| 536 |
+
els.btnExportMp4.addEventListener("click", () => exportResult(true));
|
| 537 |
+
|
| 538 |
+
// Add row
|
| 539 |
+
els.btnAddRow.addEventListener("click", () => {
|
| 540 |
+
state.segments = collectSegmentsFromTable();
|
| 541 |
+
state.segments.push({
|
| 542 |
+
id: state.segments.length + 1,
|
| 543 |
+
start: "00:00:00,000",
|
| 544 |
+
end: "00:00:02,000",
|
| 545 |
+
text: "Subtitle mới",
|
| 546 |
+
});
|
| 547 |
+
renderTable();
|
| 548 |
+
const scroll = $("tableScroll");
|
| 549 |
+
if (scroll) scroll.scrollTop = scroll.scrollHeight;
|
| 550 |
+
});
|
| 551 |
+
|
| 552 |
+
// Collapse table toggle
|
| 553 |
+
const btnCollapse = $("btnCollapseTable");
|
| 554 |
+
const tableScroll = $("tableScroll");
|
| 555 |
+
if (btnCollapse && tableScroll) {
|
| 556 |
+
btnCollapse.addEventListener("click", () => {
|
| 557 |
+
const collapsed = tableScroll.style.display === "none";
|
| 558 |
+
tableScroll.style.display = collapsed ? "" : "none";
|
| 559 |
+
btnCollapse.querySelector("svg").style.transform = collapsed ? "" : "rotate(180deg)";
|
| 560 |
+
});
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
/* --- Init -------------------------------------------------- */
|
| 564 |
+
setStep(1);
|
| 565 |
+
renderTable();
|
| 566 |
+
checkHealth();
|
static/styles.css
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Viet AutoSub Editor – Dashboard Stylesheet
|
| 3 |
+
Dark theme — Tương thích offline + HF Spaces online
|
| 4 |
+
============================================================ */
|
| 5 |
+
|
| 6 |
+
/* --- Tokens ------------------------------------------------- */
|
| 7 |
+
:root {
|
| 8 |
+
--bg-base: #0a0e1a;
|
| 9 |
+
--bg-surface: #111827;
|
| 10 |
+
--bg-raised: #1a2236;
|
| 11 |
+
--bg-input: #0f1629;
|
| 12 |
+
--border: rgba(255,255,255,0.08);
|
| 13 |
+
--border-focus: #6366f1;
|
| 14 |
+
|
| 15 |
+
--text-primary: #f1f5f9;
|
| 16 |
+
--text-secondary: #94a3b8;
|
| 17 |
+
--text-muted: #64748b;
|
| 18 |
+
|
| 19 |
+
--accent: #6366f1;
|
| 20 |
+
--accent-hover: #818cf8;
|
| 21 |
+
--accent-glow: rgba(99,102,241,0.25);
|
| 22 |
+
|
| 23 |
+
--success: #10b981;
|
| 24 |
+
--success-bg: rgba(16,185,129,0.12);
|
| 25 |
+
--danger: #ef4444;
|
| 26 |
+
--danger-bg: rgba(239,68,68,0.10);
|
| 27 |
+
--warning: #f59e0b;
|
| 28 |
+
--warning-bg: rgba(245,158,11,0.12);
|
| 29 |
+
|
| 30 |
+
--radius-sm: 8px;
|
| 31 |
+
--radius: 12px;
|
| 32 |
+
--radius-lg: 16px;
|
| 33 |
+
--radius-xl: 20px;
|
| 34 |
+
|
| 35 |
+
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
| 36 |
+
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
| 37 |
+
|
| 38 |
+
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
| 39 |
+
--shadow: 0 4px 16px rgba(0,0,0,0.35);
|
| 40 |
+
--shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* --- Reset -------------------------------------------------- */
|
| 44 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 45 |
+
html { font-size: 15px; -webkit-font-smoothing: antialiased; }
|
| 46 |
+
body {
|
| 47 |
+
font-family: var(--font-sans);
|
| 48 |
+
background: var(--bg-base);
|
| 49 |
+
color: var(--text-primary);
|
| 50 |
+
min-height: 100vh;
|
| 51 |
+
line-height: 1.55;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* --- Offline Banner ----------------------------------------- */
|
| 55 |
+
.offline-banner {
|
| 56 |
+
display: flex;
|
| 57 |
+
align-items: center;
|
| 58 |
+
gap: 10px;
|
| 59 |
+
padding: 10px 20px;
|
| 60 |
+
background: linear-gradient(90deg, rgba(245,158,11,0.18), rgba(245,158,11,0.08));
|
| 61 |
+
border-bottom: 1px solid rgba(245,158,11,0.3);
|
| 62 |
+
color: #fde68a;
|
| 63 |
+
font-size: 0.84rem;
|
| 64 |
+
font-weight: 500;
|
| 65 |
+
position: relative;
|
| 66 |
+
z-index: 60;
|
| 67 |
+
}
|
| 68 |
+
.offline-banner-icon {
|
| 69 |
+
width: 18px;
|
| 70 |
+
height: 18px;
|
| 71 |
+
flex-shrink: 0;
|
| 72 |
+
color: #f59e0b;
|
| 73 |
+
}
|
| 74 |
+
.offline-banner-close {
|
| 75 |
+
margin-left: auto;
|
| 76 |
+
background: none;
|
| 77 |
+
border: none;
|
| 78 |
+
color: #fde68a;
|
| 79 |
+
font-size: 1.2rem;
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
padding: 2px 6px;
|
| 82 |
+
border-radius: 4px;
|
| 83 |
+
opacity: 0.6;
|
| 84 |
+
transition: opacity 0.2s;
|
| 85 |
+
line-height: 1;
|
| 86 |
+
}
|
| 87 |
+
.offline-banner-close:hover {
|
| 88 |
+
opacity: 1;
|
| 89 |
+
background: rgba(245,158,11,0.15);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* --- Top Nav ------------------------------------------------ */
|
| 93 |
+
.topbar {
|
| 94 |
+
position: sticky; top: 0; z-index: 50;
|
| 95 |
+
background: rgba(10,14,26,0.82);
|
| 96 |
+
backdrop-filter: blur(16px) saturate(1.4);
|
| 97 |
+
border-bottom: 1px solid var(--border);
|
| 98 |
+
}
|
| 99 |
+
.topbar-inner {
|
| 100 |
+
max-width: 1320px;
|
| 101 |
+
margin: 0 auto;
|
| 102 |
+
padding: 0 24px;
|
| 103 |
+
height: 56px;
|
| 104 |
+
display: flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
justify-content: space-between;
|
| 107 |
+
}
|
| 108 |
+
.logo-group {
|
| 109 |
+
display: flex;
|
| 110 |
+
align-items: center;
|
| 111 |
+
gap: 10px;
|
| 112 |
+
}
|
| 113 |
+
.logo-icon { width: 28px; height: 28px; color: var(--accent); }
|
| 114 |
+
.logo-text {
|
| 115 |
+
font-size: 1.05rem;
|
| 116 |
+
font-weight: 700;
|
| 117 |
+
letter-spacing: -0.02em;
|
| 118 |
+
background: linear-gradient(135deg, #818cf8, #6366f1);
|
| 119 |
+
-webkit-background-clip: text;
|
| 120 |
+
-webkit-text-fill-color: transparent;
|
| 121 |
+
}
|
| 122 |
+
.topbar-right { display: flex; align-items: center; gap: 8px; }
|
| 123 |
+
|
| 124 |
+
/* --- Badges ------------------------------------------------- */
|
| 125 |
+
.badge {
|
| 126 |
+
display: inline-flex;
|
| 127 |
+
align-items: center;
|
| 128 |
+
gap: 6px;
|
| 129 |
+
padding: 4px 10px;
|
| 130 |
+
border-radius: 999px;
|
| 131 |
+
font-size: 0.73rem;
|
| 132 |
+
font-weight: 600;
|
| 133 |
+
letter-spacing: 0.02em;
|
| 134 |
+
text-transform: uppercase;
|
| 135 |
+
transition: all 0.3s;
|
| 136 |
+
}
|
| 137 |
+
/* Online state (green) */
|
| 138 |
+
.badge-env.badge-online,
|
| 139 |
+
.badge-env:not(.badge-offline) {
|
| 140 |
+
background: rgba(16,185,129,0.12);
|
| 141 |
+
color: #6ee7b7;
|
| 142 |
+
border: 1px solid rgba(16,185,129,0.25);
|
| 143 |
+
}
|
| 144 |
+
/* Offline state (amber/red) */
|
| 145 |
+
.badge-env.badge-offline {
|
| 146 |
+
background: rgba(239,68,68,0.12);
|
| 147 |
+
color: #fca5a5;
|
| 148 |
+
border: 1px solid rgba(239,68,68,0.25);
|
| 149 |
+
}
|
| 150 |
+
.badge-model {
|
| 151 |
+
background: rgba(99,102,241,0.12);
|
| 152 |
+
color: #a5b4fc;
|
| 153 |
+
border: 1px solid rgba(99,102,241,0.25);
|
| 154 |
+
font-family: var(--font-mono);
|
| 155 |
+
}
|
| 156 |
+
.pulse-dot {
|
| 157 |
+
width: 6px; height: 6px;
|
| 158 |
+
border-radius: 50%;
|
| 159 |
+
animation: pulse 2s ease-in-out infinite;
|
| 160 |
+
}
|
| 161 |
+
.pulse-dot.pulse-online {
|
| 162 |
+
background: var(--success);
|
| 163 |
+
}
|
| 164 |
+
.pulse-dot.pulse-offline {
|
| 165 |
+
background: var(--danger);
|
| 166 |
+
animation: pulse-fast 1.2s ease-in-out infinite;
|
| 167 |
+
}
|
| 168 |
+
@keyframes pulse {
|
| 169 |
+
0%, 100% { opacity: 1; }
|
| 170 |
+
50% { opacity: 0.35; }
|
| 171 |
+
}
|
| 172 |
+
@keyframes pulse-fast {
|
| 173 |
+
0%, 100% { opacity: 1; }
|
| 174 |
+
50% { opacity: 0.25; }
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* --- Main Layout -------------------------------------------- */
|
| 178 |
+
.main {
|
| 179 |
+
max-width: 1320px;
|
| 180 |
+
margin: 0 auto;
|
| 181 |
+
padding: 20px 24px 40px;
|
| 182 |
+
display: flex;
|
| 183 |
+
flex-direction: column;
|
| 184 |
+
gap: 18px;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/* --- Step Indicator ----------------------------------------- */
|
| 188 |
+
.steps {
|
| 189 |
+
display: flex;
|
| 190 |
+
align-items: center;
|
| 191 |
+
justify-content: center;
|
| 192 |
+
gap: 0;
|
| 193 |
+
padding: 14px 0 4px;
|
| 194 |
+
}
|
| 195 |
+
.step {
|
| 196 |
+
display: flex;
|
| 197 |
+
align-items: center;
|
| 198 |
+
gap: 8px;
|
| 199 |
+
opacity: 0.38;
|
| 200 |
+
transition: opacity 0.3s;
|
| 201 |
+
}
|
| 202 |
+
.step.active { opacity: 1; }
|
| 203 |
+
.step.done { opacity: 0.7; }
|
| 204 |
+
.step-num {
|
| 205 |
+
width: 28px; height: 28px;
|
| 206 |
+
display: grid;
|
| 207 |
+
place-items: center;
|
| 208 |
+
border-radius: 50%;
|
| 209 |
+
font-size: 0.78rem;
|
| 210 |
+
font-weight: 700;
|
| 211 |
+
background: var(--bg-raised);
|
| 212 |
+
border: 1.5px solid var(--border);
|
| 213 |
+
color: var(--text-secondary);
|
| 214 |
+
transition: all 0.3s;
|
| 215 |
+
}
|
| 216 |
+
.step.active .step-num {
|
| 217 |
+
background: var(--accent);
|
| 218 |
+
border-color: var(--accent);
|
| 219 |
+
color: #fff;
|
| 220 |
+
box-shadow: 0 0 12px var(--accent-glow);
|
| 221 |
+
}
|
| 222 |
+
.step.done .step-num {
|
| 223 |
+
background: var(--success);
|
| 224 |
+
border-color: var(--success);
|
| 225 |
+
color: #fff;
|
| 226 |
+
}
|
| 227 |
+
.step-label {
|
| 228 |
+
font-size: 0.82rem;
|
| 229 |
+
font-weight: 500;
|
| 230 |
+
color: var(--text-secondary);
|
| 231 |
+
white-space: nowrap;
|
| 232 |
+
}
|
| 233 |
+
.step.active .step-label { color: var(--text-primary); }
|
| 234 |
+
.step-line {
|
| 235 |
+
width: 40px;
|
| 236 |
+
height: 2px;
|
| 237 |
+
background: var(--border);
|
| 238 |
+
margin: 0 6px;
|
| 239 |
+
flex-shrink: 0;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
/* --- Panel (card) ------------------------------------------- */
|
| 243 |
+
.panel {
|
| 244 |
+
background: var(--bg-surface);
|
| 245 |
+
border: 1px solid var(--border);
|
| 246 |
+
border-radius: var(--radius-lg);
|
| 247 |
+
box-shadow: var(--shadow);
|
| 248 |
+
overflow: hidden;
|
| 249 |
+
}
|
| 250 |
+
.panel-head {
|
| 251 |
+
display: flex;
|
| 252 |
+
align-items: center;
|
| 253 |
+
justify-content: space-between;
|
| 254 |
+
padding: 14px 18px;
|
| 255 |
+
border-bottom: 1px solid var(--border);
|
| 256 |
+
}
|
| 257 |
+
.panel-title {
|
| 258 |
+
display: flex;
|
| 259 |
+
align-items: center;
|
| 260 |
+
gap: 8px;
|
| 261 |
+
font-size: 0.9rem;
|
| 262 |
+
font-weight: 600;
|
| 263 |
+
color: var(--text-primary);
|
| 264 |
+
}
|
| 265 |
+
.icon-sm { width: 16px; height: 16px; flex-shrink: 0; }
|
| 266 |
+
.icon-xs { width: 14px; height: 14px; }
|
| 267 |
+
.icon-btn { width: 16px; height: 16px; flex-shrink: 0; }
|
| 268 |
+
|
| 269 |
+
/* --- Upload Panel ------------------------------------------- */
|
| 270 |
+
.upload-panel { padding: 0; }
|
| 271 |
+
.drop-zone {
|
| 272 |
+
display: flex;
|
| 273 |
+
flex-direction: column;
|
| 274 |
+
align-items: center;
|
| 275 |
+
justify-content: center;
|
| 276 |
+
gap: 8px;
|
| 277 |
+
padding: 36px 24px;
|
| 278 |
+
cursor: pointer;
|
| 279 |
+
border: 2px dashed transparent;
|
| 280 |
+
transition: all 0.25s;
|
| 281 |
+
background: linear-gradient(180deg, rgba(99,102,241,0.04), transparent);
|
| 282 |
+
}
|
| 283 |
+
.drop-zone.drag-over {
|
| 284 |
+
border-color: var(--accent);
|
| 285 |
+
background: rgba(99,102,241,0.08);
|
| 286 |
+
}
|
| 287 |
+
.drop-icon { width: 44px; height: 44px; color: var(--accent); opacity: 0.7; }
|
| 288 |
+
.drop-title {
|
| 289 |
+
font-size: 1rem;
|
| 290 |
+
font-weight: 600;
|
| 291 |
+
color: var(--text-primary);
|
| 292 |
+
}
|
| 293 |
+
.drop-hint {
|
| 294 |
+
font-size: 0.8rem;
|
| 295 |
+
color: var(--text-muted);
|
| 296 |
+
}
|
| 297 |
+
.file-info {
|
| 298 |
+
display: flex;
|
| 299 |
+
align-items: center;
|
| 300 |
+
justify-content: space-between;
|
| 301 |
+
padding: 12px 18px;
|
| 302 |
+
background: rgba(99,102,241,0.06);
|
| 303 |
+
border-top: 1px solid var(--border);
|
| 304 |
+
}
|
| 305 |
+
.file-meta {
|
| 306 |
+
display: flex;
|
| 307 |
+
align-items: center;
|
| 308 |
+
gap: 8px;
|
| 309 |
+
font-size: 0.85rem;
|
| 310 |
+
font-weight: 500;
|
| 311 |
+
}
|
| 312 |
+
.file-icon { width: 16px; height: 16px; color: var(--accent); }
|
| 313 |
+
.file-size {
|
| 314 |
+
color: var(--text-muted);
|
| 315 |
+
font-size: 0.78rem;
|
| 316 |
+
font-family: var(--font-mono);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/* --- Two-column Grid ---------------------------------------- */
|
| 320 |
+
.grid-two {
|
| 321 |
+
display: grid;
|
| 322 |
+
grid-template-columns: 1.3fr 0.7fr;
|
| 323 |
+
gap: 18px;
|
| 324 |
+
}
|
| 325 |
+
@media (max-width: 960px) {
|
| 326 |
+
.grid-two { grid-template-columns: 1fr; }
|
| 327 |
+
.steps { flex-wrap: wrap; gap: 4px; }
|
| 328 |
+
.step-label { display: none; }
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
/* --- Video -------------------------------------------------- */
|
| 332 |
+
.video-panel .panel-head + * { padding: 0; }
|
| 333 |
+
.video-wrap {
|
| 334 |
+
position: relative;
|
| 335 |
+
background: #000;
|
| 336 |
+
aspect-ratio: 16/9;
|
| 337 |
+
display: flex;
|
| 338 |
+
align-items: center;
|
| 339 |
+
justify-content: center;
|
| 340 |
+
}
|
| 341 |
+
.video-wrap video {
|
| 342 |
+
width: 100%;
|
| 343 |
+
height: 100%;
|
| 344 |
+
object-fit: contain;
|
| 345 |
+
display: none;
|
| 346 |
+
}
|
| 347 |
+
.video-wrap video.has-src { display: block; }
|
| 348 |
+
.video-placeholder {
|
| 349 |
+
display: flex;
|
| 350 |
+
flex-direction: column;
|
| 351 |
+
align-items: center;
|
| 352 |
+
gap: 8px;
|
| 353 |
+
color: var(--text-muted);
|
| 354 |
+
font-size: 0.85rem;
|
| 355 |
+
}
|
| 356 |
+
.video-placeholder.hidden { display: none; }
|
| 357 |
+
.placeholder-icon { width: 56px; height: 56px; opacity: 0.3; }
|
| 358 |
+
|
| 359 |
+
/* --- Action Panel ------------------------------------------- */
|
| 360 |
+
.action-panel { display: flex; flex-direction: column; }
|
| 361 |
+
.action-stack {
|
| 362 |
+
padding: 18px;
|
| 363 |
+
display: flex;
|
| 364 |
+
flex-direction: column;
|
| 365 |
+
gap: 14px;
|
| 366 |
+
flex: 1;
|
| 367 |
+
}
|
| 368 |
+
.divider {
|
| 369 |
+
border: none;
|
| 370 |
+
border-top: 1px solid var(--border);
|
| 371 |
+
margin: 2px 0;
|
| 372 |
+
}
|
| 373 |
+
.export-title {
|
| 374 |
+
font-size: 0.78rem;
|
| 375 |
+
font-weight: 600;
|
| 376 |
+
color: var(--text-muted);
|
| 377 |
+
text-transform: uppercase;
|
| 378 |
+
letter-spacing: 0.06em;
|
| 379 |
+
margin-bottom: 6px;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
/* --- Buttons ------------------------------------------------ */
|
| 383 |
+
.btn {
|
| 384 |
+
display: inline-flex;
|
| 385 |
+
align-items: center;
|
| 386 |
+
justify-content: center;
|
| 387 |
+
gap: 8px;
|
| 388 |
+
font-family: var(--font-sans);
|
| 389 |
+
font-size: 0.85rem;
|
| 390 |
+
font-weight: 600;
|
| 391 |
+
border: 1px solid var(--border);
|
| 392 |
+
border-radius: var(--radius);
|
| 393 |
+
padding: 10px 16px;
|
| 394 |
+
cursor: pointer;
|
| 395 |
+
background: var(--bg-raised);
|
| 396 |
+
color: var(--text-primary);
|
| 397 |
+
transition: all 0.2s ease;
|
| 398 |
+
white-space: nowrap;
|
| 399 |
+
}
|
| 400 |
+
.btn:hover:not(:disabled) {
|
| 401 |
+
transform: translateY(-1px);
|
| 402 |
+
box-shadow: var(--shadow-sm);
|
| 403 |
+
}
|
| 404 |
+
.btn:active:not(:disabled) {
|
| 405 |
+
transform: translateY(0);
|
| 406 |
+
}
|
| 407 |
+
.btn:disabled {
|
| 408 |
+
opacity: 0.35;
|
| 409 |
+
cursor: not-allowed;
|
| 410 |
+
transform: none;
|
| 411 |
+
}
|
| 412 |
+
.btn-primary {
|
| 413 |
+
background: var(--accent);
|
| 414 |
+
border-color: transparent;
|
| 415 |
+
color: #fff;
|
| 416 |
+
}
|
| 417 |
+
.btn-primary:hover:not(:disabled) {
|
| 418 |
+
background: var(--accent-hover);
|
| 419 |
+
box-shadow: 0 4px 20px var(--accent-glow);
|
| 420 |
+
}
|
| 421 |
+
.btn-success {
|
| 422 |
+
background: var(--success);
|
| 423 |
+
border-color: transparent;
|
| 424 |
+
color: #fff;
|
| 425 |
+
}
|
| 426 |
+
.btn-success:hover:not(:disabled) {
|
| 427 |
+
background: #34d399;
|
| 428 |
+
box-shadow: 0 4px 20px rgba(16,185,129,0.3);
|
| 429 |
+
}
|
| 430 |
+
.btn-outline {
|
| 431 |
+
background: transparent;
|
| 432 |
+
border-color: var(--border);
|
| 433 |
+
}
|
| 434 |
+
.btn-outline:hover:not(:disabled) {
|
| 435 |
+
background: var(--bg-raised);
|
| 436 |
+
border-color: rgba(255,255,255,0.15);
|
| 437 |
+
}
|
| 438 |
+
.btn-ghost {
|
| 439 |
+
background: transparent;
|
| 440 |
+
border: none;
|
| 441 |
+
color: var(--text-secondary);
|
| 442 |
+
padding: 6px 10px;
|
| 443 |
+
}
|
| 444 |
+
.btn-ghost:hover:not(:disabled) {
|
| 445 |
+
color: var(--text-primary);
|
| 446 |
+
background: rgba(255,255,255,0.05);
|
| 447 |
+
}
|
| 448 |
+
.btn-danger-sm {
|
| 449 |
+
background: var(--danger-bg);
|
| 450 |
+
border: 1px solid rgba(239,68,68,0.25);
|
| 451 |
+
color: #fca5a5;
|
| 452 |
+
padding: 6px 10px;
|
| 453 |
+
font-size: 0.78rem;
|
| 454 |
+
}
|
| 455 |
+
.btn-danger-sm:hover:not(:disabled) {
|
| 456 |
+
background: rgba(239,68,68,0.2);
|
| 457 |
+
}
|
| 458 |
+
.btn-sm { padding: 6px 12px; font-size: 0.8rem; }
|
| 459 |
+
.btn-lg { padding: 12px 20px; font-size: 0.92rem; }
|
| 460 |
+
.btn-full { width: 100%; }
|
| 461 |
+
.btn-row {
|
| 462 |
+
display: flex;
|
| 463 |
+
gap: 8px;
|
| 464 |
+
flex-wrap: wrap;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
/* --- Progress ----------------------------------------------- */
|
| 468 |
+
.progress-wrap {
|
| 469 |
+
display: flex;
|
| 470 |
+
flex-direction: column;
|
| 471 |
+
gap: 6px;
|
| 472 |
+
}
|
| 473 |
+
.progress-bar {
|
| 474 |
+
height: 6px;
|
| 475 |
+
background: var(--bg-raised);
|
| 476 |
+
border-radius: 99px;
|
| 477 |
+
overflow: hidden;
|
| 478 |
+
}
|
| 479 |
+
.progress-fill {
|
| 480 |
+
height: 100%;
|
| 481 |
+
width: 0%;
|
| 482 |
+
background: linear-gradient(90deg, var(--accent), #818cf8);
|
| 483 |
+
border-radius: 99px;
|
| 484 |
+
transition: width 0.4s ease;
|
| 485 |
+
animation: progressPulse 1.5s ease-in-out infinite;
|
| 486 |
+
}
|
| 487 |
+
@keyframes progressPulse {
|
| 488 |
+
0%, 100% { opacity: 1; }
|
| 489 |
+
50% { opacity: 0.6; }
|
| 490 |
+
}
|
| 491 |
+
.progress-text {
|
| 492 |
+
font-size: 0.78rem;
|
| 493 |
+
color: var(--text-secondary);
|
| 494 |
+
font-weight: 500;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/* --- Status Box --------------------------------------------- */
|
| 498 |
+
.status-box {
|
| 499 |
+
display: flex;
|
| 500 |
+
align-items: center;
|
| 501 |
+
gap: 8px;
|
| 502 |
+
padding: 10px 14px;
|
| 503 |
+
border-radius: var(--radius);
|
| 504 |
+
font-size: 0.83rem;
|
| 505 |
+
font-weight: 500;
|
| 506 |
+
transition: all 0.3s;
|
| 507 |
+
}
|
| 508 |
+
.status-icon { width: 16px; height: 16px; flex-shrink: 0; }
|
| 509 |
+
.status-idle {
|
| 510 |
+
background: rgba(255,255,255,0.03);
|
| 511 |
+
color: var(--text-secondary);
|
| 512 |
+
}
|
| 513 |
+
.status-loading {
|
| 514 |
+
background: var(--warning-bg);
|
| 515 |
+
color: #fde68a;
|
| 516 |
+
}
|
| 517 |
+
.status-success {
|
| 518 |
+
background: var(--success-bg);
|
| 519 |
+
color: #6ee7b7;
|
| 520 |
+
}
|
| 521 |
+
.status-error {
|
| 522 |
+
background: var(--danger-bg);
|
| 523 |
+
color: #fca5a5;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
/* --- Download Links ----------------------------------------- */
|
| 527 |
+
.download-group {
|
| 528 |
+
display: flex;
|
| 529 |
+
gap: 10px;
|
| 530 |
+
flex-wrap: wrap;
|
| 531 |
+
}
|
| 532 |
+
.dl-link {
|
| 533 |
+
display: inline-flex;
|
| 534 |
+
align-items: center;
|
| 535 |
+
gap: 8px;
|
| 536 |
+
padding: 10px 16px;
|
| 537 |
+
border-radius: var(--radius);
|
| 538 |
+
font-size: 0.85rem;
|
| 539 |
+
font-weight: 600;
|
| 540 |
+
text-decoration: none;
|
| 541 |
+
transition: all 0.2s;
|
| 542 |
+
}
|
| 543 |
+
.dl-srt {
|
| 544 |
+
background: rgba(99,102,241,0.12);
|
| 545 |
+
color: #a5b4fc;
|
| 546 |
+
border: 1px solid rgba(99,102,241,0.25);
|
| 547 |
+
}
|
| 548 |
+
.dl-srt:hover { background: rgba(99,102,241,0.2); }
|
| 549 |
+
.dl-mp4 {
|
| 550 |
+
background: rgba(16,185,129,0.12);
|
| 551 |
+
color: #6ee7b7;
|
| 552 |
+
border: 1px solid rgba(16,185,129,0.25);
|
| 553 |
+
}
|
| 554 |
+
.dl-mp4:hover { background: rgba(16,185,129,0.2); }
|
| 555 |
+
.dl-link.disabled { pointer-events: none; opacity: 0.35; }
|
| 556 |
+
|
| 557 |
+
/* --- Table -------------------------------------------------- */
|
| 558 |
+
.table-panel { }
|
| 559 |
+
.table-meta {
|
| 560 |
+
display: flex;
|
| 561 |
+
align-items: center;
|
| 562 |
+
gap: 10px;
|
| 563 |
+
}
|
| 564 |
+
.seg-count {
|
| 565 |
+
font-size: 0.78rem;
|
| 566 |
+
font-weight: 600;
|
| 567 |
+
color: var(--text-muted);
|
| 568 |
+
font-family: var(--font-mono);
|
| 569 |
+
background: var(--bg-raised);
|
| 570 |
+
padding: 3px 10px;
|
| 571 |
+
border-radius: 999px;
|
| 572 |
+
}
|
| 573 |
+
.table-scroll {
|
| 574 |
+
overflow-x: auto;
|
| 575 |
+
max-height: 480px;
|
| 576 |
+
overflow-y: auto;
|
| 577 |
+
}
|
| 578 |
+
table {
|
| 579 |
+
width: 100%;
|
| 580 |
+
border-collapse: collapse;
|
| 581 |
+
min-width: 720px;
|
| 582 |
+
}
|
| 583 |
+
thead { position: sticky; top: 0; z-index: 5; }
|
| 584 |
+
th {
|
| 585 |
+
text-align: left;
|
| 586 |
+
padding: 10px 14px;
|
| 587 |
+
font-size: 0.73rem;
|
| 588 |
+
font-weight: 600;
|
| 589 |
+
text-transform: uppercase;
|
| 590 |
+
letter-spacing: 0.06em;
|
| 591 |
+
color: var(--text-muted);
|
| 592 |
+
background: var(--bg-raised);
|
| 593 |
+
border-bottom: 1px solid var(--border);
|
| 594 |
+
}
|
| 595 |
+
td {
|
| 596 |
+
padding: 8px 14px;
|
| 597 |
+
vertical-align: top;
|
| 598 |
+
border-bottom: 1px solid var(--border);
|
| 599 |
+
font-size: 0.85rem;
|
| 600 |
+
}
|
| 601 |
+
tr:last-child td { border-bottom: none; }
|
| 602 |
+
tr:hover td { background: rgba(255,255,255,0.02); }
|
| 603 |
+
.col-idx { width: 48px; text-align: center; }
|
| 604 |
+
.col-time { width: 155px; }
|
| 605 |
+
.col-text { }
|
| 606 |
+
.col-act { width: 72px; text-align: center; }
|
| 607 |
+
|
| 608 |
+
/* Row index number */
|
| 609 |
+
td.idx-cell {
|
| 610 |
+
text-align: center;
|
| 611 |
+
font-family: var(--font-mono);
|
| 612 |
+
font-size: 0.78rem;
|
| 613 |
+
color: var(--text-muted);
|
| 614 |
+
font-weight: 600;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
/* --- Table Inputs ------------------------------------------- */
|
| 618 |
+
.time-input, .text-input {
|
| 619 |
+
width: 100%;
|
| 620 |
+
border-radius: var(--radius-sm);
|
| 621 |
+
border: 1px solid var(--border);
|
| 622 |
+
background: var(--bg-input);
|
| 623 |
+
color: var(--text-primary);
|
| 624 |
+
padding: 8px 10px;
|
| 625 |
+
font-family: var(--font-mono);
|
| 626 |
+
font-size: 0.82rem;
|
| 627 |
+
transition: border-color 0.2s, box-shadow 0.2s;
|
| 628 |
+
}
|
| 629 |
+
.time-input:focus, .text-input:focus {
|
| 630 |
+
outline: none;
|
| 631 |
+
border-color: var(--border-focus);
|
| 632 |
+
box-shadow: 0 0 0 3px var(--accent-glow);
|
| 633 |
+
}
|
| 634 |
+
.text-input {
|
| 635 |
+
font-family: var(--font-sans);
|
| 636 |
+
resize: vertical;
|
| 637 |
+
min-height: 54px;
|
| 638 |
+
line-height: 1.45;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
/* --- Empty State -------------------------------------------- */
|
| 642 |
+
.empty-row td { padding: 40px 20px; }
|
| 643 |
+
.empty-state {
|
| 644 |
+
display: flex;
|
| 645 |
+
flex-direction: column;
|
| 646 |
+
align-items: center;
|
| 647 |
+
gap: 12px;
|
| 648 |
+
color: var(--text-muted);
|
| 649 |
+
text-align: center;
|
| 650 |
+
}
|
| 651 |
+
.empty-icon { width: 48px; height: 48px; opacity: 0.35; }
|
| 652 |
+
.empty-state p { font-size: 0.85rem; max-width: 380px; }
|
| 653 |
+
|
| 654 |
+
/* --- Footer ------------------------------------------------- */
|
| 655 |
+
.footer {
|
| 656 |
+
text-align: center;
|
| 657 |
+
padding: 20px 24px;
|
| 658 |
+
font-size: 0.75rem;
|
| 659 |
+
color: var(--text-muted);
|
| 660 |
+
border-top: 1px solid var(--border);
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
/* --- Utilities ---------------------------------------------- */
|
| 664 |
+
[hidden] { display: none !important; }
|
| 665 |
+
|
| 666 |
+
/* --- Scrollbar ---------------------------------------------- */
|
| 667 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 668 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 669 |
+
::-webkit-scrollbar-thumb {
|
| 670 |
+
background: rgba(255,255,255,0.1);
|
| 671 |
+
border-radius: 99px;
|
| 672 |
+
}
|
| 673 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
|
| 674 |
+
|
| 675 |
+
/* --- Spinner for loading button ----------------------------- */
|
| 676 |
+
.btn-loading {
|
| 677 |
+
position: relative;
|
| 678 |
+
pointer-events: none;
|
| 679 |
+
color: transparent !important;
|
| 680 |
+
}
|
| 681 |
+
.btn-loading::after {
|
| 682 |
+
content: '';
|
| 683 |
+
position: absolute;
|
| 684 |
+
width: 18px; height: 18px;
|
| 685 |
+
border: 2px solid rgba(255,255,255,0.3);
|
| 686 |
+
border-top-color: #fff;
|
| 687 |
+
border-radius: 50%;
|
| 688 |
+
animation: spin 0.6s linear infinite;
|
| 689 |
+
}
|
| 690 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 691 |
+
|
| 692 |
+
/* --- Responsive tweaks -------------------------------------- */
|
| 693 |
+
@media (max-width: 640px) {
|
| 694 |
+
.main { padding: 12px 12px 32px; gap: 12px; }
|
| 695 |
+
.topbar-inner { padding: 0 14px; }
|
| 696 |
+
.drop-zone { padding: 24px 16px; }
|
| 697 |
+
.action-stack { padding: 14px; }
|
| 698 |
+
.btn-lg { padding: 10px 14px; font-size: 0.85rem; }
|
| 699 |
+
table { min-width: 580px; }
|
| 700 |
+
.panel-head { padding: 12px 14px; }
|
| 701 |
+
.offline-banner { padding: 8px 14px; font-size: 0.78rem; }
|
| 702 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Viet AutoSub Editor</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
| 10 |
+
<link rel="stylesheet" href="static/styles.css" />
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
|
| 14 |
+
<!-- ===== OFFLINE BANNER ===== -->
|
| 15 |
+
<div class="offline-banner" id="offlineBanner" hidden>
|
| 16 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="offline-banner-icon">
|
| 17 |
+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
| 18 |
+
</svg>
|
| 19 |
+
<span id="offlineBannerText">Đang chạy offline — Chức năng AI (auto sub, xuất MP4) cần kết nối server HF Space.</span>
|
| 20 |
+
<button class="offline-banner-close" id="offlineBannerClose" title="Đóng">×</button>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<!-- ===== TOP NAV ===== -->
|
| 24 |
+
<nav class="topbar">
|
| 25 |
+
<div class="topbar-inner">
|
| 26 |
+
<div class="logo-group">
|
| 27 |
+
<svg class="logo-icon" viewBox="0 0 32 32" fill="none" aria-label="Viet AutoSub">
|
| 28 |
+
<rect x="2" y="6" width="28" height="20" rx="4" stroke="currentColor" stroke-width="2"/>
|
| 29 |
+
<rect x="6" y="20" width="20" height="4" rx="1.5" fill="currentColor" opacity="0.25"/>
|
| 30 |
+
<rect x="8" y="21" width="7" height="2" rx="1" fill="currentColor"/>
|
| 31 |
+
<rect x="17" y="21" width="5" height="2" rx="1" fill="currentColor" opacity="0.6"/>
|
| 32 |
+
<circle cx="16" cy="13" r="4" stroke="currentColor" stroke-width="1.5"/>
|
| 33 |
+
<polygon points="14.5,11.5 18.5,13 14.5,14.5" fill="currentColor"/>
|
| 34 |
+
</svg>
|
| 35 |
+
<span class="logo-text">Viet AutoSub</span>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="topbar-right">
|
| 38 |
+
<span class="badge badge-env" id="badgeEnv">
|
| 39 |
+
<span class="pulse-dot" id="pulseDot"></span>
|
| 40 |
+
<span id="badgeEnvText">Đang kiểm tra...</span>
|
| 41 |
+
</span>
|
| 42 |
+
<span class="badge badge-model" id="modelBadge">whisper-small</span>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
</nav>
|
| 46 |
+
|
| 47 |
+
<!-- ===== MAIN LAYOUT ===== -->
|
| 48 |
+
<main class="main">
|
| 49 |
+
|
| 50 |
+
<!-- ===== STEP INDICATOR ===== -->
|
| 51 |
+
<div class="steps">
|
| 52 |
+
<div class="step active" data-step="1">
|
| 53 |
+
<div class="step-num">1</div>
|
| 54 |
+
<div class="step-label">Upload video</div>
|
| 55 |
+
</div>
|
| 56 |
+
<div class="step-line"></div>
|
| 57 |
+
<div class="step" data-step="2">
|
| 58 |
+
<div class="step-num">2</div>
|
| 59 |
+
<div class="step-label">Auto sub tiếng Việt</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="step-line"></div>
|
| 62 |
+
<div class="step" data-step="3">
|
| 63 |
+
<div class="step-num">3</div>
|
| 64 |
+
<div class="step-label">Chỉnh sửa subtitle</div>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="step-line"></div>
|
| 67 |
+
<div class="step" data-step="4">
|
| 68 |
+
<div class="step-num">4</div>
|
| 69 |
+
<div class="step-label">Xuất SRT / MP4</div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<!-- ===== UPLOAD ZONE ===== -->
|
| 74 |
+
<section class="panel upload-panel" id="uploadPanel">
|
| 75 |
+
<div class="drop-zone" id="dropZone">
|
| 76 |
+
<svg class="drop-icon" viewBox="0 0 48 48" fill="none">
|
| 77 |
+
<rect x="4" y="8" width="40" height="32" rx="6" stroke="currentColor" stroke-width="2" stroke-dasharray="4 3"/>
|
| 78 |
+
<path d="M24 18v12M18 24l6-6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
| 79 |
+
</svg>
|
| 80 |
+
<p class="drop-title">Kéo thả video vào đây</p>
|
| 81 |
+
<p class="drop-hint">hoặc click để chọn file — MP4, MOV, MKV, AVI, WebM ≤ 250 MB</p>
|
| 82 |
+
<input id="videoFile" type="file" accept="video/*" hidden />
|
| 83 |
+
</div>
|
| 84 |
+
<div class="file-info" id="fileInfo" hidden>
|
| 85 |
+
<div class="file-meta">
|
| 86 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="file-icon"><path d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V7.414A2 2 0 0017.414 6L14 2.586A2 2 0 0012.586 2H4zm8 1.414L15.586 8H13a1 1 0 01-1-1V4.414zM4 5h6v2a3 3 0 003 3h2v5a1 1 0 01-1 1H4a1 1 0 01-1-1V5a1 1 0 011-1z"/></svg>
|
| 87 |
+
<span id="fileName">video.mp4</span>
|
| 88 |
+
<span class="file-size" id="fileSize">0 MB</span>
|
| 89 |
+
</div>
|
| 90 |
+
<button class="btn btn-ghost btn-sm" id="btnClearFile">Đổi file</button>
|
| 91 |
+
</div>
|
| 92 |
+
</section>
|
| 93 |
+
|
| 94 |
+
<!-- ===== TWO-COLUMN: VIDEO + CONTROLS ===== -->
|
| 95 |
+
<div class="grid-two">
|
| 96 |
+
|
| 97 |
+
<!-- LEFT: Video Preview -->
|
| 98 |
+
<section class="panel video-panel">
|
| 99 |
+
<div class="panel-head">
|
| 100 |
+
<h2 class="panel-title">
|
| 101 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z"/></svg>
|
| 102 |
+
Xem trước
|
| 103 |
+
</h2>
|
| 104 |
+
</div>
|
| 105 |
+
<div class="video-wrap">
|
| 106 |
+
<video id="preview" controls playsinline></video>
|
| 107 |
+
<div class="video-placeholder" id="videoPlaceholder">
|
| 108 |
+
<svg viewBox="0 0 64 64" fill="none" class="placeholder-icon">
|
| 109 |
+
<rect x="8" y="14" width="48" height="36" rx="6" stroke="currentColor" stroke-width="2"/>
|
| 110 |
+
<polygon points="26,24 42,32 26,40" fill="currentColor" opacity="0.3"/>
|
| 111 |
+
</svg>
|
| 112 |
+
<span>Chưa có video</span>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</section>
|
| 116 |
+
|
| 117 |
+
<!-- RIGHT: Action Panel -->
|
| 118 |
+
<section class="panel action-panel">
|
| 119 |
+
<div class="panel-head">
|
| 120 |
+
<h2 class="panel-title">
|
| 121 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
|
| 122 |
+
Điều khiển
|
| 123 |
+
</h2>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div class="action-stack">
|
| 127 |
+
<!-- Transcribe -->
|
| 128 |
+
<button id="btnTranscribe" class="btn btn-primary btn-lg btn-full">
|
| 129 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd"/></svg>
|
| 130 |
+
Auto sub tiếng Việt
|
| 131 |
+
</button>
|
| 132 |
+
|
| 133 |
+
<!-- Progress Bar -->
|
| 134 |
+
<div class="progress-wrap" id="progressWrap" hidden>
|
| 135 |
+
<div class="progress-bar">
|
| 136 |
+
<div class="progress-fill" id="progressFill"></div>
|
| 137 |
+
</div>
|
| 138 |
+
<span class="progress-text" id="progressText">Đang xử lý...</span>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<!-- Status -->
|
| 142 |
+
<div id="status" class="status-box status-idle">
|
| 143 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="status-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
|
| 144 |
+
<span id="statusText">Sẵn sàng. Hãy upload video để bắt đầu.</span>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<hr class="divider" />
|
| 148 |
+
|
| 149 |
+
<!-- Edit Actions -->
|
| 150 |
+
<div class="btn-row">
|
| 151 |
+
<button id="btnAddRow" class="btn btn-outline" disabled>
|
| 152 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
| 153 |
+
Thêm dòng
|
| 154 |
+
</button>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<hr class="divider" />
|
| 158 |
+
|
| 159 |
+
<!-- Export Actions -->
|
| 160 |
+
<div class="export-group">
|
| 161 |
+
<h3 class="export-title">Xuất file</h3>
|
| 162 |
+
<div class="btn-row">
|
| 163 |
+
<button id="btnExportSrt" class="btn btn-outline" disabled>
|
| 164 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
| 165 |
+
Xuất .SRT
|
| 166 |
+
</button>
|
| 167 |
+
<button id="btnExportMp4" class="btn btn-success" disabled>
|
| 168 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z"/></svg>
|
| 169 |
+
Xuất .MP4 burn sub
|
| 170 |
+
</button>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<!-- Download Links -->
|
| 175 |
+
<div class="download-group" id="downloadGroup" hidden>
|
| 176 |
+
<a id="downloadSrt" class="dl-link dl-srt" href="#" download>
|
| 177 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
| 178 |
+
Tải .SRT
|
| 179 |
+
</a>
|
| 180 |
+
<a id="downloadMp4" class="dl-link dl-mp4" href="#" download>
|
| 181 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
| 182 |
+
Tải .MP4
|
| 183 |
+
</a>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</section>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<!-- ===== SUBTITLE TABLE ===== -->
|
| 190 |
+
<section class="panel table-panel">
|
| 191 |
+
<div class="panel-head">
|
| 192 |
+
<h2 class="panel-title">
|
| 193 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
|
| 194 |
+
Bảng Subtitle
|
| 195 |
+
</h2>
|
| 196 |
+
<div class="table-meta">
|
| 197 |
+
<span class="seg-count" id="segmentCount">0 dòng</span>
|
| 198 |
+
<button class="btn btn-ghost btn-sm" id="btnCollapseTable" title="Thu gọn">
|
| 199 |
+
<svg viewBox="0 0 20 20" fill="currentColor" class="icon-xs"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
<div class="table-scroll" id="tableScroll">
|
| 204 |
+
<table>
|
| 205 |
+
<thead>
|
| 206 |
+
<tr>
|
| 207 |
+
<th class="col-idx">#</th>
|
| 208 |
+
<th class="col-time">Bắt đầu</th>
|
| 209 |
+
<th class="col-time">Kết thúc</th>
|
| 210 |
+
<th class="col-text">Nội dung</th>
|
| 211 |
+
<th class="col-act">Thao tác</th>
|
| 212 |
+
</tr>
|
| 213 |
+
</thead>
|
| 214 |
+
<tbody id="subtitleBody">
|
| 215 |
+
<tr class="empty-row">
|
| 216 |
+
<td colspan="5">
|
| 217 |
+
<div class="empty-state">
|
| 218 |
+
<svg viewBox="0 0 48 48" fill="none" class="empty-icon">
|
| 219 |
+
<rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
|
| 220 |
+
<line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
|
| 221 |
+
<line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
|
| 222 |
+
<line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
|
| 223 |
+
</svg>
|
| 224 |
+
<p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
|
| 225 |
+
</div>
|
| 226 |
+
</td>
|
| 227 |
+
</tr>
|
| 228 |
+
</tbody>
|
| 229 |
+
</table>
|
| 230 |
+
</div>
|
| 231 |
+
</section>
|
| 232 |
+
|
| 233 |
+
</main>
|
| 234 |
+
|
| 235 |
+
<!-- ===== FOOTER ===== -->
|
| 236 |
+
<footer class="footer">
|
| 237 |
+
<span>Viet AutoSub Editor — Nhận diện giọng nói tiếng Việt bằng Whisper</span>
|
| 238 |
+
</footer>
|
| 239 |
+
|
| 240 |
+
<script src="static/app.js"></script>
|
| 241 |
+
</body>
|
| 242 |
+
</html>
|