CVNSS commited on
Commit
1ff21b0
·
verified ·
1 Parent(s): 9b2d507

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +21 -0
  2. README.md +33 -7
  3. app.py +703 -0
  4. requirements.txt +6 -0
  5. static/app.js +792 -0
  6. static/styles.css +1056 -0
  7. templates/index.html +368 -0
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PORT=7860 \
6
+ WHISPER_MODEL_SIZE=small \
7
+ MAX_UPLOAD_MB=250 \
8
+ KEEP_HOURS=24
9
+
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ ffmpeg \
12
+ fonts-dejavu-core \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+ COPY requirements.txt ./
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ COPY . .
20
+ EXPOSE 7860
21
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,12 +1,38 @@
1
  ---
2
- title: VietAutoSub3
3
- emoji: 🐨
4
  colorFrom: red
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.9.0
8
- app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Viet AutoSub Editor
3
+ emoji: 🎬
4
  colorFrom: red
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: false
9
  ---
10
 
11
+ # Viet AutoSub Editor
12
+
13
+ Ứng dụng tối giản để:
14
+ - Upload video
15
+ - Tự động tạo subtitle tiếng Việt
16
+ - Sửa text và time trong bảng
17
+ - Xuất file `.srt`
18
+ - Xuất lại `.mp4` đã burn subtitle
19
+
20
+ ## Chạy local
21
+
22
+ ```bash
23
+ docker build -t viet-autosub .
24
+ docker run -p 7860:7860 viet-autosub
25
+ ```
26
+
27
+ Hoặc dùng Python trực tiếp:
28
+
29
+ ```bash
30
+ pip install -r requirements.txt
31
+ python app.py
32
+ ```
33
+
34
+ ## Ghi chú
35
+
36
+ - Mặc định dùng model `small` để cân bằng tốc độ và độ chính xác.
37
+ - Với video dài hoặc audio nhiều nhạc nền, nên nâng cấp phần model hoặc thêm căn chỉnh timestamp sâu hơn.
38
+ - Dữ liệu lưu tạm trong thư mục `workspace` và tự dọn sau 24 giờ.
app.py ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import threading
6
+ import uuid
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+ from fastapi.templating import Jinja2Templates
16
+ from faster_whisper import WhisperModel
17
+ from pydantic import BaseModel, Field
18
+
19
+
20
+ APP_DIR = Path(__file__).resolve().parent
21
+ WORK_DIR = APP_DIR / "workspace"
22
+ TEMPLATES_DIR = APP_DIR / "templates"
23
+ STATIC_DIR = APP_DIR / "static"
24
+ WORK_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+
27
+ app = FastAPI(title="Viet AutoSub Editor")
28
+ app.add_middleware(
29
+ CORSMiddleware,
30
+ allow_origins=["*"],
31
+ allow_credentials=True,
32
+ allow_methods=["*"],
33
+ allow_headers=["*"],
34
+ )
35
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
36
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
37
+
38
+
39
+ MODEL_LOCK = threading.Lock()
40
+ MODEL_CACHE = {}
41
+ DEFAULT_MODEL_SIZE = os.getenv("WHISPER_MODEL_SIZE", "small")
42
+ MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "250"))
43
+ KEEP_HOURS = int(os.getenv("KEEP_HOURS", "24"))
44
+
45
+
46
+ class SegmentIn(BaseModel):
47
+ id: int
48
+ start: str
49
+ end: str
50
+ text: str = Field(default="")
51
+
52
+
53
+ class SubtitleStyle(BaseModel):
54
+ font_name: str = "DejaVu Sans"
55
+ font_color: str = "#FFFFFF" # Hex color for text
56
+ highlight_color: str = "#FFD700" # Hex color for karaoke highlight
57
+ outline_color: str = "#000000" # Hex color for outline
58
+ outline_width: int = 2 # Outline thickness (px)
59
+ font_size_pct: int = 100 # Font size percentage (50-200)
60
+ position_pct: int = 90 # Vertical position 0=top, 100=bottom
61
+ karaoke_mode: bool = False # Word-by-word karaoke highlight
62
+
63
+
64
+ class ExportRequest(BaseModel):
65
+ job_id: str
66
+ segments: List[SegmentIn]
67
+ burn_in: bool = True
68
+ style: Optional[SubtitleStyle] = None
69
+
70
+
71
+ class SegmentOut(BaseModel):
72
+ id: int
73
+ start: float
74
+ end: float
75
+ text: str
76
+
77
+
78
+
79
+ def cleanup_old_jobs() -> None:
80
+ cutoff = datetime.utcnow() - timedelta(hours=KEEP_HOURS)
81
+ for folder in WORK_DIR.iterdir():
82
+ if not folder.is_dir():
83
+ continue
84
+ try:
85
+ modified = datetime.utcfromtimestamp(folder.stat().st_mtime)
86
+ if modified < cutoff:
87
+ shutil.rmtree(folder, ignore_errors=True)
88
+ except Exception:
89
+ continue
90
+
91
+
92
+
93
+ def get_model(model_size: str = DEFAULT_MODEL_SIZE) -> WhisperModel:
94
+ with MODEL_LOCK:
95
+ if model_size not in MODEL_CACHE:
96
+ MODEL_CACHE[model_size] = WhisperModel(
97
+ model_size,
98
+ device="cpu",
99
+ compute_type="int8",
100
+ )
101
+ return MODEL_CACHE[model_size]
102
+
103
+
104
+
105
+ def ffmpeg_exists() -> bool:
106
+ return shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
107
+
108
+
109
+
110
+ def save_upload(upload: UploadFile, target_dir: Path) -> Path:
111
+ suffix = Path(upload.filename or "video.mp4").suffix or ".mp4"
112
+ video_path = target_dir / f"source{suffix}"
113
+ with video_path.open("wb") as f:
114
+ while True:
115
+ chunk = upload.file.read(1024 * 1024)
116
+ if not chunk:
117
+ break
118
+ f.write(chunk)
119
+ if f.tell() > MAX_UPLOAD_MB * 1024 * 1024:
120
+ raise HTTPException(status_code=413, detail=f"File quá lớn. Giới hạn {MAX_UPLOAD_MB} MB.")
121
+ return video_path
122
+
123
+
124
+
125
+ def run_ffprobe_duration(video_path: Path) -> Optional[float]:
126
+ try:
127
+ cmd = [
128
+ "ffprobe",
129
+ "-v",
130
+ "error",
131
+ "-show_entries",
132
+ "format=duration",
133
+ "-of",
134
+ "default=noprint_wrappers=1:nokey=1",
135
+ str(video_path),
136
+ ]
137
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
138
+ return float(result.stdout.strip())
139
+ except Exception:
140
+ return None
141
+
142
+
143
+ # ============================================================
144
+ # TRANSCRIPTION — 2 chế độ: "music" (lời bài hát) và "speech" (giọng nói)
145
+ # ============================================================
146
+
147
+ def merge_segments_music(raw_segments: list, max_gap: float = 0.8, max_len: float = 8.0) -> list:
148
+ """
149
+ Gộp các segment ngắn liên tiếp thành câu dài hơn, phù hợp lời bài hát.
150
+ - max_gap: khoảng trống tối đa giữa 2 segment để gộp (giây)
151
+ - max_len: độ dài tối đa 1 segment sau gộp (giây)
152
+ """
153
+ if not raw_segments:
154
+ return []
155
+
156
+ merged = []
157
+ current = {
158
+ "start": raw_segments[0]["start"],
159
+ "end": raw_segments[0]["end"],
160
+ "text": raw_segments[0]["text"],
161
+ }
162
+
163
+ for seg in raw_segments[1:]:
164
+ gap = seg["start"] - current["end"]
165
+ new_duration = seg["end"] - current["start"]
166
+
167
+ # Gộp nếu: khoảng trống nhỏ VÀ tổng thời lượng không quá dài
168
+ if gap <= max_gap and new_duration <= max_len:
169
+ current["end"] = seg["end"]
170
+ current["text"] = current["text"] + " " + seg["text"]
171
+ else:
172
+ merged.append(current)
173
+ current = {
174
+ "start": seg["start"],
175
+ "end": seg["end"],
176
+ "text": seg["text"],
177
+ }
178
+
179
+ merged.append(current)
180
+ return merged
181
+
182
+
183
+ def fill_timeline_gaps(segments: list, total_duration: Optional[float] = None, min_gap: float = 0.3) -> list:
184
+ """
185
+ Lấp khoảng trống lớn giữa các segment.
186
+ Nếu khoảng trống > min_gap, điều chỉnh end/start của segment kề cho liền mạch.
187
+ Giúp subtitle phủ toàn bộ timeline video.
188
+ """
189
+ if not segments:
190
+ return segments
191
+
192
+ result = []
193
+ for i, seg in enumerate(segments):
194
+ s = dict(seg)
195
+
196
+ # Kéo start sớm hơn để lấp gap phía trước
197
+ if i > 0:
198
+ prev_end = result[-1]["end"]
199
+ gap = s["start"] - prev_end
200
+ if 0 < gap <= 1.5:
201
+ # Gap nhỏ: kéo start segment hiện tại lùi lại
202
+ s["start"] = prev_end
203
+ elif gap > 1.5:
204
+ # Gap lớn: kéo end segment trước ra + kéo start hiện tại lùi
205
+ half = gap / 2
206
+ result[-1]["end"] = prev_end + min(half, 0.5)
207
+ s["start"] = s["start"] - min(half, 0.5)
208
+
209
+ result.append(s)
210
+
211
+ # Xử lý end của segment cuối nếu có total_duration
212
+ if total_duration and result:
213
+ last = result[-1]
214
+ remaining = total_duration - last["end"]
215
+ if 0 < remaining <= 2.0:
216
+ last["end"] = total_duration
217
+
218
+ return result
219
+
220
+
221
+ def transcribe_video_music(video_path: Path, duration: Optional[float] = None,
222
+ model_size: str = DEFAULT_MODEL_SIZE) -> List[SegmentOut]:
223
+ """
224
+ Chế độ LỜI BÀI HÁT: tối ưu để nhận diện toàn bộ lyrics.
225
+ - Tắt VAD filter (không cắt đoạn nhạc nền)
226
+ - Tăng beam_size cho accuracy
227
+ - Bật word_timestamps cho khớp chính xác
228
+ - Gộp segment thông minh
229
+ - Lấp khoảng trống timeline
230
+ """
231
+ model = get_model(model_size)
232
+
233
+ segments, info = model.transcribe(
234
+ str(video_path),
235
+ language="vi",
236
+ vad_filter=False, # QUAN TRỌNG: tắt VAD để không bỏ sót lời hát
237
+ beam_size=8, # Tăng beam cho accuracy lời bài hát
238
+ best_of=5, # Sample nhiều hơn, chọn tốt nhất
239
+ patience=1.5, # Kiên nhẫn hơn khi decode
240
+ condition_on_previous_text=True,
241
+ word_timestamps=True, # Timestamp cấp từ → khớp chính xác
242
+ no_speech_threshold=0.3, # Hạ threshold → ít bỏ sót đoạn hát nhỏ
243
+ log_prob_threshold=-1.5, # Chấp nhận xác suất thấp hơn (lời hát khó nghe)
244
+ compression_ratio_threshold=2.8, # Nới ngưỡng nén → ít reject segment
245
+ )
246
+
247
+ raw: list = []
248
+ for seg in segments:
249
+ text = (seg.text or "").strip()
250
+ if not text:
251
+ continue
252
+ raw.append({
253
+ "start": float(seg.start),
254
+ "end": float(seg.end),
255
+ "text": text,
256
+ })
257
+
258
+ if not raw:
259
+ raise HTTPException(status_code=400, detail="Không nhận diện được lời thoại/lời hát trong video.")
260
+
261
+ # Gộp segment ngắn thành câu lời bài hát tự nhiên
262
+ merged = merge_segments_music(raw, max_gap=0.8, max_len=8.0)
263
+
264
+ # Lấp khoảng trống timeline
265
+ filled = fill_timeline_gaps(merged, total_duration=duration)
266
+
267
+ rows: List[SegmentOut] = []
268
+ for idx, seg in enumerate(filled, start=1):
269
+ rows.append(SegmentOut(
270
+ id=idx,
271
+ start=seg["start"],
272
+ end=seg["end"],
273
+ text=seg["text"],
274
+ ))
275
+
276
+ return rows
277
+
278
+
279
+ def transcribe_video_speech(video_path: Path, model_size: str = DEFAULT_MODEL_SIZE) -> List[SegmentOut]:
280
+ """
281
+ Chế độ GIỌNG NÓI: giữ nguyên logic cũ, tối ưu cho lời thoại/thuyết trình.
282
+ - Bật VAD filter (lọc tiếng ồn)
283
+ - beam_size vừa phải
284
+ """
285
+ model = get_model(model_size)
286
+ segments, _info = model.transcribe(
287
+ str(video_path),
288
+ language="vi",
289
+ vad_filter=True,
290
+ beam_size=5,
291
+ condition_on_previous_text=True,
292
+ )
293
+ rows: List[SegmentOut] = []
294
+ for idx, seg in enumerate(segments, start=1):
295
+ text = (seg.text or "").strip()
296
+ if not text:
297
+ continue
298
+ rows.append(
299
+ SegmentOut(
300
+ id=idx,
301
+ start=float(seg.start),
302
+ end=float(seg.end),
303
+ text=text,
304
+ )
305
+ )
306
+ if not rows:
307
+ raise HTTPException(status_code=400, detail="Không nhận diện được lời thoại trong video.")
308
+ return rows
309
+
310
+
311
+
312
+ def format_srt_time(seconds: float) -> str:
313
+ total_ms = max(0, int(round(seconds * 1000)))
314
+ hours = total_ms // 3600000
315
+ total_ms %= 3600000
316
+ minutes = total_ms // 60000
317
+ total_ms %= 60000
318
+ secs = total_ms // 1000
319
+ millis = total_ms % 1000
320
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
321
+
322
+
323
+
324
+ def parse_time_string(value: str) -> float:
325
+ value = value.strip()
326
+ if not value:
327
+ return 0.0
328
+ value = value.replace(".", ",")
329
+ try:
330
+ hhmmss, ms = value.split(",") if "," in value else (value, "0")
331
+ parts = hhmmss.split(":")
332
+ if len(parts) == 2:
333
+ hours = 0
334
+ minutes, secs = parts
335
+ elif len(parts) == 3:
336
+ hours, minutes, secs = parts
337
+ else:
338
+ raise ValueError
339
+ return int(hours) * 3600 + int(minutes) * 60 + int(secs) + int(ms.ljust(3, "0")[:3]) / 1000.0
340
+ except Exception as exc:
341
+ raise HTTPException(status_code=400, detail=f"Sai định dạng thời gian: {value}") from exc
342
+
343
+
344
+
345
+ def write_srt(job_dir: Path, segments: List[SegmentIn]) -> Path:
346
+ srt_path = job_dir / "edited.srt"
347
+ lines: List[str] = []
348
+ cleaned = sorted(segments, key=lambda s: parse_time_string(s.start))
349
+ for idx, seg in enumerate(cleaned, start=1):
350
+ start_sec = parse_time_string(seg.start)
351
+ end_sec = parse_time_string(seg.end)
352
+ if end_sec <= start_sec:
353
+ end_sec = start_sec + 1.0
354
+ text = (seg.text or "").strip()
355
+ if not text:
356
+ continue
357
+ lines.extend(
358
+ [
359
+ str(idx),
360
+ f"{format_srt_time(start_sec)} --> {format_srt_time(end_sec)}",
361
+ text,
362
+ "",
363
+ ]
364
+ )
365
+ if not lines:
366
+ raise HTTPException(status_code=400, detail="Không có subtitle hợp lệ để xuất SRT.")
367
+ srt_path.write_text("\n".join(lines), encoding="utf-8")
368
+ return srt_path
369
+
370
+
371
+
372
+ def hex_to_ass_color(hex_color: str) -> str:
373
+ """
374
+ Chuyển đổi hex color (#RRGGBB) thành ASS color (&HBBGGRR&).
375
+ ASS dùng format BGR ngược lại.
376
+ """
377
+ h = hex_color.lstrip("#")
378
+ if len(h) != 6:
379
+ h = "FFFFFF" # fallback white
380
+ r, g, b = h[0:2], h[2:4], h[4:6]
381
+ return f"&H00{b.upper()}{g.upper()}{r.upper()}&"
382
+
383
+
384
+ def build_force_style(style: Optional["SubtitleStyle"] = None) -> str:
385
+ """
386
+ Tạo chuỗi force_style cho FFmpeg subtitles filter dựa trên SubtitleStyle.
387
+ """
388
+ if style is None:
389
+ return "FontName=DejaVu Sans,FontSize=20,Outline=1,Shadow=0,MarginV=18,Alignment=2"
390
+
391
+ # Font name — dùng font_name gửi từ frontend
392
+ font_name = style.font_name or "DejaVu Sans"
393
+
394
+ # Font size: base 20, scale theo pct
395
+ base_size = 20
396
+ font_size = max(10, int(base_size * style.font_size_pct / 100))
397
+
398
+ # Colors (ASS format)
399
+ primary_color = hex_to_ass_color(style.font_color)
400
+ outline_color = hex_to_ass_color(style.outline_color)
401
+
402
+ # Outline width
403
+ outline = max(0, min(6, style.outline_width))
404
+
405
+ # MarginV: convert position_pct (0=top, 100=bottom)
406
+ # ASS MarginV: khoảng cách từ cạnh (lớn = xa cạnh dưới hơn = lên cao hơn)
407
+ # position_pct 90 = gần đáy → MarginV nhỏ
408
+ # position_pct 10 = gần đỉnh → MarginV lớn
409
+ # Quy đổi: MarginV = (100 - position_pct) * 3, clamp 5..280
410
+ margin_v = max(5, min(280, int((100 - style.position_pct) * 3)))
411
+
412
+ # Alignment: 2 = bottom center (mặc định phụ đề)
413
+ # Nếu position < 50, dùng alignment 8 (top center)
414
+ alignment = 8 if style.position_pct < 40 else 2
415
+
416
+ parts = [
417
+ f"FontName={font_name}",
418
+ f"FontSize={font_size}",
419
+ f"PrimaryColour={primary_color}",
420
+ f"OutlineColour={outline_color}",
421
+ f"Outline={outline}",
422
+ f"Shadow=0",
423
+ f"MarginV={margin_v}",
424
+ f"Alignment={alignment}",
425
+ f"Bold=1",
426
+ ]
427
+ return ",".join(parts)
428
+
429
+
430
+ def write_ass_karaoke(job_dir: Path, segments: List["SegmentIn"], style: Optional["SubtitleStyle"] = None) -> Path:
431
+ """
432
+ Tạo file ASS với karaoke word-by-word highlight (\kf tags).
433
+ Mỗi từ được highlight lần lượt theo thời gian segment.
434
+ """
435
+ ass_path = job_dir / "karaoke.ass"
436
+ s = style or SubtitleStyle()
437
+
438
+ font_name = s.font_name or "DejaVu Sans"
439
+ base_size = 20
440
+ font_size = max(10, int(base_size * s.font_size_pct / 100))
441
+ primary_color = hex_to_ass_color(s.font_color)
442
+ highlight_color = hex_to_ass_color(s.highlight_color)
443
+ outline_color = hex_to_ass_color(s.outline_color)
444
+ outline = max(0, min(6, s.outline_width))
445
+ margin_v = max(5, min(280, int((100 - s.position_pct) * 3)))
446
+ alignment = 8 if s.position_pct < 40 else 2
447
+
448
+ header = f"""[Script Info]
449
+ Title: Viet AutoSub Karaoke
450
+ ScriptType: v4.00+
451
+ PlayResX: 1280
452
+ PlayResY: 720
453
+ ScaledBorderAndShadow: yes
454
+
455
+ [V4+ Styles]
456
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
457
+ Style: Default,{font_name},{font_size},{primary_color},{highlight_color},{outline_color},&H80000000&,1,0,0,0,100,100,0,0,1,{outline},0,{alignment},20,20,{margin_v},1
458
+
459
+ [Events]
460
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
461
+ """
462
+ lines_out: List[str] = [header.strip()]
463
+
464
+ cleaned = sorted(segments, key=lambda seg: parse_time_string(seg.start))
465
+ for seg in cleaned:
466
+ text = (seg.text or "").strip()
467
+ if not text:
468
+ continue
469
+
470
+ start_sec = parse_time_string(seg.start)
471
+ end_sec = parse_time_string(seg.end)
472
+ if end_sec <= start_sec:
473
+ end_sec = start_sec + 1.0
474
+
475
+ # ASS time format: H:MM:SS.cc
476
+ def sec_to_ass(seconds: float) -> str:
477
+ total_cs = max(0, int(round(seconds * 100)))
478
+ h = total_cs // 360000
479
+ total_cs %= 360000
480
+ m = total_cs // 6000
481
+ total_cs %= 6000
482
+ ss = total_cs // 100
483
+ cs = total_cs % 100
484
+ return f"{h}:{m:02d}:{ss:02d}.{cs:02d}"
485
+
486
+ ass_start = sec_to_ass(start_sec)
487
+ ass_end = sec_to_ass(end_sec)
488
+
489
+ # Split text into words, distribute time evenly
490
+ words = text.split()
491
+ if not words:
492
+ continue
493
+
494
+ duration_cs = max(1, int(round((end_sec - start_sec) * 100)))
495
+ per_word_cs = max(1, duration_cs // len(words))
496
+
497
+ # Build karaoke text with \kf tags
498
+ # \kf = smooth fill karaoke effect
499
+ karaoke_parts = []
500
+ for word in words:
501
+ karaoke_parts.append(f"{{\\kf{per_word_cs}}}{word}")
502
+
503
+ karaoke_text = " ".join(karaoke_parts)
504
+ # Override highlight color for karaoke fill: use SecondaryColour via \1c for filled portion
505
+ # Use \K (uppercase) style coloring: {\1c&highlight&} before karaoke
506
+ color_override = f"{{\\1c{highlight_color}}}"
507
+ line = f"Dialogue: 0,{ass_start},{ass_end},Default,,0,0,0,,{color_override}{karaoke_text}"
508
+ lines_out.append(line)
509
+
510
+ ass_path.write_text("\n".join(lines_out), encoding="utf-8")
511
+ return ass_path
512
+
513
+
514
+ def burn_subtitles(job_dir: Path, video_path: Path, srt_path: Path,
515
+ style: Optional["SubtitleStyle"] = None) -> Path:
516
+ output_path = job_dir / "output_subtitled.mp4"
517
+
518
+ # Xác định dùng karaoke ASS hay SRT thường
519
+ if style and style.karaoke_mode:
520
+ # Tạo file ASS karaoke
521
+ ass_path = write_ass_karaoke(job_dir, [], style) # placeholder, sẽ được ghi đè bên dưới
522
+ subtitle_filter = f"ass=karaoke.ass"
523
+ else:
524
+ force_style = build_force_style(style)
525
+ subtitle_filter = f"subtitles=edited.srt:force_style='{force_style}'"
526
+
527
+ cmd = [
528
+ "ffmpeg",
529
+ "-y",
530
+ "-i",
531
+ video_path.name,
532
+ "-vf",
533
+ subtitle_filter,
534
+ "-c:v",
535
+ "libx264",
536
+ "-preset",
537
+ "veryfast",
538
+ "-crf",
539
+ "23",
540
+ "-c:a",
541
+ "aac",
542
+ "-b:a",
543
+ "192k",
544
+ output_path.name,
545
+ ]
546
+ try:
547
+ subprocess.run(cmd, cwd=job_dir, capture_output=True, text=True, check=True)
548
+ except subprocess.CalledProcessError as exc:
549
+ stderr = (exc.stderr or "").strip()
550
+ raise HTTPException(status_code=500, detail=f"FFmpeg lỗi khi xuất MP4: {stderr[:1200]}") from exc
551
+ return output_path
552
+
553
+
554
+
555
+ def job_meta_path(job_dir: Path) -> Path:
556
+ return job_dir / "meta.json"
557
+
558
+
559
+
560
+ def save_job_meta(job_dir: Path, data: dict) -> None:
561
+ job_meta_path(job_dir).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
562
+
563
+
564
+
565
+ def load_job_meta(job_id: str) -> dict:
566
+ meta = job_meta_path(WORK_DIR / job_id)
567
+ if not meta.exists():
568
+ raise HTTPException(status_code=404, detail="Không tìm thấy job.")
569
+ return json.loads(meta.read_text(encoding="utf-8"))
570
+
571
+
572
+ @app.get("/", response_class=HTMLResponse)
573
+ def home(request: Request):
574
+ return templates.TemplateResponse("index.html", {"request": request})
575
+
576
+
577
+ @app.get("/health")
578
+ def health():
579
+ return {
580
+ "ok": True,
581
+ "ffmpeg": ffmpeg_exists(),
582
+ "workspace": str(WORK_DIR),
583
+ "default_model": DEFAULT_MODEL_SIZE,
584
+ }
585
+
586
+
587
+ @app.post("/api/transcribe")
588
+ def api_transcribe(
589
+ file: UploadFile = File(...),
590
+ mode: str = Form(default="music"),
591
+ ):
592
+ """
593
+ mode: "music" (lời bài hát) hoặc "speech" (giọng nói/thuyết trình)
594
+ """
595
+ cleanup_old_jobs()
596
+ if not ffmpeg_exists():
597
+ raise HTTPException(status_code=500, detail="Máy chủ chưa có FFmpeg.")
598
+
599
+ filename = file.filename or "video.mp4"
600
+ if not filename.lower().endswith((".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v")):
601
+ raise HTTPException(status_code=400, detail="Chỉ hỗ trợ video mp4, mov, mkv, avi, webm, m4v.")
602
+
603
+ if mode not in ("music", "speech"):
604
+ mode = "music"
605
+
606
+ job_id = uuid.uuid4().hex
607
+ job_dir = WORK_DIR / job_id
608
+ job_dir.mkdir(parents=True, exist_ok=True)
609
+ try:
610
+ video_path = save_upload(file, job_dir)
611
+ duration = run_ffprobe_duration(video_path)
612
+
613
+ if mode == "music":
614
+ segments = transcribe_video_music(video_path, duration=duration)
615
+ else:
616
+ segments = transcribe_video_speech(video_path)
617
+
618
+ # Tính coverage: tổng thời lượng sub / tổng video
619
+ total_sub_time = sum(s.end - s.start for s in segments)
620
+ coverage_pct = round((total_sub_time / duration * 100), 1) if duration and duration > 0 else 0
621
+
622
+ save_job_meta(
623
+ job_dir,
624
+ {
625
+ "job_id": job_id,
626
+ "video_path": video_path.name,
627
+ "duration": duration,
628
+ "mode": mode,
629
+ "created_at": datetime.utcnow().isoformat() + "Z",
630
+ },
631
+ )
632
+ return JSONResponse(
633
+ {
634
+ "job_id": job_id,
635
+ "duration": duration,
636
+ "mode": mode,
637
+ "coverage_pct": coverage_pct,
638
+ "segments": [
639
+ {
640
+ "id": seg.id,
641
+ "start": format_srt_time(seg.start),
642
+ "end": format_srt_time(seg.end),
643
+ "text": seg.text,
644
+ }
645
+ for seg in segments
646
+ ],
647
+ }
648
+ )
649
+ except Exception:
650
+ shutil.rmtree(job_dir, ignore_errors=True)
651
+ raise
652
+
653
+
654
+ @app.post("/api/export")
655
+ def api_export(payload: ExportRequest):
656
+ job_dir = WORK_DIR / payload.job_id
657
+ if not job_dir.exists():
658
+ raise HTTPException(status_code=404, detail="Job đã hết hạn hoặc không tồn tại.")
659
+
660
+ meta = load_job_meta(payload.job_id)
661
+ video_path = job_dir / meta["video_path"]
662
+ if not video_path.exists():
663
+ raise HTTPException(status_code=404, detail="Không tìm thấy video gốc để xuất lại.")
664
+
665
+ srt_path = write_srt(job_dir, payload.segments)
666
+ response = {
667
+ "job_id": payload.job_id,
668
+ "srt_url": f"/download/{payload.job_id}/srt",
669
+ "mp4_url": None,
670
+ }
671
+
672
+ if payload.burn_in:
673
+ # Nếu karaoke mode, tạo file ASS từ segments
674
+ if payload.style and payload.style.karaoke_mode:
675
+ write_ass_karaoke(job_dir, payload.segments, payload.style)
676
+ mp4_path = burn_subtitles(job_dir, video_path, srt_path, style=payload.style)
677
+ response["mp4_url"] = f"/download/{payload.job_id}/mp4"
678
+ response["mp4_size_mb"] = round(mp4_path.stat().st_size / (1024 * 1024), 2)
679
+
680
+ return JSONResponse(response)
681
+
682
+
683
+ @app.get("/download/{job_id}/srt")
684
+ def download_srt(job_id: str):
685
+ path = WORK_DIR / job_id / "edited.srt"
686
+ if not path.exists():
687
+ raise HTTPException(status_code=404, detail="Chưa có file SRT.")
688
+ return FileResponse(path, media_type="application/x-subrip", filename=f"{job_id}.srt")
689
+
690
+
691
+ @app.get("/download/{job_id}/mp4")
692
+ def download_mp4(job_id: str):
693
+ path = WORK_DIR / job_id / "output_subtitled.mp4"
694
+ if not path.exists():
695
+ raise HTTPException(status_code=404, detail="Chưa có file MP4.")
696
+ return FileResponse(path, media_type="video/mp4", filename=f"{job_id}.mp4")
697
+
698
+
699
+ if __name__ == "__main__":
700
+ import uvicorn
701
+
702
+ port = int(os.getenv("PORT", "7860"))
703
+ uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.12
2
+ uvicorn[standard]==0.34.0
3
+ jinja2==3.1.6
4
+ python-multipart==0.0.20
5
+ requests>=2.31.0
6
+ faster-whisper==1.1.1
static/app.js ADDED
@@ -0,0 +1,792 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Viet AutoSub Editor – Dashboard JavaScript
3
+ Tương thích cả offline (file://) và online (HF Spaces)
4
+ Hỗ trợ 2 chế độ: Lời bài hát (music) + Giọng nói (speech)
5
+ ============================================================ */
6
+
7
+ const state = {
8
+ jobId: null,
9
+ file: null,
10
+ segments: [],
11
+ isOnline: false,
12
+ mode: "music", // "music" | "speech"
13
+ karaokeStyle: {
14
+ font: "Bangers",
15
+ color: "#FFFFFF",
16
+ highlight: "#FFD700",
17
+ outline: "#000000",
18
+ outlineWidth: 2,
19
+ sizePct: 100,
20
+ positionPct: 90,
21
+ karaokeMode: false,
22
+ },
23
+ };
24
+
25
+ /* --- Detect environment ------------------------------------ */
26
+ const IS_FILE_PROTOCOL = window.location.protocol === "file:";
27
+
28
+ function getApiBase() {
29
+ if (IS_FILE_PROTOCOL) return null;
30
+ return "";
31
+ }
32
+
33
+ /* --- DOM refs ---------------------------------------------- */
34
+ const $ = (id) => document.getElementById(id);
35
+
36
+ const els = {
37
+ fileInput: $("videoFile"),
38
+ preview: $("preview"),
39
+ videoPlaceholder:$("videoPlaceholder"),
40
+ status: $("status"),
41
+ statusText: $("statusText"),
42
+ btnTranscribe: $("btnTranscribe"),
43
+ btnAddRow: $("btnAddRow"),
44
+ btnExportSrt: $("btnExportSrt"),
45
+ btnExportMp4: $("btnExportMp4"),
46
+ btnClearFile: $("btnClearFile"),
47
+ subtitleBody: $("subtitleBody"),
48
+ segmentCount: $("segmentCount"),
49
+ downloadSrt: $("downloadSrt"),
50
+ downloadMp4: $("downloadMp4"),
51
+ downloadGroup: $("downloadGroup"),
52
+ dropZone: $("dropZone"),
53
+ uploadPanel: $("uploadPanel"),
54
+ fileInfo: $("fileInfo"),
55
+ fileName: $("fileName"),
56
+ fileSize: $("fileSize"),
57
+ progressWrap: $("progressWrap"),
58
+ progressFill: $("progressFill"),
59
+ progressText: $("progressText"),
60
+ // Offline banner + badge
61
+ offlineBanner: $("offlineBanner"),
62
+ offlineBannerText: $("offlineBannerText"),
63
+ offlineBannerClose:$("offlineBannerClose"),
64
+ badgeEnv: $("badgeEnv"),
65
+ badgeEnvText: $("badgeEnvText"),
66
+ pulseDot: $("pulseDot"),
67
+ // Mode selector
68
+ modeToggle: $("modeToggle"),
69
+ modeMusic: $("modeMusic"),
70
+ modeSpeech: $("modeSpeech"),
71
+ modeHint: $("modeHint"),
72
+ // Coverage
73
+ coverageBar: $("coverageBar"),
74
+ coveragePct: $("coveragePct"),
75
+ coverageFill: $("coverageFill"),
76
+ // Karaoke Style
77
+ ksFont: $("ksFont"),
78
+ ksColor: $("ksColor"),
79
+ ksColorHex: $("ksColorHex"),
80
+ ksHighlight: $("ksHighlight"),
81
+ ksHighlightHex: $("ksHighlightHex"),
82
+ ksOutline: $("ksOutline"),
83
+ ksOutlineHex: $("ksOutlineHex"),
84
+ ksOutlineWidth: $("ksOutlineWidth"),
85
+ ksOutlineWidthVal: $("ksOutlineWidthVal"),
86
+ ksSize: $("ksSize"),
87
+ ksSizeVal: $("ksSizeVal"),
88
+ ksPosition: $("ksPosition"),
89
+ ksPositionVal: $("ksPositionVal"),
90
+ ksKaraokeMode: $("ksKaraokeMode"),
91
+ ksKaraokeHint: $("ksKaraokeHint"),
92
+ btnPreviewStyle: $("btnPreviewStyle"),
93
+ videoWrap: $("videoWrap"),
94
+ subPreviewOverlay: $("subPreviewOverlay"),
95
+ subPreviewText: $("subPreviewText"),
96
+ };
97
+
98
+ /* --- Mode selector ----------------------------------------- */
99
+ const MODE_HINTS = {
100
+ music: "Tối ưu cho Vietsub lời bài hát, nhận diện toàn bộ lyrics khớp timeline.",
101
+ speech: "Tối ưu cho giọng nói, thuyết trình, podcast. Lọc tiếng ồn nền.",
102
+ };
103
+
104
+ function setMode(mode) {
105
+ state.mode = mode;
106
+ // Update toggle buttons
107
+ if (els.modeMusic) els.modeMusic.classList.toggle("active", mode === "music");
108
+ if (els.modeSpeech) els.modeSpeech.classList.toggle("active", mode === "speech");
109
+ if (els.modeHint) els.modeHint.textContent = MODE_HINTS[mode] || "";
110
+ }
111
+
112
+ // Mode toggle event listeners
113
+ if (els.modeToggle) {
114
+ els.modeToggle.addEventListener("click", (e) => {
115
+ const btn = e.target.closest(".mode-btn");
116
+ if (!btn) return;
117
+ const mode = btn.dataset.mode;
118
+ if (mode) setMode(mode);
119
+ });
120
+ }
121
+
122
+ /* --- Coverage display -------------------------------------- */
123
+ function showCoverage(pct) {
124
+ if (!els.coverageBar) return;
125
+ els.coverageBar.hidden = false;
126
+ const val = Math.min(100, Math.max(0, pct));
127
+ if (els.coveragePct) els.coveragePct.textContent = val + "%";
128
+ if (els.coverageFill) els.coverageFill.style.width = val + "%";
129
+
130
+ // Color coding
131
+ if (els.coverageFill) {
132
+ els.coverageFill.classList.remove("cov-low", "cov-mid", "cov-high");
133
+ if (val >= 80) els.coverageFill.classList.add("cov-high");
134
+ else if (val >= 50) els.coverageFill.classList.add("cov-mid");
135
+ else els.coverageFill.classList.add("cov-low");
136
+ }
137
+ }
138
+
139
+ function hideCoverage() {
140
+ if (els.coverageBar) els.coverageBar.hidden = true;
141
+ }
142
+
143
+ /* --- Health check ------------------------------------------ */
144
+ let healthRetryTimer = null;
145
+
146
+ async function checkHealth() {
147
+ if (IS_FILE_PROTOCOL) {
148
+ setOnlineState(false, "Offline (file://)");
149
+ return;
150
+ }
151
+
152
+ try {
153
+ const res = await fetch("/health", { method: "GET", cache: "no-store" });
154
+ if (res.ok) {
155
+ const data = await res.json();
156
+ setOnlineState(true, "HF Space");
157
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
158
+ } else {
159
+ setOnlineState(false, "Server lỗi");
160
+ }
161
+ } catch (_) {
162
+ setOnlineState(false, "Không kết nối");
163
+ }
164
+ }
165
+
166
+ function setOnlineState(online, label) {
167
+ state.isOnline = online;
168
+
169
+ if (els.badgeEnv) {
170
+ els.badgeEnv.classList.toggle("badge-online", online);
171
+ els.badgeEnv.classList.toggle("badge-offline", !online);
172
+ }
173
+ if (els.badgeEnvText) {
174
+ els.badgeEnvText.textContent = label || (online ? "Online" : "Offline");
175
+ }
176
+ if (els.pulseDot) {
177
+ els.pulseDot.className = online ? "pulse-dot pulse-online" : "pulse-dot pulse-offline";
178
+ }
179
+
180
+ if (!online) {
181
+ if (els.offlineBanner) els.offlineBanner.hidden = false;
182
+ if (els.offlineBannerText) {
183
+ els.offlineBannerText.textContent = IS_FILE_PROTOCOL
184
+ ? "Đ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."
185
+ : "Không kết nối được server — Đang thử lại mỗi 30 giây...";
186
+ }
187
+ if (!IS_FILE_PROTOCOL && !healthRetryTimer) {
188
+ healthRetryTimer = setInterval(checkHealth, 30000);
189
+ }
190
+ } else {
191
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
192
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
193
+ }
194
+ }
195
+
196
+ if (els.offlineBannerClose) {
197
+ els.offlineBannerClose.addEventListener("click", () => {
198
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
199
+ });
200
+ }
201
+
202
+ /* --- Steps ------------------------------------------------- */
203
+ function setStep(num) {
204
+ document.querySelectorAll(".step").forEach((el) => {
205
+ const s = parseInt(el.dataset.step, 10);
206
+ el.classList.toggle("active", s === num);
207
+ el.classList.toggle("done", s < num);
208
+ });
209
+ }
210
+
211
+ /* --- Status ------------------------------------------------ */
212
+ function setStatus(message, type = "idle") {
213
+ els.status.className = `status-box status-${type}`;
214
+ els.statusText.textContent = message;
215
+ }
216
+
217
+ /* --- Buttons state ----------------------------------------- */
218
+ function setEditButtons(enabled) {
219
+ els.btnAddRow.disabled = !enabled;
220
+ els.btnExportSrt.disabled = !enabled;
221
+ els.btnExportMp4.disabled = !enabled;
222
+ }
223
+
224
+ /* --- Download link helpers --------------------------------- */
225
+ function showDownload(el, url, visible) {
226
+ el.href = visible ? url : "#";
227
+ el.classList.toggle("disabled", !visible);
228
+ }
229
+ function showDownloadGroup(show) {
230
+ els.downloadGroup.hidden = !show;
231
+ }
232
+
233
+ /* --- Progress simulation ----------------------------------- */
234
+ let progressTimer = null;
235
+ function startProgress(label) {
236
+ els.progressWrap.hidden = false;
237
+ els.progressFill.style.width = "0%";
238
+ els.progressText.textContent = label || "Đang xử lý...";
239
+
240
+ let pct = 0;
241
+ clearInterval(progressTimer);
242
+ progressTimer = setInterval(() => {
243
+ const remaining = 90 - pct;
244
+ const step = Math.max(0.3, remaining * 0.04);
245
+ pct = Math.min(90, pct + step);
246
+ els.progressFill.style.width = pct + "%";
247
+ }, 300);
248
+ }
249
+ function finishProgress() {
250
+ clearInterval(progressTimer);
251
+ els.progressFill.style.width = "100%";
252
+ setTimeout(() => {
253
+ els.progressWrap.hidden = true;
254
+ els.progressFill.style.width = "0%";
255
+ }, 600);
256
+ }
257
+ function cancelProgress() {
258
+ clearInterval(progressTimer);
259
+ els.progressWrap.hidden = true;
260
+ els.progressFill.style.width = "0%";
261
+ }
262
+
263
+ /* --- File size formatter ----------------------------------- */
264
+ function formatSize(bytes) {
265
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
266
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
267
+ }
268
+
269
+ /* --- Create table cell inputs ------------------------------ */
270
+ function createInput(value, className) {
271
+ const input = document.createElement("input");
272
+ input.type = "text";
273
+ input.value = value || "";
274
+ input.className = className;
275
+ input.spellcheck = false;
276
+ return input;
277
+ }
278
+
279
+ function createTextArea(value) {
280
+ const textarea = document.createElement("textarea");
281
+ textarea.value = value || "";
282
+ textarea.rows = 2;
283
+ textarea.className = "text-input";
284
+ return textarea;
285
+ }
286
+
287
+ /* --- Collect segments from table --------------------------- */
288
+ function collectSegmentsFromTable() {
289
+ const rows = Array.from(els.subtitleBody.querySelectorAll("tr[data-row='1']"));
290
+ return rows.map((row, index) => ({
291
+ id: index + 1,
292
+ start: row.querySelector(".start-input").value.trim(),
293
+ end: row.querySelector(".end-input").value.trim(),
294
+ text: row.querySelector(".text-input").value.trim(),
295
+ }));
296
+ }
297
+
298
+ /* --- Render table ------------------------------------------ */
299
+ function renderTable() {
300
+ els.subtitleBody.innerHTML = "";
301
+
302
+ if (!state.segments.length) {
303
+ els.subtitleBody.innerHTML = `
304
+ <tr class="empty-row">
305
+ <td colspan="5">
306
+ <div class="empty-state">
307
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
308
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
309
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
310
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
311
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
312
+ </svg>
313
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
314
+ </div>
315
+ </td>
316
+ </tr>`;
317
+ els.segmentCount.textContent = "0 dòng";
318
+ setEditButtons(false);
319
+ return;
320
+ }
321
+
322
+ state.segments.forEach((seg, index) => {
323
+ const tr = document.createElement("tr");
324
+ tr.dataset.row = "1";
325
+
326
+ const tdIdx = document.createElement("td");
327
+ tdIdx.className = "idx-cell";
328
+ tdIdx.textContent = String(index + 1);
329
+
330
+ const tdStart = document.createElement("td");
331
+ tdStart.appendChild(createInput(seg.start, "start-input time-input"));
332
+
333
+ const tdEnd = document.createElement("td");
334
+ tdEnd.appendChild(createInput(seg.end, "end-input time-input"));
335
+
336
+ const tdText = document.createElement("td");
337
+ tdText.appendChild(createTextArea(seg.text));
338
+
339
+ const tdAct = document.createElement("td");
340
+ tdAct.style.textAlign = "center";
341
+ const delBtn = document.createElement("button");
342
+ delBtn.className = "btn btn-danger-sm";
343
+ 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>`;
344
+ delBtn.title = "Xóa dòng";
345
+ delBtn.addEventListener("click", () => {
346
+ state.segments = collectSegmentsFromTable();
347
+ state.segments.splice(index, 1);
348
+ renderTable();
349
+ });
350
+ tdAct.appendChild(delBtn);
351
+
352
+ tr.append(tdIdx, tdStart, tdEnd, tdText, tdAct);
353
+ els.subtitleBody.appendChild(tr);
354
+ });
355
+
356
+ els.segmentCount.textContent = `${state.segments.length} dòng`;
357
+ setEditButtons(true);
358
+ }
359
+
360
+ /* --- Transcribe -------------------------------------------- */
361
+ async function transcribeVideo() {
362
+ if (!state.file) {
363
+ setStatus("Hãy chọn video trước.", "error");
364
+ return;
365
+ }
366
+
367
+ if (!state.isOnline) {
368
+ setStatus(
369
+ IS_FILE_PROTOCOL
370
+ ? "Đ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."
371
+ : "Server không phản hồi. Đang thử kết nối lại...",
372
+ "error"
373
+ );
374
+ if (!IS_FILE_PROTOCOL) checkHealth();
375
+ return;
376
+ }
377
+
378
+ const fd = new FormData();
379
+ fd.append("file", state.file);
380
+ fd.append("mode", state.mode);
381
+
382
+ els.btnTranscribe.disabled = true;
383
+ els.btnTranscribe.classList.add("btn-loading");
384
+ const modeLabel = state.mode === "music" ? "lời bài hát" : "giọng nói";
385
+ setStatus(`Đang nhận diện ${modeLabel} tiếng Việt...`, "loading");
386
+ setStep(2);
387
+ startProgress(`Đang upload và nhận diện ${modeLabel}...`);
388
+ showDownload(els.downloadSrt, "#", false);
389
+ showDownload(els.downloadMp4, "#", false);
390
+ showDownloadGroup(false);
391
+ hideCoverage();
392
+
393
+ try {
394
+ const res = await fetch("/api/transcribe", {
395
+ method: "POST",
396
+ body: fd,
397
+ });
398
+ const data = await res.json();
399
+ if (!res.ok) throw new Error(data.detail || "Không thể nhận diện subtitle.");
400
+
401
+ state.jobId = data.job_id;
402
+ state.segments = data.segments || [];
403
+ renderTable();
404
+ finishProgress();
405
+
406
+ // Show coverage
407
+ if (data.coverage_pct !== undefined) {
408
+ showCoverage(data.coverage_pct);
409
+ }
410
+
411
+ const coverageInfo = data.coverage_pct ? ` (phủ ${data.coverage_pct}% timeline)` : "";
412
+ setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng Vietsub${coverageInfo}.`, "success");
413
+ setStep(3);
414
+ } catch (err) {
415
+ cancelProgress();
416
+ const msg = err.message.includes("Failed to fetch")
417
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
418
+ : (err.message || "Có lỗi khi auto sub.");
419
+ setStatus(msg, "error");
420
+ setStep(1);
421
+ checkHealth();
422
+ } finally {
423
+ els.btnTranscribe.disabled = false;
424
+ els.btnTranscribe.classList.remove("btn-loading");
425
+ }
426
+ }
427
+
428
+ /* --- Client-side SRT generation (offline-capable) ---------- */
429
+ function generateSrtString(segments) {
430
+ let lines = [];
431
+ segments.forEach((seg, idx) => {
432
+ const start = seg.start || "00:00:00,000";
433
+ const end = seg.end || "00:00:02,000";
434
+ const text = (seg.text || "").trim();
435
+ if (!text) return;
436
+ lines.push(String(idx + 1));
437
+ lines.push(`${start} --> ${end}`);
438
+ lines.push(text);
439
+ lines.push("");
440
+ });
441
+ return lines.join("\n");
442
+ }
443
+
444
+ function downloadSrtOffline() {
445
+ const segments = collectSegmentsFromTable();
446
+ if (!segments.length) {
447
+ setStatus("Chưa có subtitle để xuất.", "error");
448
+ return;
449
+ }
450
+ const srtContent = generateSrtString(segments);
451
+ const blob = new Blob([srtContent], { type: "text/plain;charset=utf-8" });
452
+ const url = URL.createObjectURL(blob);
453
+ const a = document.createElement("a");
454
+ a.href = url;
455
+ a.download = "subtitle.srt";
456
+ document.body.appendChild(a);
457
+ a.click();
458
+ document.body.removeChild(a);
459
+ URL.revokeObjectURL(url);
460
+ setStatus("Đã xuất file SRT thành công (offline).", "success");
461
+ setStep(4);
462
+ }
463
+
464
+ /* --- Export ------------------------------------------------- */
465
+ async function exportResult(burnIn) {
466
+ if (!burnIn && (!state.isOnline || !state.jobId)) {
467
+ downloadSrtOffline();
468
+ return;
469
+ }
470
+
471
+ if (burnIn && !state.isOnline) {
472
+ setStatus(
473
+ IS_FILE_PROTOCOL
474
+ ? "Xuất MP4 burn sub cần server HF Space. Hãy deploy ứng dụng lên HF Space trước."
475
+ : "Server không phản hồi. Xuất MP4 cần kết nối server.",
476
+ "error"
477
+ );
478
+ return;
479
+ }
480
+
481
+ if (!state.jobId) {
482
+ setStatus("Chưa có job để xuất file. Hãy bấm Auto sub trước.", "error");
483
+ return;
484
+ }
485
+
486
+ const ks = getKaraokeStyle();
487
+ const payload = {
488
+ job_id: state.jobId,
489
+ burn_in: burnIn,
490
+ segments: collectSegmentsFromTable(),
491
+ style: {
492
+ font_name: ks.font,
493
+ font_color: ks.color,
494
+ highlight_color: ks.highlight,
495
+ outline_color: ks.outline,
496
+ outline_width: ks.outlineWidth,
497
+ font_size_pct: ks.sizePct,
498
+ position_pct: ks.positionPct,
499
+ karaoke_mode: ks.karaokeMode,
500
+ },
501
+ };
502
+
503
+ const label = burnIn ? "Đang xuất MP4 có sub..." : "Đang tạo file SRT...";
504
+ setStatus(label, "loading");
505
+ startProgress(label);
506
+ setStep(4);
507
+ els.btnExportSrt.disabled = true;
508
+ els.btnExportMp4.disabled = true;
509
+
510
+ try {
511
+ const res = await fetch("/api/export", {
512
+ method: "POST",
513
+ headers: { "Content-Type": "application/json" },
514
+ body: JSON.stringify(payload),
515
+ });
516
+ const data = await res.json();
517
+ if (!res.ok) throw new Error(data.detail || "Xuất file thất bại.");
518
+
519
+ finishProgress();
520
+ showDownloadGroup(true);
521
+ showDownload(els.downloadSrt, data.srt_url, true);
522
+ if (data.mp4_url) {
523
+ showDownload(els.downloadMp4, data.mp4_url, true);
524
+ }
525
+
526
+ const msg = burnIn
527
+ ? `Xuất MP4 thành công${data.mp4_size_mb ? ` (${data.mp4_size_mb} MB)` : ""}.`
528
+ : "Đã tạo file SRT thành công.";
529
+ setStatus(msg, "success");
530
+ } catch (err) {
531
+ cancelProgress();
532
+ const msg = err.message.includes("Failed to fetch")
533
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
534
+ : (err.message || "Có lỗi khi xuất file.");
535
+ setStatus(msg, "error");
536
+ checkHealth();
537
+ } finally {
538
+ setEditButtons(true);
539
+ }
540
+ }
541
+
542
+ /* --- File selection ---------------------------------------- */
543
+ function handleFile(file) {
544
+ if (!file) return;
545
+ state.file = file;
546
+ state.jobId = null;
547
+ state.segments = [];
548
+ renderTable();
549
+ showDownload(els.downloadSrt, "#", false);
550
+ showDownload(els.downloadMp4, "#", false);
551
+ showDownloadGroup(false);
552
+ hideCoverage();
553
+
554
+ const url = URL.createObjectURL(file);
555
+ els.preview.src = url;
556
+ els.preview.classList.add("has-src");
557
+ els.videoPlaceholder.classList.add("hidden");
558
+
559
+ els.fileInfo.hidden = false;
560
+ els.fileName.textContent = file.name;
561
+ els.fileSize.textContent = formatSize(file.size);
562
+
563
+ setStatus(`Đã chọn: ${file.name}`, "idle");
564
+ setStep(1);
565
+ }
566
+
567
+ function clearFile() {
568
+ state.file = null;
569
+ state.jobId = null;
570
+ state.segments = [];
571
+ renderTable();
572
+ els.preview.removeAttribute("src");
573
+ els.preview.classList.remove("has-src");
574
+ els.videoPlaceholder.classList.remove("hidden");
575
+ els.fileInfo.hidden = true;
576
+ els.fileInput.value = "";
577
+ showDownloadGroup(false);
578
+ hideCoverage();
579
+ setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle");
580
+ setStep(1);
581
+ }
582
+
583
+ /* --- Event listeners --------------------------------------- */
584
+
585
+ els.fileInput.addEventListener("change", (e) => {
586
+ const [file] = e.target.files || [];
587
+ if (file) handleFile(file);
588
+ });
589
+
590
+ els.dropZone.addEventListener("click", () => els.fileInput.click());
591
+
592
+ els.dropZone.addEventListener("dragover", (e) => {
593
+ e.preventDefault();
594
+ els.dropZone.classList.add("drag-over");
595
+ });
596
+ els.dropZone.addEventListener("dragleave", () => {
597
+ els.dropZone.classList.remove("drag-over");
598
+ });
599
+ els.dropZone.addEventListener("drop", (e) => {
600
+ e.preventDefault();
601
+ els.dropZone.classList.remove("drag-over");
602
+ const file = e.dataTransfer.files[0];
603
+ if (file) {
604
+ const dt = new DataTransfer();
605
+ dt.items.add(file);
606
+ els.fileInput.files = dt.files;
607
+ handleFile(file);
608
+ }
609
+ });
610
+
611
+ els.btnClearFile.addEventListener("click", clearFile);
612
+ els.btnTranscribe.addEventListener("click", transcribeVideo);
613
+ els.btnExportSrt.addEventListener("click", () => exportResult(false));
614
+ els.btnExportMp4.addEventListener("click", () => exportResult(true));
615
+
616
+ els.btnAddRow.addEventListener("click", () => {
617
+ state.segments = collectSegmentsFromTable();
618
+ state.segments.push({
619
+ id: state.segments.length + 1,
620
+ start: "00:00:00,000",
621
+ end: "00:00:02,000",
622
+ text: "Subtitle mới",
623
+ });
624
+ renderTable();
625
+ const scroll = $("tableScroll");
626
+ if (scroll) scroll.scrollTop = scroll.scrollHeight;
627
+ });
628
+
629
+ const btnCollapse = $("btnCollapseTable");
630
+ const tableScroll = $("tableScroll");
631
+ if (btnCollapse && tableScroll) {
632
+ btnCollapse.addEventListener("click", () => {
633
+ const collapsed = tableScroll.style.display === "none";
634
+ tableScroll.style.display = collapsed ? "" : "none";
635
+ btnCollapse.querySelector("svg").style.transform = collapsed ? "" : "rotate(180deg)";
636
+ });
637
+ }
638
+
639
+ /* --- Karaoke Style ----------------------------------------- */
640
+
641
+ function getKaraokeStyle() {
642
+ return {
643
+ font: els.ksFont ? els.ksFont.value : "Bangers",
644
+ color: els.ksColor ? els.ksColor.value : "#FFFFFF",
645
+ highlight: els.ksHighlight ? els.ksHighlight.value : "#FFD700",
646
+ outline: els.ksOutline ? els.ksOutline.value : "#000000",
647
+ outlineWidth: els.ksOutlineWidth ? parseInt(els.ksOutlineWidth.value, 10) : 2,
648
+ sizePct: els.ksSize ? parseInt(els.ksSize.value, 10) : 100,
649
+ positionPct: els.ksPosition ? parseInt(els.ksPosition.value, 10) : 90,
650
+ karaokeMode: els.ksKaraokeMode ? els.ksKaraokeMode.checked : false,
651
+ };
652
+ }
653
+
654
+ function updatePreviewOverlay() {
655
+ const ks = getKaraokeStyle();
656
+ state.karaokeStyle = ks;
657
+
658
+ const overlay = els.subPreviewOverlay;
659
+ const textEl = els.subPreviewText;
660
+ if (!overlay || !textEl) return;
661
+
662
+ // Position: convert 0-100% to bottom offset
663
+ // 0% = top (bottom: 90%), 100% = bottom (bottom: 2%)
664
+ const bottomPct = Math.max(2, 90 - (ks.positionPct / 100) * 88);
665
+ overlay.style.bottom = bottomPct + "%";
666
+
667
+ // Font
668
+ textEl.style.fontFamily = "'" + ks.font + "', sans-serif";
669
+
670
+ // Size: base 1.3rem * sizePct / 100
671
+ textEl.style.fontSize = (1.3 * ks.sizePct / 100) + "rem";
672
+
673
+ // Color
674
+ textEl.style.color = ks.color;
675
+
676
+ // Text shadow for outline
677
+ const ow = ks.outlineWidth;
678
+ const oc = ks.outline;
679
+ if (ow > 0) {
680
+ textEl.style.textShadow = [
681
+ `${ow}px ${ow}px 0 ${oc}`,
682
+ `-${ow}px -${ow}px 0 ${oc}`,
683
+ `${ow}px -${ow}px 0 ${oc}`,
684
+ `-${ow}px ${ow}px 0 ${oc}`,
685
+ `0 0 8px rgba(0,0,0,0.7)`,
686
+ ].join(", ");
687
+ } else {
688
+ textEl.style.textShadow = "0 0 8px rgba(0,0,0,0.7)";
689
+ }
690
+
691
+ // Demo text with karaoke highlight
692
+ if (ks.karaokeMode) {
693
+ textEl.innerHTML = 'Phụ đề <span class="ks-word-active" style="color:' + ks.highlight + '">mẫu</span> — Xem trước <span class="ks-word-active" style="color:' + ks.highlight + '">Karaoke</span>';
694
+ } else {
695
+ textEl.textContent = "Phụ đề mẫu — Xem trước Karaoke";
696
+ }
697
+ }
698
+
699
+ function showSubtitlePreview() {
700
+ if (els.subPreviewOverlay) {
701
+ els.subPreviewOverlay.hidden = false;
702
+ updatePreviewOverlay();
703
+ }
704
+ }
705
+
706
+ function hideSubtitlePreview() {
707
+ if (els.subPreviewOverlay) els.subPreviewOverlay.hidden = true;
708
+ }
709
+
710
+ // Wire up karaoke control events
711
+ function setupKaraokeEvents() {
712
+ // Font selector → update preview font display
713
+ if (els.ksFont) {
714
+ els.ksFont.addEventListener("change", () => {
715
+ els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif";
716
+ updatePreviewOverlay();
717
+ });
718
+ // Set initial font display
719
+ els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif";
720
+ }
721
+
722
+ // Color pickers
723
+ if (els.ksColor) {
724
+ els.ksColor.addEventListener("input", () => {
725
+ if (els.ksColorHex) els.ksColorHex.textContent = els.ksColor.value.toUpperCase();
726
+ updatePreviewOverlay();
727
+ });
728
+ }
729
+ if (els.ksHighlight) {
730
+ els.ksHighlight.addEventListener("input", () => {
731
+ if (els.ksHighlightHex) els.ksHighlightHex.textContent = els.ksHighlight.value.toUpperCase();
732
+ updatePreviewOverlay();
733
+ });
734
+ }
735
+ if (els.ksOutline) {
736
+ els.ksOutline.addEventListener("input", () => {
737
+ if (els.ksOutlineHex) els.ksOutlineHex.textContent = els.ksOutline.value.toUpperCase();
738
+ updatePreviewOverlay();
739
+ });
740
+ }
741
+ if (els.ksOutlineWidth) {
742
+ els.ksOutlineWidth.addEventListener("input", () => {
743
+ if (els.ksOutlineWidthVal) els.ksOutlineWidthVal.textContent = els.ksOutlineWidth.value + "px";
744
+ updatePreviewOverlay();
745
+ });
746
+ }
747
+
748
+ // Size slider
749
+ if (els.ksSize) {
750
+ els.ksSize.addEventListener("input", () => {
751
+ if (els.ksSizeVal) els.ksSizeVal.textContent = els.ksSize.value + "%";
752
+ updatePreviewOverlay();
753
+ });
754
+ }
755
+
756
+ // Position slider
757
+ if (els.ksPosition) {
758
+ els.ksPosition.addEventListener("input", () => {
759
+ if (els.ksPositionVal) els.ksPositionVal.textContent = els.ksPosition.value + "%";
760
+ updatePreviewOverlay();
761
+ });
762
+ }
763
+
764
+ // Karaoke mode toggle
765
+ if (els.ksKaraokeMode) {
766
+ els.ksKaraokeMode.addEventListener("change", () => {
767
+ updatePreviewOverlay();
768
+ });
769
+ }
770
+
771
+ // Preview button
772
+ if (els.btnPreviewStyle) {
773
+ let previewVisible = false;
774
+ els.btnPreviewStyle.addEventListener("click", () => {
775
+ previewVisible = !previewVisible;
776
+ if (previewVisible) {
777
+ showSubtitlePreview();
778
+ 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ụ đề';
779
+ } else {
780
+ hideSubtitlePreview();
781
+ 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ụ đề';
782
+ }
783
+ });
784
+ }
785
+ }
786
+
787
+ /* --- Init -------------------------------------------------- */
788
+ setStep(1);
789
+ setMode("music");
790
+ renderTable();
791
+ checkHealth();
792
+ setupKaraokeEvents();
static/styles.css ADDED
@@ -0,0 +1,1056 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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; max-height: 100%; }
361
+ .action-stack {
362
+ padding: 18px;
363
+ display: flex;
364
+ flex-direction: column;
365
+ gap: 14px;
366
+ flex: 1;
367
+ overflow-y: auto;
368
+ max-height: calc(100vh - 260px);
369
+ }
370
+ .divider {
371
+ border: none;
372
+ border-top: 1px solid var(--border);
373
+ margin: 2px 0;
374
+ }
375
+ .export-title {
376
+ font-size: 0.78rem;
377
+ font-weight: 600;
378
+ color: var(--text-muted);
379
+ text-transform: uppercase;
380
+ letter-spacing: 0.06em;
381
+ margin-bottom: 6px;
382
+ }
383
+
384
+ /* --- Buttons ------------------------------------------------ */
385
+ .btn {
386
+ display: inline-flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ gap: 8px;
390
+ font-family: var(--font-sans);
391
+ font-size: 0.85rem;
392
+ font-weight: 600;
393
+ border: 1px solid var(--border);
394
+ border-radius: var(--radius);
395
+ padding: 10px 16px;
396
+ cursor: pointer;
397
+ background: var(--bg-raised);
398
+ color: var(--text-primary);
399
+ transition: all 0.2s ease;
400
+ white-space: nowrap;
401
+ }
402
+ .btn:hover:not(:disabled) {
403
+ transform: translateY(-1px);
404
+ box-shadow: var(--shadow-sm);
405
+ }
406
+ .btn:active:not(:disabled) {
407
+ transform: translateY(0);
408
+ }
409
+ .btn:disabled {
410
+ opacity: 0.35;
411
+ cursor: not-allowed;
412
+ transform: none;
413
+ }
414
+ .btn-primary {
415
+ background: var(--accent);
416
+ border-color: transparent;
417
+ color: #fff;
418
+ }
419
+ .btn-primary:hover:not(:disabled) {
420
+ background: var(--accent-hover);
421
+ box-shadow: 0 4px 20px var(--accent-glow);
422
+ }
423
+ .btn-success {
424
+ background: var(--success);
425
+ border-color: transparent;
426
+ color: #fff;
427
+ }
428
+ .btn-success:hover:not(:disabled) {
429
+ background: #34d399;
430
+ box-shadow: 0 4px 20px rgba(16,185,129,0.3);
431
+ }
432
+ .btn-outline {
433
+ background: transparent;
434
+ border-color: var(--border);
435
+ }
436
+ .btn-outline:hover:not(:disabled) {
437
+ background: var(--bg-raised);
438
+ border-color: rgba(255,255,255,0.15);
439
+ }
440
+ .btn-ghost {
441
+ background: transparent;
442
+ border: none;
443
+ color: var(--text-secondary);
444
+ padding: 6px 10px;
445
+ }
446
+ .btn-ghost:hover:not(:disabled) {
447
+ color: var(--text-primary);
448
+ background: rgba(255,255,255,0.05);
449
+ }
450
+ .btn-danger-sm {
451
+ background: var(--danger-bg);
452
+ border: 1px solid rgba(239,68,68,0.25);
453
+ color: #fca5a5;
454
+ padding: 6px 10px;
455
+ font-size: 0.78rem;
456
+ }
457
+ .btn-danger-sm:hover:not(:disabled) {
458
+ background: rgba(239,68,68,0.2);
459
+ }
460
+ .btn-sm { padding: 6px 12px; font-size: 0.8rem; }
461
+ .btn-lg { padding: 12px 20px; font-size: 0.92rem; }
462
+ .btn-full { width: 100%; }
463
+ .btn-row {
464
+ display: flex;
465
+ gap: 8px;
466
+ flex-wrap: wrap;
467
+ }
468
+
469
+ /* --- Progress ----------------------------------------------- */
470
+ .progress-wrap {
471
+ display: flex;
472
+ flex-direction: column;
473
+ gap: 6px;
474
+ }
475
+ .progress-bar {
476
+ height: 6px;
477
+ background: var(--bg-raised);
478
+ border-radius: 99px;
479
+ overflow: hidden;
480
+ }
481
+ .progress-fill {
482
+ height: 100%;
483
+ width: 0%;
484
+ background: linear-gradient(90deg, var(--accent), #818cf8);
485
+ border-radius: 99px;
486
+ transition: width 0.4s ease;
487
+ animation: progressPulse 1.5s ease-in-out infinite;
488
+ }
489
+ @keyframes progressPulse {
490
+ 0%, 100% { opacity: 1; }
491
+ 50% { opacity: 0.6; }
492
+ }
493
+ .progress-text {
494
+ font-size: 0.78rem;
495
+ color: var(--text-secondary);
496
+ font-weight: 500;
497
+ }
498
+
499
+ /* --- Status Box --------------------------------------------- */
500
+ .status-box {
501
+ display: flex;
502
+ align-items: center;
503
+ gap: 8px;
504
+ padding: 10px 14px;
505
+ border-radius: var(--radius);
506
+ font-size: 0.83rem;
507
+ font-weight: 500;
508
+ transition: all 0.3s;
509
+ }
510
+ .status-icon { width: 16px; height: 16px; flex-shrink: 0; }
511
+ .status-idle {
512
+ background: rgba(255,255,255,0.03);
513
+ color: var(--text-secondary);
514
+ }
515
+ .status-loading {
516
+ background: var(--warning-bg);
517
+ color: #fde68a;
518
+ }
519
+ .status-success {
520
+ background: var(--success-bg);
521
+ color: #6ee7b7;
522
+ }
523
+ .status-error {
524
+ background: var(--danger-bg);
525
+ color: #fca5a5;
526
+ }
527
+
528
+ /* --- Download Links ----------------------------------------- */
529
+ .download-group {
530
+ display: flex;
531
+ gap: 10px;
532
+ flex-wrap: wrap;
533
+ }
534
+ .dl-link {
535
+ display: inline-flex;
536
+ align-items: center;
537
+ gap: 8px;
538
+ padding: 10px 16px;
539
+ border-radius: var(--radius);
540
+ font-size: 0.85rem;
541
+ font-weight: 600;
542
+ text-decoration: none;
543
+ transition: all 0.2s;
544
+ }
545
+ .dl-srt {
546
+ background: rgba(99,102,241,0.12);
547
+ color: #a5b4fc;
548
+ border: 1px solid rgba(99,102,241,0.25);
549
+ }
550
+ .dl-srt:hover { background: rgba(99,102,241,0.2); }
551
+ .dl-mp4 {
552
+ background: rgba(16,185,129,0.12);
553
+ color: #6ee7b7;
554
+ border: 1px solid rgba(16,185,129,0.25);
555
+ }
556
+ .dl-mp4:hover { background: rgba(16,185,129,0.2); }
557
+ .dl-link.disabled { pointer-events: none; opacity: 0.35; }
558
+
559
+ /* --- Table -------------------------------------------------- */
560
+ .table-panel { }
561
+ .table-meta {
562
+ display: flex;
563
+ align-items: center;
564
+ gap: 10px;
565
+ }
566
+ .seg-count {
567
+ font-size: 0.78rem;
568
+ font-weight: 600;
569
+ color: var(--text-muted);
570
+ font-family: var(--font-mono);
571
+ background: var(--bg-raised);
572
+ padding: 3px 10px;
573
+ border-radius: 999px;
574
+ }
575
+ .table-scroll {
576
+ overflow-x: auto;
577
+ max-height: 480px;
578
+ overflow-y: auto;
579
+ }
580
+ table {
581
+ width: 100%;
582
+ border-collapse: collapse;
583
+ min-width: 720px;
584
+ }
585
+ thead { position: sticky; top: 0; z-index: 5; }
586
+ th {
587
+ text-align: left;
588
+ padding: 10px 14px;
589
+ font-size: 0.73rem;
590
+ font-weight: 600;
591
+ text-transform: uppercase;
592
+ letter-spacing: 0.06em;
593
+ color: var(--text-muted);
594
+ background: var(--bg-raised);
595
+ border-bottom: 1px solid var(--border);
596
+ }
597
+ td {
598
+ padding: 8px 14px;
599
+ vertical-align: top;
600
+ border-bottom: 1px solid var(--border);
601
+ font-size: 0.85rem;
602
+ }
603
+ tr:last-child td { border-bottom: none; }
604
+ tr:hover td { background: rgba(255,255,255,0.02); }
605
+ .col-idx { width: 48px; text-align: center; }
606
+ .col-time { width: 155px; }
607
+ .col-text { }
608
+ .col-act { width: 72px; text-align: center; }
609
+
610
+ /* Row index number */
611
+ td.idx-cell {
612
+ text-align: center;
613
+ font-family: var(--font-mono);
614
+ font-size: 0.78rem;
615
+ color: var(--text-muted);
616
+ font-weight: 600;
617
+ }
618
+
619
+ /* --- Table Inputs ------------------------------------------- */
620
+ .time-input, .text-input {
621
+ width: 100%;
622
+ border-radius: var(--radius-sm);
623
+ border: 1px solid var(--border);
624
+ background: var(--bg-input);
625
+ color: var(--text-primary);
626
+ padding: 8px 10px;
627
+ font-family: var(--font-mono);
628
+ font-size: 0.82rem;
629
+ transition: border-color 0.2s, box-shadow 0.2s;
630
+ }
631
+ .time-input:focus, .text-input:focus {
632
+ outline: none;
633
+ border-color: var(--border-focus);
634
+ box-shadow: 0 0 0 3px var(--accent-glow);
635
+ }
636
+ .text-input {
637
+ font-family: var(--font-sans);
638
+ resize: vertical;
639
+ min-height: 54px;
640
+ line-height: 1.45;
641
+ }
642
+
643
+ /* --- Empty State -------------------------------------------- */
644
+ .empty-row td { padding: 40px 20px; }
645
+ .empty-state {
646
+ display: flex;
647
+ flex-direction: column;
648
+ align-items: center;
649
+ gap: 12px;
650
+ color: var(--text-muted);
651
+ text-align: center;
652
+ }
653
+ .empty-icon { width: 48px; height: 48px; opacity: 0.35; }
654
+ .empty-state p { font-size: 0.85rem; max-width: 380px; }
655
+
656
+ /* --- Footer ------------------------------------------------- */
657
+ .footer {
658
+ text-align: center;
659
+ padding: 20px 24px;
660
+ font-size: 0.75rem;
661
+ color: var(--text-muted);
662
+ border-top: 1px solid var(--border);
663
+ }
664
+
665
+ /* --- Utilities ---------------------------------------------- */
666
+ [hidden] { display: none !important; }
667
+
668
+ /* --- Scrollbar ---------------------------------------------- */
669
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
670
+ ::-webkit-scrollbar-track { background: transparent; }
671
+ ::-webkit-scrollbar-thumb {
672
+ background: rgba(255,255,255,0.1);
673
+ border-radius: 99px;
674
+ }
675
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
676
+
677
+ /* --- Spinner for loading button ----------------------------- */
678
+ .btn-loading {
679
+ position: relative;
680
+ pointer-events: none;
681
+ color: transparent !important;
682
+ }
683
+ .btn-loading::after {
684
+ content: '';
685
+ position: absolute;
686
+ width: 18px; height: 18px;
687
+ border: 2px solid rgba(255,255,255,0.3);
688
+ border-top-color: #fff;
689
+ border-radius: 50%;
690
+ animation: spin 0.6s linear infinite;
691
+ }
692
+ @keyframes spin { to { transform: rotate(360deg); } }
693
+
694
+ /* --- Mode Selector ------------------------------------------ */
695
+ .mode-selector {
696
+ display: flex;
697
+ flex-direction: column;
698
+ gap: 8px;
699
+ }
700
+ .mode-label {
701
+ font-size: 0.78rem;
702
+ font-weight: 600;
703
+ color: var(--text-muted);
704
+ text-transform: uppercase;
705
+ letter-spacing: 0.06em;
706
+ }
707
+ .mode-toggle {
708
+ display: flex;
709
+ gap: 6px;
710
+ background: var(--bg-base);
711
+ border-radius: var(--radius);
712
+ padding: 4px;
713
+ border: 1px solid var(--border);
714
+ }
715
+ .mode-btn {
716
+ flex: 1;
717
+ display: inline-flex;
718
+ align-items: center;
719
+ justify-content: center;
720
+ gap: 6px;
721
+ padding: 8px 12px;
722
+ border: none;
723
+ border-radius: var(--radius-sm);
724
+ background: transparent;
725
+ color: var(--text-secondary);
726
+ font-family: var(--font-sans);
727
+ font-size: 0.82rem;
728
+ font-weight: 600;
729
+ cursor: pointer;
730
+ transition: all 0.2s;
731
+ white-space: nowrap;
732
+ }
733
+ .mode-btn:hover:not(.active) {
734
+ color: var(--text-primary);
735
+ background: rgba(255,255,255,0.04);
736
+ }
737
+ .mode-btn.active {
738
+ background: var(--accent);
739
+ color: #fff;
740
+ box-shadow: 0 2px 8px var(--accent-glow);
741
+ }
742
+ .mode-btn .icon-btn {
743
+ width: 14px;
744
+ height: 14px;
745
+ }
746
+ .mode-hint {
747
+ font-size: 0.75rem;
748
+ color: var(--text-muted);
749
+ line-height: 1.4;
750
+ margin: 0;
751
+ }
752
+
753
+ /* --- Coverage Bar ------------------------------------------- */
754
+ .coverage-bar {
755
+ display: flex;
756
+ flex-direction: column;
757
+ gap: 6px;
758
+ padding: 10px 14px;
759
+ background: rgba(255,255,255,0.02);
760
+ border-radius: var(--radius);
761
+ border: 1px solid var(--border);
762
+ }
763
+ .coverage-header {
764
+ display: flex;
765
+ align-items: center;
766
+ justify-content: space-between;
767
+ }
768
+ .coverage-label {
769
+ font-size: 0.75rem;
770
+ font-weight: 600;
771
+ color: var(--text-muted);
772
+ text-transform: uppercase;
773
+ letter-spacing: 0.04em;
774
+ }
775
+ .coverage-pct {
776
+ font-size: 0.82rem;
777
+ font-weight: 700;
778
+ font-family: var(--font-mono);
779
+ color: var(--text-primary);
780
+ }
781
+ .coverage-track {
782
+ height: 6px;
783
+ background: var(--bg-raised);
784
+ border-radius: 99px;
785
+ overflow: hidden;
786
+ }
787
+ .coverage-fill {
788
+ height: 100%;
789
+ width: 0%;
790
+ border-radius: 99px;
791
+ transition: width 0.6s ease, background 0.3s;
792
+ }
793
+ .coverage-fill.cov-high {
794
+ background: linear-gradient(90deg, var(--success), #34d399);
795
+ }
796
+ .coverage-fill.cov-mid {
797
+ background: linear-gradient(90deg, var(--warning), #fbbf24);
798
+ }
799
+ .coverage-fill.cov-low {
800
+ background: linear-gradient(90deg, var(--danger), #f87171);
801
+ }
802
+
803
+ /* --- Subtitle Preview Overlay ------------------------------- */
804
+ .sub-preview-overlay {
805
+ position: absolute;
806
+ left: 0;
807
+ right: 0;
808
+ bottom: 10%;
809
+ display: flex;
810
+ justify-content: center;
811
+ pointer-events: none;
812
+ z-index: 10;
813
+ transition: bottom 0.3s ease;
814
+ }
815
+ .sub-preview-text {
816
+ display: inline-block;
817
+ padding: 6px 18px;
818
+ font-family: 'Bangers', sans-serif;
819
+ font-size: 1.3rem;
820
+ color: #fff;
821
+ text-shadow: 0 0 6px rgba(0,0,0,0.9), 2px 2px 4px rgba(0,0,0,0.7);
822
+ background: rgba(0,0,0,0.45);
823
+ border-radius: var(--radius-sm);
824
+ letter-spacing: 0.02em;
825
+ line-height: 1.4;
826
+ text-align: center;
827
+ max-width: 90%;
828
+ word-break: break-word;
829
+ transition: all 0.3s ease;
830
+ }
831
+ .sub-preview-text .ks-word-active {
832
+ color: #FFD700;
833
+ transition: color 0.15s;
834
+ }
835
+
836
+ /* --- Karaoke Style Panel ------------------------------------ */
837
+ .karaoke-style-panel {
838
+ display: flex;
839
+ flex-direction: column;
840
+ gap: 10px;
841
+ padding: 14px;
842
+ background: rgba(99,102,241,0.04);
843
+ border: 1px solid rgba(99,102,241,0.12);
844
+ border-radius: var(--radius);
845
+ }
846
+ .ks-title {
847
+ display: flex;
848
+ align-items: center;
849
+ gap: 8px;
850
+ font-size: 0.82rem;
851
+ font-weight: 700;
852
+ color: var(--accent-hover);
853
+ text-transform: uppercase;
854
+ letter-spacing: 0.05em;
855
+ margin: 0;
856
+ }
857
+ .ks-group {
858
+ display: flex;
859
+ flex-direction: column;
860
+ gap: 4px;
861
+ }
862
+ .ks-label {
863
+ font-size: 0.73rem;
864
+ font-weight: 600;
865
+ color: var(--text-muted);
866
+ text-transform: uppercase;
867
+ letter-spacing: 0.04em;
868
+ }
869
+
870
+ /* Font select */
871
+ .ks-select {
872
+ width: 100%;
873
+ padding: 8px 10px;
874
+ border-radius: var(--radius-sm);
875
+ border: 1px solid var(--border);
876
+ background-color: var(--bg-input);
877
+ color: var(--text-primary);
878
+ font-size: 0.85rem;
879
+ font-family: var(--font-sans);
880
+ cursor: pointer;
881
+ transition: border-color 0.2s;
882
+ appearance: none;
883
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M2.5 4.5l3.5 3.5 3.5-3.5'/%3E%3C/svg%3E");
884
+ background-repeat: no-repeat;
885
+ background-position: right 10px center;
886
+ padding-right: 28px;
887
+ }
888
+ .ks-select option {
889
+ font-family: var(--font-sans);
890
+ font-size: 0.85rem;
891
+ padding: 6px 10px;
892
+ }
893
+ .ks-select:focus {
894
+ outline: none;
895
+ border-color: var(--border-focus);
896
+ box-shadow: 0 0 0 3px var(--accent-glow);
897
+ }
898
+
899
+ /* Color pickers */
900
+ .ks-color-row {
901
+ flex-direction: row;
902
+ gap: 10px;
903
+ }
904
+ .ks-color-item {
905
+ flex: 1;
906
+ display: flex;
907
+ flex-direction: column;
908
+ gap: 4px;
909
+ }
910
+ .ks-color-wrap {
911
+ display: flex;
912
+ align-items: center;
913
+ gap: 8px;
914
+ }
915
+ .ks-color-input {
916
+ width: 32px;
917
+ height: 32px;
918
+ border: 2px solid var(--border);
919
+ border-radius: var(--radius-sm);
920
+ cursor: pointer;
921
+ padding: 0;
922
+ background: none;
923
+ flex-shrink: 0;
924
+ }
925
+ .ks-color-input::-webkit-color-swatch-wrapper { padding: 2px; }
926
+ .ks-color-input::-webkit-color-swatch {
927
+ border: none;
928
+ border-radius: 4px;
929
+ }
930
+ .ks-color-hex {
931
+ font-family: var(--font-mono);
932
+ font-size: 0.73rem;
933
+ color: var(--text-secondary);
934
+ font-weight: 500;
935
+ }
936
+
937
+ /* Range sliders */
938
+ .ks-slider-wrap {
939
+ display: flex;
940
+ align-items: center;
941
+ gap: 10px;
942
+ }
943
+ .ks-range {
944
+ flex: 1;
945
+ height: 4px;
946
+ -webkit-appearance: none;
947
+ appearance: none;
948
+ background: var(--bg-raised);
949
+ border-radius: 99px;
950
+ outline: none;
951
+ cursor: pointer;
952
+ }
953
+ .ks-range::-webkit-slider-thumb {
954
+ -webkit-appearance: none;
955
+ width: 16px;
956
+ height: 16px;
957
+ border-radius: 50%;
958
+ background: var(--accent);
959
+ border: 2px solid var(--bg-surface);
960
+ box-shadow: 0 0 6px var(--accent-glow);
961
+ cursor: pointer;
962
+ transition: transform 0.15s;
963
+ }
964
+ .ks-range::-webkit-slider-thumb:hover {
965
+ transform: scale(1.2);
966
+ }
967
+ .ks-range::-moz-range-thumb {
968
+ width: 16px;
969
+ height: 16px;
970
+ border-radius: 50%;
971
+ background: var(--accent);
972
+ border: 2px solid var(--bg-surface);
973
+ box-shadow: 0 0 6px var(--accent-glow);
974
+ cursor: pointer;
975
+ }
976
+ .ks-range-val {
977
+ font-family: var(--font-mono);
978
+ font-size: 0.75rem;
979
+ font-weight: 600;
980
+ color: var(--text-primary);
981
+ min-width: 40px;
982
+ text-align: right;
983
+ }
984
+
985
+ /* Toggle switch */
986
+ .ks-toggle-row {
987
+ flex-direction: row;
988
+ align-items: center;
989
+ justify-content: space-between;
990
+ }
991
+ .ks-switch {
992
+ position: relative;
993
+ display: inline-block;
994
+ width: 40px;
995
+ height: 22px;
996
+ flex-shrink: 0;
997
+ }
998
+ .ks-switch input {
999
+ opacity: 0;
1000
+ width: 0;
1001
+ height: 0;
1002
+ }
1003
+ .ks-switch-slider {
1004
+ position: absolute;
1005
+ cursor: pointer;
1006
+ top: 0; left: 0; right: 0; bottom: 0;
1007
+ background: var(--bg-raised);
1008
+ border: 1px solid var(--border);
1009
+ border-radius: 99px;
1010
+ transition: all 0.25s;
1011
+ }
1012
+ .ks-switch-slider::before {
1013
+ content: '';
1014
+ position: absolute;
1015
+ width: 16px;
1016
+ height: 16px;
1017
+ left: 2px;
1018
+ bottom: 2px;
1019
+ background: var(--text-secondary);
1020
+ border-radius: 50%;
1021
+ transition: all 0.25s;
1022
+ }
1023
+ .ks-switch input:checked + .ks-switch-slider {
1024
+ background: var(--accent);
1025
+ border-color: var(--accent);
1026
+ }
1027
+ .ks-switch input:checked + .ks-switch-slider::before {
1028
+ transform: translateX(18px);
1029
+ background: #fff;
1030
+ }
1031
+
1032
+ /* Hints */
1033
+ .ks-hint {
1034
+ font-size: 0.7rem;
1035
+ color: var(--text-muted);
1036
+ margin: 0;
1037
+ line-height: 1.3;
1038
+ }
1039
+ .ks-hint-karaoke {
1040
+ padding-left: 2px;
1041
+ }
1042
+
1043
+ /* --- Responsive tweaks -------------------------------------- */
1044
+ @media (max-width: 640px) {
1045
+ .main { padding: 12px 12px 32px; gap: 12px; }
1046
+ .topbar-inner { padding: 0 14px; }
1047
+ .drop-zone { padding: 24px 16px; }
1048
+ .action-stack { padding: 14px; }
1049
+ .btn-lg { padding: 10px 14px; font-size: 0.85rem; }
1050
+ table { min-width: 580px; }
1051
+ .panel-head { padding: 12px 14px; }
1052
+ .offline-banner { padding: 8px 14px; font-size: 0.78rem; }
1053
+ .mode-btn { padding: 6px 8px; font-size: 0.78rem; }
1054
+ .ks-color-row { flex-direction: column; }
1055
+ .sub-preview-text { font-size: 1rem; }
1056
+ }
templates/index.html ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&family=Bangers&family=Pacifico&family=Dancing+Script:wght@400;700&family=Bebas+Neue&family=Lobster&family=Permanent+Marker&family=Playfair+Display:wght@700;900&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">&times;</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 &mdash; MP4, MOV, MKV, AVI, WebM &le; 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" id="videoWrap">
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
+ <!-- Subtitle Preview Overlay -->
115
+ <div class="sub-preview-overlay" id="subPreviewOverlay" hidden>
116
+ <span class="sub-preview-text" id="subPreviewText">Phụ đề mẫu — Xem trước Karaoke</span>
117
+ </div>
118
+ </div>
119
+ </section>
120
+
121
+ <!-- RIGHT: Action Panel -->
122
+ <section class="panel action-panel">
123
+ <div class="panel-head">
124
+ <h2 class="panel-title">
125
+ <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>
126
+ Điều khiển
127
+ </h2>
128
+ </div>
129
+
130
+ <div class="action-stack">
131
+ <!-- Mode Selector -->
132
+ <div class="mode-selector">
133
+ <label class="mode-label">Chế độ nhận diện</label>
134
+ <div class="mode-toggle" id="modeToggle">
135
+ <button class="mode-btn active" data-mode="music" id="modeMusic">
136
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z"/></svg>
137
+ Lời bài hát
138
+ </button>
139
+ <button class="mode-btn" data-mode="speech" id="modeSpeech">
140
+ <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>
141
+ Giọng nói
142
+ </button>
143
+ </div>
144
+ <p class="mode-hint" id="modeHint">Tối ưu cho Vietsub lời bài hát, nhận diện toàn bộ lyrics khớp timeline.</p>
145
+ </div>
146
+
147
+ <!-- Transcribe -->
148
+ <button id="btnTranscribe" class="btn btn-primary btn-lg btn-full">
149
+ <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>
150
+ Auto sub tiếng Việt
151
+ </button>
152
+
153
+ <!-- Coverage Stats -->
154
+ <div class="coverage-bar" id="coverageBar" hidden>
155
+ <div class="coverage-header">
156
+ <span class="coverage-label">Phủ sóng timeline</span>
157
+ <span class="coverage-pct" id="coveragePct">0%</span>
158
+ </div>
159
+ <div class="coverage-track">
160
+ <div class="coverage-fill" id="coverageFill"></div>
161
+ </div>
162
+ </div>
163
+
164
+ <!-- Progress Bar -->
165
+ <div class="progress-wrap" id="progressWrap" hidden>
166
+ <div class="progress-bar">
167
+ <div class="progress-fill" id="progressFill"></div>
168
+ </div>
169
+ <span class="progress-text" id="progressText">Đang xử lý...</span>
170
+ </div>
171
+
172
+ <!-- Status -->
173
+ <div id="status" class="status-box status-idle">
174
+ <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>
175
+ <span id="statusText">Sẵn sàng. Hãy upload video để bắt đầu.</span>
176
+ </div>
177
+
178
+ <hr class="divider" />
179
+
180
+ <!-- ===== KARAOKE STYLE ===== -->
181
+ <div class="karaoke-style-panel">
182
+ <h3 class="ks-title">
183
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z"/></svg>
184
+ Kiểu Karaoke
185
+ </h3>
186
+
187
+ <!-- Font Selector -->
188
+ <div class="ks-group">
189
+ <label class="ks-label" for="ksFont">Font phụ đề</label>
190
+ <select class="ks-select" id="ksFont">
191
+ <option value="Bangers" style="font-family:'Bangers'">Bangers</option>
192
+ <option value="Bebas Neue" style="font-family:'Bebas Neue'">Bebas Neue</option>
193
+ <option value="Lobster" style="font-family:'Lobster'">Lobster</option>
194
+ <option value="Permanent Marker" style="font-family:'Permanent Marker'">Permanent Marker</option>
195
+ <option value="Pacifico" style="font-family:'Pacifico'">Pacifico</option>
196
+ <option value="Dancing Script" style="font-family:'Dancing Script'">Dancing Script</option>
197
+ <option value="Playfair Display" style="font-family:'Playfair Display'">Playfair Display</option>
198
+ </select>
199
+ </div>
200
+
201
+ <!-- Color Pickers -->
202
+ <div class="ks-group ks-color-row">
203
+ <div class="ks-color-item">
204
+ <label class="ks-label" for="ksColor">Màu chữ</label>
205
+ <div class="ks-color-wrap">
206
+ <input type="color" id="ksColor" value="#FFFFFF" class="ks-color-input" />
207
+ <span class="ks-color-hex" id="ksColorHex">#FFFFFF</span>
208
+ </div>
209
+ </div>
210
+ <div class="ks-color-item">
211
+ <label class="ks-label" for="ksHighlight">Màu highlight</label>
212
+ <div class="ks-color-wrap">
213
+ <input type="color" id="ksHighlight" value="#FFD700" class="ks-color-input" />
214
+ <span class="ks-color-hex" id="ksHighlightHex">#FFD700</span>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <!-- Outline Color -->
220
+ <div class="ks-group ks-color-row">
221
+ <div class="ks-color-item">
222
+ <label class="ks-label" for="ksOutline">Màu viền</label>
223
+ <div class="ks-color-wrap">
224
+ <input type="color" id="ksOutline" value="#000000" class="ks-color-input" />
225
+ <span class="ks-color-hex" id="ksOutlineHex">#000000</span>
226
+ </div>
227
+ </div>
228
+ <div class="ks-color-item">
229
+ <label class="ks-label" for="ksOutlineWidth">Độ dày viền</label>
230
+ <div class="ks-slider-wrap">
231
+ <input type="range" id="ksOutlineWidth" min="0" max="6" step="1" value="2" class="ks-range" />
232
+ <span class="ks-range-val" id="ksOutlineWidthVal">2px</span>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ <!-- Font Size Slider -->
238
+ <div class="ks-group">
239
+ <label class="ks-label" for="ksSize">Cỡ chữ</label>
240
+ <div class="ks-slider-wrap">
241
+ <input type="range" id="ksSize" min="50" max="200" step="5" value="100" class="ks-range" />
242
+ <span class="ks-range-val" id="ksSizeVal">100%</span>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- Vertical Position Slider -->
247
+ <div class="ks-group">
248
+ <label class="ks-label" for="ksPosition">Vị trí dọc</label>
249
+ <div class="ks-slider-wrap">
250
+ <input type="range" id="ksPosition" min="0" max="100" step="1" value="90" class="ks-range" />
251
+ <span class="ks-range-val" id="ksPositionVal">90%</span>
252
+ </div>
253
+ <p class="ks-hint">0% = trên cùng, 100% = dưới cùng</p>
254
+ </div>
255
+
256
+ <!-- Karaoke Word-by-word toggle -->
257
+ <div class="ks-group ks-toggle-row">
258
+ <label class="ks-label" for="ksKaraokeMode">Chế độ Karaoke từng từ</label>
259
+ <label class="ks-switch">
260
+ <input type="checkbox" id="ksKaraokeMode" />
261
+ <span class="ks-switch-slider"></span>
262
+ </label>
263
+ </div>
264
+ <p class="ks-hint ks-hint-karaoke" id="ksKaraokeHint">Bật để highlight từng từ theo nhạc khi xuất MP4.</p>
265
+
266
+ <!-- Preview Button -->
267
+ <button id="btnPreviewStyle" class="btn btn-outline btn-sm btn-full">
268
+ <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>
269
+ Xem trước phụ đề
270
+ </button>
271
+ </div>
272
+
273
+ <hr class="divider" />
274
+
275
+ <!-- Edit Actions -->
276
+ <div class="btn-row">
277
+ <button id="btnAddRow" class="btn btn-outline" disabled>
278
+ <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>
279
+ Thêm dòng
280
+ </button>
281
+ </div>
282
+
283
+ <hr class="divider" />
284
+
285
+ <!-- Export Actions -->
286
+ <div class="export-group">
287
+ <h3 class="export-title">Xuất file</h3>
288
+ <div class="btn-row">
289
+ <button id="btnExportSrt" class="btn btn-outline" disabled>
290
+ <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>
291
+ Xuất .SRT
292
+ </button>
293
+ <button id="btnExportMp4" class="btn btn-success" disabled>
294
+ <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>
295
+ Xuất .MP4 burn sub
296
+ </button>
297
+ </div>
298
+ </div>
299
+
300
+ <!-- Download Links -->
301
+ <div class="download-group" id="downloadGroup" hidden>
302
+ <a id="downloadSrt" class="dl-link dl-srt" href="#" download>
303
+ <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>
304
+ Tải .SRT
305
+ </a>
306
+ <a id="downloadMp4" class="dl-link dl-mp4" href="#" download>
307
+ <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>
308
+ Tải .MP4
309
+ </a>
310
+ </div>
311
+ </div>
312
+ </section>
313
+ </div>
314
+
315
+ <!-- ===== SUBTITLE TABLE ===== -->
316
+ <section class="panel table-panel">
317
+ <div class="panel-head">
318
+ <h2 class="panel-title">
319
+ <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>
320
+ Bảng Subtitle
321
+ </h2>
322
+ <div class="table-meta">
323
+ <span class="seg-count" id="segmentCount">0 dòng</span>
324
+ <button class="btn btn-ghost btn-sm" id="btnCollapseTable" title="Thu gọn">
325
+ <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>
326
+ </button>
327
+ </div>
328
+ </div>
329
+ <div class="table-scroll" id="tableScroll">
330
+ <table>
331
+ <thead>
332
+ <tr>
333
+ <th class="col-idx">#</th>
334
+ <th class="col-time">Bắt đầu</th>
335
+ <th class="col-time">Kết thúc</th>
336
+ <th class="col-text">Nội dung</th>
337
+ <th class="col-act">Thao tác</th>
338
+ </tr>
339
+ </thead>
340
+ <tbody id="subtitleBody">
341
+ <tr class="empty-row">
342
+ <td colspan="5">
343
+ <div class="empty-state">
344
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
345
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
346
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
347
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
348
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
349
+ </svg>
350
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
351
+ </div>
352
+ </td>
353
+ </tr>
354
+ </tbody>
355
+ </table>
356
+ </div>
357
+ </section>
358
+
359
+ </main>
360
+
361
+ <!-- ===== FOOTER ===== -->
362
+ <footer class="footer">
363
+ <span>Viet AutoSub Editor &mdash; Nhận diện giọng nói tiếng Việt bằng Whisper</span>
364
+ </footer>
365
+
366
+ <script src="static/app.js"></script>
367
+ </body>
368
+ </html>