CVNSS commited on
Commit
afb047e
·
verified ·
1 Parent(s): d3d5e82

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +21 -0
  2. README.md +34 -8
  3. app.py +539 -0
  4. requirements.txt +6 -0
  5. static/app.js +602 -0
  6. static/styles.css +812 -0
  7. templates/index.html +269 -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: VietAutoSub2
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: red
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,539 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ExportRequest(BaseModel):
54
+ job_id: str
55
+ segments: List[SegmentIn]
56
+ burn_in: bool = True
57
+
58
+
59
+ class SegmentOut(BaseModel):
60
+ id: int
61
+ start: float
62
+ end: float
63
+ text: str
64
+
65
+
66
+
67
+ def cleanup_old_jobs() -> None:
68
+ cutoff = datetime.utcnow() - timedelta(hours=KEEP_HOURS)
69
+ for folder in WORK_DIR.iterdir():
70
+ if not folder.is_dir():
71
+ continue
72
+ try:
73
+ modified = datetime.utcfromtimestamp(folder.stat().st_mtime)
74
+ if modified < cutoff:
75
+ shutil.rmtree(folder, ignore_errors=True)
76
+ except Exception:
77
+ continue
78
+
79
+
80
+
81
+ def get_model(model_size: str = DEFAULT_MODEL_SIZE) -> WhisperModel:
82
+ with MODEL_LOCK:
83
+ if model_size not in MODEL_CACHE:
84
+ MODEL_CACHE[model_size] = WhisperModel(
85
+ model_size,
86
+ device="cpu",
87
+ compute_type="int8",
88
+ )
89
+ return MODEL_CACHE[model_size]
90
+
91
+
92
+
93
+ def ffmpeg_exists() -> bool:
94
+ return shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
95
+
96
+
97
+
98
+ def save_upload(upload: UploadFile, target_dir: Path) -> Path:
99
+ suffix = Path(upload.filename or "video.mp4").suffix or ".mp4"
100
+ video_path = target_dir / f"source{suffix}"
101
+ with video_path.open("wb") as f:
102
+ while True:
103
+ chunk = upload.file.read(1024 * 1024)
104
+ if not chunk:
105
+ break
106
+ f.write(chunk)
107
+ if f.tell() > MAX_UPLOAD_MB * 1024 * 1024:
108
+ raise HTTPException(status_code=413, detail=f"File quá lớn. Giới hạn {MAX_UPLOAD_MB} MB.")
109
+ return video_path
110
+
111
+
112
+
113
+ def run_ffprobe_duration(video_path: Path) -> Optional[float]:
114
+ try:
115
+ cmd = [
116
+ "ffprobe",
117
+ "-v",
118
+ "error",
119
+ "-show_entries",
120
+ "format=duration",
121
+ "-of",
122
+ "default=noprint_wrappers=1:nokey=1",
123
+ str(video_path),
124
+ ]
125
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
126
+ return float(result.stdout.strip())
127
+ except Exception:
128
+ return None
129
+
130
+
131
+ # ============================================================
132
+ # TRANSCRIPTION — 2 chế độ: "music" (lời bài hát) và "speech" (giọng nói)
133
+ # ============================================================
134
+
135
+ def merge_segments_music(raw_segments: list, max_gap: float = 0.8, max_len: float = 8.0) -> list:
136
+ """
137
+ 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.
138
+ - max_gap: khoảng trống tối đa giữa 2 segment để gộp (giây)
139
+ - max_len: độ dài tối đa 1 segment sau gộp (giây)
140
+ """
141
+ if not raw_segments:
142
+ return []
143
+
144
+ merged = []
145
+ current = {
146
+ "start": raw_segments[0]["start"],
147
+ "end": raw_segments[0]["end"],
148
+ "text": raw_segments[0]["text"],
149
+ }
150
+
151
+ for seg in raw_segments[1:]:
152
+ gap = seg["start"] - current["end"]
153
+ new_duration = seg["end"] - current["start"]
154
+
155
+ # Gộp nếu: khoảng trống nhỏ VÀ tổng thời lượng không quá dài
156
+ if gap <= max_gap and new_duration <= max_len:
157
+ current["end"] = seg["end"]
158
+ current["text"] = current["text"] + " " + seg["text"]
159
+ else:
160
+ merged.append(current)
161
+ current = {
162
+ "start": seg["start"],
163
+ "end": seg["end"],
164
+ "text": seg["text"],
165
+ }
166
+
167
+ merged.append(current)
168
+ return merged
169
+
170
+
171
+ def fill_timeline_gaps(segments: list, total_duration: Optional[float] = None, min_gap: float = 0.3) -> list:
172
+ """
173
+ Lấp khoảng trống lớn giữa các segment.
174
+ Nếu khoảng trống > min_gap, điều chỉnh end/start của segment kề cho liền mạch.
175
+ Giúp subtitle phủ toàn bộ timeline video.
176
+ """
177
+ if not segments:
178
+ return segments
179
+
180
+ result = []
181
+ for i, seg in enumerate(segments):
182
+ s = dict(seg)
183
+
184
+ # Kéo start sớm hơn để lấp gap phía trước
185
+ if i > 0:
186
+ prev_end = result[-1]["end"]
187
+ gap = s["start"] - prev_end
188
+ if 0 < gap <= 1.5:
189
+ # Gap nhỏ: kéo start segment hiện tại lùi lại
190
+ s["start"] = prev_end
191
+ elif gap > 1.5:
192
+ # Gap lớn: kéo end segment trước ra + kéo start hiện tại lùi
193
+ half = gap / 2
194
+ result[-1]["end"] = prev_end + min(half, 0.5)
195
+ s["start"] = s["start"] - min(half, 0.5)
196
+
197
+ result.append(s)
198
+
199
+ # Xử lý end của segment cuối nếu có total_duration
200
+ if total_duration and result:
201
+ last = result[-1]
202
+ remaining = total_duration - last["end"]
203
+ if 0 < remaining <= 2.0:
204
+ last["end"] = total_duration
205
+
206
+ return result
207
+
208
+
209
+ def transcribe_video_music(video_path: Path, duration: Optional[float] = None,
210
+ model_size: str = DEFAULT_MODEL_SIZE) -> List[SegmentOut]:
211
+ """
212
+ Chế độ LỜI BÀI HÁT: tối ưu để nhận diện toàn bộ lyrics.
213
+ - Tắt VAD filter (không cắt đoạn nhạc nền)
214
+ - Tăng beam_size cho accuracy
215
+ - Bật word_timestamps cho khớp chính xác
216
+ - Gộp segment thông minh
217
+ - Lấp khoảng trống timeline
218
+ """
219
+ model = get_model(model_size)
220
+
221
+ segments, info = model.transcribe(
222
+ str(video_path),
223
+ language="vi",
224
+ vad_filter=False, # QUAN TRỌNG: tắt VAD để không bỏ sót lời hát
225
+ beam_size=8, # Tăng beam cho accuracy lời bài hát
226
+ best_of=5, # Sample nhiều hơn, chọn tốt nhất
227
+ patience=1.5, # Kiên nhẫn hơn khi decode
228
+ condition_on_previous_text=True,
229
+ word_timestamps=True, # Timestamp cấp từ → khớp chính xác
230
+ no_speech_threshold=0.3, # Hạ threshold → ít bỏ sót đoạn hát nhỏ
231
+ log_prob_threshold=-1.5, # Chấp nhận xác suất thấp hơn (lời hát khó nghe)
232
+ compression_ratio_threshold=2.8, # Nới ngưỡng nén → ít reject segment
233
+ )
234
+
235
+ raw: list = []
236
+ for seg in segments:
237
+ text = (seg.text or "").strip()
238
+ if not text:
239
+ continue
240
+ raw.append({
241
+ "start": float(seg.start),
242
+ "end": float(seg.end),
243
+ "text": text,
244
+ })
245
+
246
+ if not raw:
247
+ raise HTTPException(status_code=400, detail="Không nhận diện được lời thoại/lời hát trong video.")
248
+
249
+ # Gộp segment ngắn thành câu lời bài hát tự nhiên
250
+ merged = merge_segments_music(raw, max_gap=0.8, max_len=8.0)
251
+
252
+ # Lấp khoảng trống timeline
253
+ filled = fill_timeline_gaps(merged, total_duration=duration)
254
+
255
+ rows: List[SegmentOut] = []
256
+ for idx, seg in enumerate(filled, start=1):
257
+ rows.append(SegmentOut(
258
+ id=idx,
259
+ start=seg["start"],
260
+ end=seg["end"],
261
+ text=seg["text"],
262
+ ))
263
+
264
+ return rows
265
+
266
+
267
+ def transcribe_video_speech(video_path: Path, model_size: str = DEFAULT_MODEL_SIZE) -> List[SegmentOut]:
268
+ """
269
+ Chế độ GIỌNG NÓI: giữ nguyên logic cũ, tối ưu cho lời thoại/thuyết trình.
270
+ - Bật VAD filter (lọc tiếng ồn)
271
+ - beam_size vừa phải
272
+ """
273
+ model = get_model(model_size)
274
+ segments, _info = model.transcribe(
275
+ str(video_path),
276
+ language="vi",
277
+ vad_filter=True,
278
+ beam_size=5,
279
+ condition_on_previous_text=True,
280
+ )
281
+ rows: List[SegmentOut] = []
282
+ for idx, seg in enumerate(segments, start=1):
283
+ text = (seg.text or "").strip()
284
+ if not text:
285
+ continue
286
+ rows.append(
287
+ SegmentOut(
288
+ id=idx,
289
+ start=float(seg.start),
290
+ end=float(seg.end),
291
+ text=text,
292
+ )
293
+ )
294
+ if not rows:
295
+ raise HTTPException(status_code=400, detail="Không nhận diện được lời thoại trong video.")
296
+ return rows
297
+
298
+
299
+
300
+ def format_srt_time(seconds: float) -> str:
301
+ total_ms = max(0, int(round(seconds * 1000)))
302
+ hours = total_ms // 3600000
303
+ total_ms %= 3600000
304
+ minutes = total_ms // 60000
305
+ total_ms %= 60000
306
+ secs = total_ms // 1000
307
+ millis = total_ms % 1000
308
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
309
+
310
+
311
+
312
+ def parse_time_string(value: str) -> float:
313
+ value = value.strip()
314
+ if not value:
315
+ return 0.0
316
+ value = value.replace(".", ",")
317
+ try:
318
+ hhmmss, ms = value.split(",") if "," in value else (value, "0")
319
+ parts = hhmmss.split(":")
320
+ if len(parts) == 2:
321
+ hours = 0
322
+ minutes, secs = parts
323
+ elif len(parts) == 3:
324
+ hours, minutes, secs = parts
325
+ else:
326
+ raise ValueError
327
+ return int(hours) * 3600 + int(minutes) * 60 + int(secs) + int(ms.ljust(3, "0")[:3]) / 1000.0
328
+ except Exception as exc:
329
+ raise HTTPException(status_code=400, detail=f"Sai định dạng thời gian: {value}") from exc
330
+
331
+
332
+
333
+ def write_srt(job_dir: Path, segments: List[SegmentIn]) -> Path:
334
+ srt_path = job_dir / "edited.srt"
335
+ lines: List[str] = []
336
+ cleaned = sorted(segments, key=lambda s: parse_time_string(s.start))
337
+ for idx, seg in enumerate(cleaned, start=1):
338
+ start_sec = parse_time_string(seg.start)
339
+ end_sec = parse_time_string(seg.end)
340
+ if end_sec <= start_sec:
341
+ end_sec = start_sec + 1.0
342
+ text = (seg.text or "").strip()
343
+ if not text:
344
+ continue
345
+ lines.extend(
346
+ [
347
+ str(idx),
348
+ f"{format_srt_time(start_sec)} --> {format_srt_time(end_sec)}",
349
+ text,
350
+ "",
351
+ ]
352
+ )
353
+ if not lines:
354
+ raise HTTPException(status_code=400, detail="Không có subtitle hợp lệ để xuất SRT.")
355
+ srt_path.write_text("\n".join(lines), encoding="utf-8")
356
+ return srt_path
357
+
358
+
359
+
360
+ def burn_subtitles(job_dir: Path, video_path: Path, srt_path: Path) -> Path:
361
+ output_path = job_dir / "output_subtitled.mp4"
362
+ subtitle_filter = (
363
+ "subtitles=edited.srt:"
364
+ "force_style='FontName=DejaVu Sans,FontSize=20,Outline=1,Shadow=0,MarginV=18,Alignment=2'"
365
+ )
366
+ cmd = [
367
+ "ffmpeg",
368
+ "-y",
369
+ "-i",
370
+ video_path.name,
371
+ "-vf",
372
+ subtitle_filter,
373
+ "-c:v",
374
+ "libx264",
375
+ "-preset",
376
+ "veryfast",
377
+ "-crf",
378
+ "23",
379
+ "-c:a",
380
+ "aac",
381
+ "-b:a",
382
+ "192k",
383
+ output_path.name,
384
+ ]
385
+ try:
386
+ subprocess.run(cmd, cwd=job_dir, capture_output=True, text=True, check=True)
387
+ except subprocess.CalledProcessError as exc:
388
+ stderr = (exc.stderr or "").strip()
389
+ raise HTTPException(status_code=500, detail=f"FFmpeg lỗi khi xuất MP4: {stderr[:1200]}") from exc
390
+ return output_path
391
+
392
+
393
+
394
+ def job_meta_path(job_dir: Path) -> Path:
395
+ return job_dir / "meta.json"
396
+
397
+
398
+
399
+ def save_job_meta(job_dir: Path, data: dict) -> None:
400
+ job_meta_path(job_dir).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
401
+
402
+
403
+
404
+ def load_job_meta(job_id: str) -> dict:
405
+ meta = job_meta_path(WORK_DIR / job_id)
406
+ if not meta.exists():
407
+ raise HTTPException(status_code=404, detail="Không tìm thấy job.")
408
+ return json.loads(meta.read_text(encoding="utf-8"))
409
+
410
+
411
+ @app.get("/", response_class=HTMLResponse)
412
+ def home(request: Request):
413
+ return templates.TemplateResponse("index.html", {"request": request})
414
+
415
+
416
+ @app.get("/health")
417
+ def health():
418
+ return {
419
+ "ok": True,
420
+ "ffmpeg": ffmpeg_exists(),
421
+ "workspace": str(WORK_DIR),
422
+ "default_model": DEFAULT_MODEL_SIZE,
423
+ }
424
+
425
+
426
+ @app.post("/api/transcribe")
427
+ def api_transcribe(
428
+ file: UploadFile = File(...),
429
+ mode: str = Form(default="music"),
430
+ ):
431
+ """
432
+ mode: "music" (lời bài hát) hoặc "speech" (giọng nói/thuyết trình)
433
+ """
434
+ cleanup_old_jobs()
435
+ if not ffmpeg_exists():
436
+ raise HTTPException(status_code=500, detail="Máy chủ chưa có FFmpeg.")
437
+
438
+ filename = file.filename or "video.mp4"
439
+ if not filename.lower().endswith((".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v")):
440
+ raise HTTPException(status_code=400, detail="Chỉ hỗ trợ video mp4, mov, mkv, avi, webm, m4v.")
441
+
442
+ if mode not in ("music", "speech"):
443
+ mode = "music"
444
+
445
+ job_id = uuid.uuid4().hex
446
+ job_dir = WORK_DIR / job_id
447
+ job_dir.mkdir(parents=True, exist_ok=True)
448
+ try:
449
+ video_path = save_upload(file, job_dir)
450
+ duration = run_ffprobe_duration(video_path)
451
+
452
+ if mode == "music":
453
+ segments = transcribe_video_music(video_path, duration=duration)
454
+ else:
455
+ segments = transcribe_video_speech(video_path)
456
+
457
+ # Tính coverage: tổng thời lượng sub / tổng video
458
+ total_sub_time = sum(s.end - s.start for s in segments)
459
+ coverage_pct = round((total_sub_time / duration * 100), 1) if duration and duration > 0 else 0
460
+
461
+ save_job_meta(
462
+ job_dir,
463
+ {
464
+ "job_id": job_id,
465
+ "video_path": video_path.name,
466
+ "duration": duration,
467
+ "mode": mode,
468
+ "created_at": datetime.utcnow().isoformat() + "Z",
469
+ },
470
+ )
471
+ return JSONResponse(
472
+ {
473
+ "job_id": job_id,
474
+ "duration": duration,
475
+ "mode": mode,
476
+ "coverage_pct": coverage_pct,
477
+ "segments": [
478
+ {
479
+ "id": seg.id,
480
+ "start": format_srt_time(seg.start),
481
+ "end": format_srt_time(seg.end),
482
+ "text": seg.text,
483
+ }
484
+ for seg in segments
485
+ ],
486
+ }
487
+ )
488
+ except Exception:
489
+ shutil.rmtree(job_dir, ignore_errors=True)
490
+ raise
491
+
492
+
493
+ @app.post("/api/export")
494
+ def api_export(payload: ExportRequest):
495
+ job_dir = WORK_DIR / payload.job_id
496
+ if not job_dir.exists():
497
+ raise HTTPException(status_code=404, detail="Job đã hết hạn hoặc không tồn tại.")
498
+
499
+ meta = load_job_meta(payload.job_id)
500
+ video_path = job_dir / meta["video_path"]
501
+ if not video_path.exists():
502
+ raise HTTPException(status_code=404, detail="Không tìm thấy video gốc để xuất lại.")
503
+
504
+ srt_path = write_srt(job_dir, payload.segments)
505
+ response = {
506
+ "job_id": payload.job_id,
507
+ "srt_url": f"/download/{payload.job_id}/srt",
508
+ "mp4_url": None,
509
+ }
510
+
511
+ if payload.burn_in:
512
+ mp4_path = burn_subtitles(job_dir, video_path, srt_path)
513
+ response["mp4_url"] = f"/download/{payload.job_id}/mp4"
514
+ response["mp4_size_mb"] = round(mp4_path.stat().st_size / (1024 * 1024), 2)
515
+
516
+ return JSONResponse(response)
517
+
518
+
519
+ @app.get("/download/{job_id}/srt")
520
+ def download_srt(job_id: str):
521
+ path = WORK_DIR / job_id / "edited.srt"
522
+ if not path.exists():
523
+ raise HTTPException(status_code=404, detail="Chưa có file SRT.")
524
+ return FileResponse(path, media_type="application/x-subrip", filename=f"{job_id}.srt")
525
+
526
+
527
+ @app.get("/download/{job_id}/mp4")
528
+ def download_mp4(job_id: str):
529
+ path = WORK_DIR / job_id / "output_subtitled.mp4"
530
+ if not path.exists():
531
+ raise HTTPException(status_code=404, detail="Chưa có file MP4.")
532
+ return FileResponse(path, media_type="video/mp4", filename=f"{job_id}.mp4")
533
+
534
+
535
+ if __name__ == "__main__":
536
+ import uvicorn
537
+
538
+ port = int(os.getenv("PORT", "7860"))
539
+ 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,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ };
14
+
15
+ /* --- Detect environment ------------------------------------ */
16
+ const IS_FILE_PROTOCOL = window.location.protocol === "file:";
17
+
18
+ function getApiBase() {
19
+ if (IS_FILE_PROTOCOL) return null;
20
+ return "";
21
+ }
22
+
23
+ /* --- DOM refs ---------------------------------------------- */
24
+ const $ = (id) => document.getElementById(id);
25
+
26
+ const els = {
27
+ fileInput: $("videoFile"),
28
+ preview: $("preview"),
29
+ videoPlaceholder:$("videoPlaceholder"),
30
+ status: $("status"),
31
+ statusText: $("statusText"),
32
+ btnTranscribe: $("btnTranscribe"),
33
+ btnAddRow: $("btnAddRow"),
34
+ btnExportSrt: $("btnExportSrt"),
35
+ btnExportMp4: $("btnExportMp4"),
36
+ btnClearFile: $("btnClearFile"),
37
+ subtitleBody: $("subtitleBody"),
38
+ segmentCount: $("segmentCount"),
39
+ downloadSrt: $("downloadSrt"),
40
+ downloadMp4: $("downloadMp4"),
41
+ downloadGroup: $("downloadGroup"),
42
+ dropZone: $("dropZone"),
43
+ uploadPanel: $("uploadPanel"),
44
+ fileInfo: $("fileInfo"),
45
+ fileName: $("fileName"),
46
+ fileSize: $("fileSize"),
47
+ progressWrap: $("progressWrap"),
48
+ progressFill: $("progressFill"),
49
+ progressText: $("progressText"),
50
+ // Offline banner + badge
51
+ offlineBanner: $("offlineBanner"),
52
+ offlineBannerText: $("offlineBannerText"),
53
+ offlineBannerClose:$("offlineBannerClose"),
54
+ badgeEnv: $("badgeEnv"),
55
+ badgeEnvText: $("badgeEnvText"),
56
+ pulseDot: $("pulseDot"),
57
+ // Mode selector
58
+ modeToggle: $("modeToggle"),
59
+ modeMusic: $("modeMusic"),
60
+ modeSpeech: $("modeSpeech"),
61
+ modeHint: $("modeHint"),
62
+ // Coverage
63
+ coverageBar: $("coverageBar"),
64
+ coveragePct: $("coveragePct"),
65
+ coverageFill: $("coverageFill"),
66
+ };
67
+
68
+ /* --- Mode selector ----------------------------------------- */
69
+ const MODE_HINTS = {
70
+ music: "Tối ưu cho Vietsub lời bài hát, nhận diện toàn bộ lyrics khớp timeline.",
71
+ speech: "Tối ưu cho giọng nói, thuyết trình, podcast. Lọc tiếng ồn nền.",
72
+ };
73
+
74
+ function setMode(mode) {
75
+ state.mode = mode;
76
+ // Update toggle buttons
77
+ if (els.modeMusic) els.modeMusic.classList.toggle("active", mode === "music");
78
+ if (els.modeSpeech) els.modeSpeech.classList.toggle("active", mode === "speech");
79
+ if (els.modeHint) els.modeHint.textContent = MODE_HINTS[mode] || "";
80
+ }
81
+
82
+ // Mode toggle event listeners
83
+ if (els.modeToggle) {
84
+ els.modeToggle.addEventListener("click", (e) => {
85
+ const btn = e.target.closest(".mode-btn");
86
+ if (!btn) return;
87
+ const mode = btn.dataset.mode;
88
+ if (mode) setMode(mode);
89
+ });
90
+ }
91
+
92
+ /* --- Coverage display -------------------------------------- */
93
+ function showCoverage(pct) {
94
+ if (!els.coverageBar) return;
95
+ els.coverageBar.hidden = false;
96
+ const val = Math.min(100, Math.max(0, pct));
97
+ if (els.coveragePct) els.coveragePct.textContent = val + "%";
98
+ if (els.coverageFill) els.coverageFill.style.width = val + "%";
99
+
100
+ // Color coding
101
+ if (els.coverageFill) {
102
+ els.coverageFill.classList.remove("cov-low", "cov-mid", "cov-high");
103
+ if (val >= 80) els.coverageFill.classList.add("cov-high");
104
+ else if (val >= 50) els.coverageFill.classList.add("cov-mid");
105
+ else els.coverageFill.classList.add("cov-low");
106
+ }
107
+ }
108
+
109
+ function hideCoverage() {
110
+ if (els.coverageBar) els.coverageBar.hidden = true;
111
+ }
112
+
113
+ /* --- Health check ------------------------------------------ */
114
+ let healthRetryTimer = null;
115
+
116
+ async function checkHealth() {
117
+ if (IS_FILE_PROTOCOL) {
118
+ setOnlineState(false, "Offline (file://)");
119
+ return;
120
+ }
121
+
122
+ try {
123
+ const res = await fetch("/health", { method: "GET", cache: "no-store" });
124
+ if (res.ok) {
125
+ const data = await res.json();
126
+ setOnlineState(true, "HF Space");
127
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
128
+ } else {
129
+ setOnlineState(false, "Server lỗi");
130
+ }
131
+ } catch (_) {
132
+ setOnlineState(false, "Không kết nối");
133
+ }
134
+ }
135
+
136
+ function setOnlineState(online, label) {
137
+ state.isOnline = online;
138
+
139
+ if (els.badgeEnv) {
140
+ els.badgeEnv.classList.toggle("badge-online", online);
141
+ els.badgeEnv.classList.toggle("badge-offline", !online);
142
+ }
143
+ if (els.badgeEnvText) {
144
+ els.badgeEnvText.textContent = label || (online ? "Online" : "Offline");
145
+ }
146
+ if (els.pulseDot) {
147
+ els.pulseDot.className = online ? "pulse-dot pulse-online" : "pulse-dot pulse-offline";
148
+ }
149
+
150
+ if (!online) {
151
+ if (els.offlineBanner) els.offlineBanner.hidden = false;
152
+ if (els.offlineBannerText) {
153
+ els.offlineBannerText.textContent = IS_FILE_PROTOCOL
154
+ ? "Đ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."
155
+ : "Không kết nối được server — Đang thử lại mỗi 30 giây...";
156
+ }
157
+ if (!IS_FILE_PROTOCOL && !healthRetryTimer) {
158
+ healthRetryTimer = setInterval(checkHealth, 30000);
159
+ }
160
+ } else {
161
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
162
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
163
+ }
164
+ }
165
+
166
+ if (els.offlineBannerClose) {
167
+ els.offlineBannerClose.addEventListener("click", () => {
168
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
169
+ });
170
+ }
171
+
172
+ /* --- Steps ------------------------------------------------- */
173
+ function setStep(num) {
174
+ document.querySelectorAll(".step").forEach((el) => {
175
+ const s = parseInt(el.dataset.step, 10);
176
+ el.classList.toggle("active", s === num);
177
+ el.classList.toggle("done", s < num);
178
+ });
179
+ }
180
+
181
+ /* --- Status ------------------------------------------------ */
182
+ function setStatus(message, type = "idle") {
183
+ els.status.className = `status-box status-${type}`;
184
+ els.statusText.textContent = message;
185
+ }
186
+
187
+ /* --- Buttons state ----------------------------------------- */
188
+ function setEditButtons(enabled) {
189
+ els.btnAddRow.disabled = !enabled;
190
+ els.btnExportSrt.disabled = !enabled;
191
+ els.btnExportMp4.disabled = !enabled;
192
+ }
193
+
194
+ /* --- Download link helpers --------------------------------- */
195
+ function showDownload(el, url, visible) {
196
+ el.href = visible ? url : "#";
197
+ el.classList.toggle("disabled", !visible);
198
+ }
199
+ function showDownloadGroup(show) {
200
+ els.downloadGroup.hidden = !show;
201
+ }
202
+
203
+ /* --- Progress simulation ----------------------------------- */
204
+ let progressTimer = null;
205
+ function startProgress(label) {
206
+ els.progressWrap.hidden = false;
207
+ els.progressFill.style.width = "0%";
208
+ els.progressText.textContent = label || "Đang xử lý...";
209
+
210
+ let pct = 0;
211
+ clearInterval(progressTimer);
212
+ progressTimer = setInterval(() => {
213
+ const remaining = 90 - pct;
214
+ const step = Math.max(0.3, remaining * 0.04);
215
+ pct = Math.min(90, pct + step);
216
+ els.progressFill.style.width = pct + "%";
217
+ }, 300);
218
+ }
219
+ function finishProgress() {
220
+ clearInterval(progressTimer);
221
+ els.progressFill.style.width = "100%";
222
+ setTimeout(() => {
223
+ els.progressWrap.hidden = true;
224
+ els.progressFill.style.width = "0%";
225
+ }, 600);
226
+ }
227
+ function cancelProgress() {
228
+ clearInterval(progressTimer);
229
+ els.progressWrap.hidden = true;
230
+ els.progressFill.style.width = "0%";
231
+ }
232
+
233
+ /* --- File size formatter ----------------------------------- */
234
+ function formatSize(bytes) {
235
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
236
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
237
+ }
238
+
239
+ /* --- Create table cell inputs ------------------------------ */
240
+ function createInput(value, className) {
241
+ const input = document.createElement("input");
242
+ input.type = "text";
243
+ input.value = value || "";
244
+ input.className = className;
245
+ input.spellcheck = false;
246
+ return input;
247
+ }
248
+
249
+ function createTextArea(value) {
250
+ const textarea = document.createElement("textarea");
251
+ textarea.value = value || "";
252
+ textarea.rows = 2;
253
+ textarea.className = "text-input";
254
+ return textarea;
255
+ }
256
+
257
+ /* --- Collect segments from table --------------------------- */
258
+ function collectSegmentsFromTable() {
259
+ const rows = Array.from(els.subtitleBody.querySelectorAll("tr[data-row='1']"));
260
+ return rows.map((row, index) => ({
261
+ id: index + 1,
262
+ start: row.querySelector(".start-input").value.trim(),
263
+ end: row.querySelector(".end-input").value.trim(),
264
+ text: row.querySelector(".text-input").value.trim(),
265
+ }));
266
+ }
267
+
268
+ /* --- Render table ------------------------------------------ */
269
+ function renderTable() {
270
+ els.subtitleBody.innerHTML = "";
271
+
272
+ if (!state.segments.length) {
273
+ els.subtitleBody.innerHTML = `
274
+ <tr class="empty-row">
275
+ <td colspan="5">
276
+ <div class="empty-state">
277
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
278
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
279
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
280
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
281
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
282
+ </svg>
283
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
284
+ </div>
285
+ </td>
286
+ </tr>`;
287
+ els.segmentCount.textContent = "0 dòng";
288
+ setEditButtons(false);
289
+ return;
290
+ }
291
+
292
+ state.segments.forEach((seg, index) => {
293
+ const tr = document.createElement("tr");
294
+ tr.dataset.row = "1";
295
+
296
+ const tdIdx = document.createElement("td");
297
+ tdIdx.className = "idx-cell";
298
+ tdIdx.textContent = String(index + 1);
299
+
300
+ const tdStart = document.createElement("td");
301
+ tdStart.appendChild(createInput(seg.start, "start-input time-input"));
302
+
303
+ const tdEnd = document.createElement("td");
304
+ tdEnd.appendChild(createInput(seg.end, "end-input time-input"));
305
+
306
+ const tdText = document.createElement("td");
307
+ tdText.appendChild(createTextArea(seg.text));
308
+
309
+ const tdAct = document.createElement("td");
310
+ tdAct.style.textAlign = "center";
311
+ const delBtn = document.createElement("button");
312
+ delBtn.className = "btn btn-danger-sm";
313
+ 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>`;
314
+ delBtn.title = "Xóa dòng";
315
+ delBtn.addEventListener("click", () => {
316
+ state.segments = collectSegmentsFromTable();
317
+ state.segments.splice(index, 1);
318
+ renderTable();
319
+ });
320
+ tdAct.appendChild(delBtn);
321
+
322
+ tr.append(tdIdx, tdStart, tdEnd, tdText, tdAct);
323
+ els.subtitleBody.appendChild(tr);
324
+ });
325
+
326
+ els.segmentCount.textContent = `${state.segments.length} dòng`;
327
+ setEditButtons(true);
328
+ }
329
+
330
+ /* --- Transcribe -------------------------------------------- */
331
+ async function transcribeVideo() {
332
+ if (!state.file) {
333
+ setStatus("Hãy chọn video trước.", "error");
334
+ return;
335
+ }
336
+
337
+ if (!state.isOnline) {
338
+ setStatus(
339
+ IS_FILE_PROTOCOL
340
+ ? "Đ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."
341
+ : "Server không phản hồi. Đang thử kết nối lại...",
342
+ "error"
343
+ );
344
+ if (!IS_FILE_PROTOCOL) checkHealth();
345
+ return;
346
+ }
347
+
348
+ const fd = new FormData();
349
+ fd.append("file", state.file);
350
+ fd.append("mode", state.mode);
351
+
352
+ els.btnTranscribe.disabled = true;
353
+ els.btnTranscribe.classList.add("btn-loading");
354
+ const modeLabel = state.mode === "music" ? "lời bài hát" : "giọng nói";
355
+ setStatus(`Đang nhận diện ${modeLabel} tiếng Việt...`, "loading");
356
+ setStep(2);
357
+ startProgress(`Đang upload và nhận diện ${modeLabel}...`);
358
+ showDownload(els.downloadSrt, "#", false);
359
+ showDownload(els.downloadMp4, "#", false);
360
+ showDownloadGroup(false);
361
+ hideCoverage();
362
+
363
+ try {
364
+ const res = await fetch("/api/transcribe", {
365
+ method: "POST",
366
+ body: fd,
367
+ });
368
+ const data = await res.json();
369
+ if (!res.ok) throw new Error(data.detail || "Không thể nhận diện subtitle.");
370
+
371
+ state.jobId = data.job_id;
372
+ state.segments = data.segments || [];
373
+ renderTable();
374
+ finishProgress();
375
+
376
+ // Show coverage
377
+ if (data.coverage_pct !== undefined) {
378
+ showCoverage(data.coverage_pct);
379
+ }
380
+
381
+ const coverageInfo = data.coverage_pct ? ` (phủ ${data.coverage_pct}% timeline)` : "";
382
+ setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng Vietsub${coverageInfo}.`, "success");
383
+ setStep(3);
384
+ } catch (err) {
385
+ cancelProgress();
386
+ const msg = err.message.includes("Failed to fetch")
387
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
388
+ : (err.message || "Có lỗi khi auto sub.");
389
+ setStatus(msg, "error");
390
+ setStep(1);
391
+ checkHealth();
392
+ } finally {
393
+ els.btnTranscribe.disabled = false;
394
+ els.btnTranscribe.classList.remove("btn-loading");
395
+ }
396
+ }
397
+
398
+ /* --- Client-side SRT generation (offline-capable) ---------- */
399
+ function generateSrtString(segments) {
400
+ let lines = [];
401
+ segments.forEach((seg, idx) => {
402
+ const start = seg.start || "00:00:00,000";
403
+ const end = seg.end || "00:00:02,000";
404
+ const text = (seg.text || "").trim();
405
+ if (!text) return;
406
+ lines.push(String(idx + 1));
407
+ lines.push(`${start} --> ${end}`);
408
+ lines.push(text);
409
+ lines.push("");
410
+ });
411
+ return lines.join("\n");
412
+ }
413
+
414
+ function downloadSrtOffline() {
415
+ const segments = collectSegmentsFromTable();
416
+ if (!segments.length) {
417
+ setStatus("Chưa có subtitle để xuất.", "error");
418
+ return;
419
+ }
420
+ const srtContent = generateSrtString(segments);
421
+ const blob = new Blob([srtContent], { type: "text/plain;charset=utf-8" });
422
+ const url = URL.createObjectURL(blob);
423
+ const a = document.createElement("a");
424
+ a.href = url;
425
+ a.download = "subtitle.srt";
426
+ document.body.appendChild(a);
427
+ a.click();
428
+ document.body.removeChild(a);
429
+ URL.revokeObjectURL(url);
430
+ setStatus("Đã xuất file SRT thành công (offline).", "success");
431
+ setStep(4);
432
+ }
433
+
434
+ /* --- Export ------------------------------------------------- */
435
+ async function exportResult(burnIn) {
436
+ if (!burnIn && (!state.isOnline || !state.jobId)) {
437
+ downloadSrtOffline();
438
+ return;
439
+ }
440
+
441
+ if (burnIn && !state.isOnline) {
442
+ setStatus(
443
+ IS_FILE_PROTOCOL
444
+ ? "Xuất MP4 burn sub cần server HF Space. Hãy deploy ứng dụng lên HF Space trước."
445
+ : "Server không phản hồi. Xuất MP4 cần kết nối server.",
446
+ "error"
447
+ );
448
+ return;
449
+ }
450
+
451
+ if (!state.jobId) {
452
+ setStatus("Chưa có job để xuất file. Hãy bấm Auto sub trước.", "error");
453
+ return;
454
+ }
455
+
456
+ const payload = {
457
+ job_id: state.jobId,
458
+ burn_in: burnIn,
459
+ segments: collectSegmentsFromTable(),
460
+ };
461
+
462
+ const label = burnIn ? "Đang xuất MP4 có sub..." : "Đang tạo file SRT...";
463
+ setStatus(label, "loading");
464
+ startProgress(label);
465
+ setStep(4);
466
+ els.btnExportSrt.disabled = true;
467
+ els.btnExportMp4.disabled = true;
468
+
469
+ try {
470
+ const res = await fetch("/api/export", {
471
+ method: "POST",
472
+ headers: { "Content-Type": "application/json" },
473
+ body: JSON.stringify(payload),
474
+ });
475
+ const data = await res.json();
476
+ if (!res.ok) throw new Error(data.detail || "Xuất file thất bại.");
477
+
478
+ finishProgress();
479
+ showDownloadGroup(true);
480
+ showDownload(els.downloadSrt, data.srt_url, true);
481
+ if (data.mp4_url) {
482
+ showDownload(els.downloadMp4, data.mp4_url, true);
483
+ }
484
+
485
+ const msg = burnIn
486
+ ? `Xuất MP4 thành công${data.mp4_size_mb ? ` (${data.mp4_size_mb} MB)` : ""}.`
487
+ : "Đã tạo file SRT thành công.";
488
+ setStatus(msg, "success");
489
+ } catch (err) {
490
+ cancelProgress();
491
+ const msg = err.message.includes("Failed to fetch")
492
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
493
+ : (err.message || "Có lỗi khi xuất file.");
494
+ setStatus(msg, "error");
495
+ checkHealth();
496
+ } finally {
497
+ setEditButtons(true);
498
+ }
499
+ }
500
+
501
+ /* --- File selection ---------------------------------------- */
502
+ function handleFile(file) {
503
+ if (!file) return;
504
+ state.file = file;
505
+ state.jobId = null;
506
+ state.segments = [];
507
+ renderTable();
508
+ showDownload(els.downloadSrt, "#", false);
509
+ showDownload(els.downloadMp4, "#", false);
510
+ showDownloadGroup(false);
511
+ hideCoverage();
512
+
513
+ const url = URL.createObjectURL(file);
514
+ els.preview.src = url;
515
+ els.preview.classList.add("has-src");
516
+ els.videoPlaceholder.classList.add("hidden");
517
+
518
+ els.fileInfo.hidden = false;
519
+ els.fileName.textContent = file.name;
520
+ els.fileSize.textContent = formatSize(file.size);
521
+
522
+ setStatus(`Đã chọn: ${file.name}`, "idle");
523
+ setStep(1);
524
+ }
525
+
526
+ function clearFile() {
527
+ state.file = null;
528
+ state.jobId = null;
529
+ state.segments = [];
530
+ renderTable();
531
+ els.preview.removeAttribute("src");
532
+ els.preview.classList.remove("has-src");
533
+ els.videoPlaceholder.classList.remove("hidden");
534
+ els.fileInfo.hidden = true;
535
+ els.fileInput.value = "";
536
+ showDownloadGroup(false);
537
+ hideCoverage();
538
+ setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle");
539
+ setStep(1);
540
+ }
541
+
542
+ /* --- Event listeners --------------------------------------- */
543
+
544
+ els.fileInput.addEventListener("change", (e) => {
545
+ const [file] = e.target.files || [];
546
+ if (file) handleFile(file);
547
+ });
548
+
549
+ els.dropZone.addEventListener("click", () => els.fileInput.click());
550
+
551
+ els.dropZone.addEventListener("dragover", (e) => {
552
+ e.preventDefault();
553
+ els.dropZone.classList.add("drag-over");
554
+ });
555
+ els.dropZone.addEventListener("dragleave", () => {
556
+ els.dropZone.classList.remove("drag-over");
557
+ });
558
+ els.dropZone.addEventListener("drop", (e) => {
559
+ e.preventDefault();
560
+ els.dropZone.classList.remove("drag-over");
561
+ const file = e.dataTransfer.files[0];
562
+ if (file) {
563
+ const dt = new DataTransfer();
564
+ dt.items.add(file);
565
+ els.fileInput.files = dt.files;
566
+ handleFile(file);
567
+ }
568
+ });
569
+
570
+ els.btnClearFile.addEventListener("click", clearFile);
571
+ els.btnTranscribe.addEventListener("click", transcribeVideo);
572
+ els.btnExportSrt.addEventListener("click", () => exportResult(false));
573
+ els.btnExportMp4.addEventListener("click", () => exportResult(true));
574
+
575
+ els.btnAddRow.addEventListener("click", () => {
576
+ state.segments = collectSegmentsFromTable();
577
+ state.segments.push({
578
+ id: state.segments.length + 1,
579
+ start: "00:00:00,000",
580
+ end: "00:00:02,000",
581
+ text: "Subtitle mới",
582
+ });
583
+ renderTable();
584
+ const scroll = $("tableScroll");
585
+ if (scroll) scroll.scrollTop = scroll.scrollHeight;
586
+ });
587
+
588
+ const btnCollapse = $("btnCollapseTable");
589
+ const tableScroll = $("tableScroll");
590
+ if (btnCollapse && tableScroll) {
591
+ btnCollapse.addEventListener("click", () => {
592
+ const collapsed = tableScroll.style.display === "none";
593
+ tableScroll.style.display = collapsed ? "" : "none";
594
+ btnCollapse.querySelector("svg").style.transform = collapsed ? "" : "rotate(180deg)";
595
+ });
596
+ }
597
+
598
+ /* --- Init -------------------------------------------------- */
599
+ setStep(1);
600
+ setMode("music");
601
+ renderTable();
602
+ checkHealth();
static/styles.css ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Viet AutoSub Editor – Dashboard Stylesheet
3
+ Dark theme — Tương thích offline + HF Spaces online
4
+ ============================================================ */
5
+
6
+ /* --- Tokens ------------------------------------------------- */
7
+ :root {
8
+ --bg-base: #0a0e1a;
9
+ --bg-surface: #111827;
10
+ --bg-raised: #1a2236;
11
+ --bg-input: #0f1629;
12
+ --border: rgba(255,255,255,0.08);
13
+ --border-focus: #6366f1;
14
+
15
+ --text-primary: #f1f5f9;
16
+ --text-secondary: #94a3b8;
17
+ --text-muted: #64748b;
18
+
19
+ --accent: #6366f1;
20
+ --accent-hover: #818cf8;
21
+ --accent-glow: rgba(99,102,241,0.25);
22
+
23
+ --success: #10b981;
24
+ --success-bg: rgba(16,185,129,0.12);
25
+ --danger: #ef4444;
26
+ --danger-bg: rgba(239,68,68,0.10);
27
+ --warning: #f59e0b;
28
+ --warning-bg: rgba(245,158,11,0.12);
29
+
30
+ --radius-sm: 8px;
31
+ --radius: 12px;
32
+ --radius-lg: 16px;
33
+ --radius-xl: 20px;
34
+
35
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
36
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
37
+
38
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
39
+ --shadow: 0 4px 16px rgba(0,0,0,0.35);
40
+ --shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
41
+ }
42
+
43
+ /* --- Reset -------------------------------------------------- */
44
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
45
+ html { font-size: 15px; -webkit-font-smoothing: antialiased; }
46
+ body {
47
+ font-family: var(--font-sans);
48
+ background: var(--bg-base);
49
+ color: var(--text-primary);
50
+ min-height: 100vh;
51
+ line-height: 1.55;
52
+ }
53
+
54
+ /* --- Offline Banner ----------------------------------------- */
55
+ .offline-banner {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 10px;
59
+ padding: 10px 20px;
60
+ background: linear-gradient(90deg, rgba(245,158,11,0.18), rgba(245,158,11,0.08));
61
+ border-bottom: 1px solid rgba(245,158,11,0.3);
62
+ color: #fde68a;
63
+ font-size: 0.84rem;
64
+ font-weight: 500;
65
+ position: relative;
66
+ z-index: 60;
67
+ }
68
+ .offline-banner-icon {
69
+ width: 18px;
70
+ height: 18px;
71
+ flex-shrink: 0;
72
+ color: #f59e0b;
73
+ }
74
+ .offline-banner-close {
75
+ margin-left: auto;
76
+ background: none;
77
+ border: none;
78
+ color: #fde68a;
79
+ font-size: 1.2rem;
80
+ cursor: pointer;
81
+ padding: 2px 6px;
82
+ border-radius: 4px;
83
+ opacity: 0.6;
84
+ transition: opacity 0.2s;
85
+ line-height: 1;
86
+ }
87
+ .offline-banner-close:hover {
88
+ opacity: 1;
89
+ background: rgba(245,158,11,0.15);
90
+ }
91
+
92
+ /* --- Top Nav ------------------------------------------------ */
93
+ .topbar {
94
+ position: sticky; top: 0; z-index: 50;
95
+ background: rgba(10,14,26,0.82);
96
+ backdrop-filter: blur(16px) saturate(1.4);
97
+ border-bottom: 1px solid var(--border);
98
+ }
99
+ .topbar-inner {
100
+ max-width: 1320px;
101
+ margin: 0 auto;
102
+ padding: 0 24px;
103
+ height: 56px;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ }
108
+ .logo-group {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 10px;
112
+ }
113
+ .logo-icon { width: 28px; height: 28px; color: var(--accent); }
114
+ .logo-text {
115
+ font-size: 1.05rem;
116
+ font-weight: 700;
117
+ letter-spacing: -0.02em;
118
+ background: linear-gradient(135deg, #818cf8, #6366f1);
119
+ -webkit-background-clip: text;
120
+ -webkit-text-fill-color: transparent;
121
+ }
122
+ .topbar-right { display: flex; align-items: center; gap: 8px; }
123
+
124
+ /* --- Badges ------------------------------------------------- */
125
+ .badge {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: 6px;
129
+ padding: 4px 10px;
130
+ border-radius: 999px;
131
+ font-size: 0.73rem;
132
+ font-weight: 600;
133
+ letter-spacing: 0.02em;
134
+ text-transform: uppercase;
135
+ transition: all 0.3s;
136
+ }
137
+ /* Online state (green) */
138
+ .badge-env.badge-online,
139
+ .badge-env:not(.badge-offline) {
140
+ background: rgba(16,185,129,0.12);
141
+ color: #6ee7b7;
142
+ border: 1px solid rgba(16,185,129,0.25);
143
+ }
144
+ /* Offline state (amber/red) */
145
+ .badge-env.badge-offline {
146
+ background: rgba(239,68,68,0.12);
147
+ color: #fca5a5;
148
+ border: 1px solid rgba(239,68,68,0.25);
149
+ }
150
+ .badge-model {
151
+ background: rgba(99,102,241,0.12);
152
+ color: #a5b4fc;
153
+ border: 1px solid rgba(99,102,241,0.25);
154
+ font-family: var(--font-mono);
155
+ }
156
+ .pulse-dot {
157
+ width: 6px; height: 6px;
158
+ border-radius: 50%;
159
+ animation: pulse 2s ease-in-out infinite;
160
+ }
161
+ .pulse-dot.pulse-online {
162
+ background: var(--success);
163
+ }
164
+ .pulse-dot.pulse-offline {
165
+ background: var(--danger);
166
+ animation: pulse-fast 1.2s ease-in-out infinite;
167
+ }
168
+ @keyframes pulse {
169
+ 0%, 100% { opacity: 1; }
170
+ 50% { opacity: 0.35; }
171
+ }
172
+ @keyframes pulse-fast {
173
+ 0%, 100% { opacity: 1; }
174
+ 50% { opacity: 0.25; }
175
+ }
176
+
177
+ /* --- Main Layout -------------------------------------------- */
178
+ .main {
179
+ max-width: 1320px;
180
+ margin: 0 auto;
181
+ padding: 20px 24px 40px;
182
+ display: flex;
183
+ flex-direction: column;
184
+ gap: 18px;
185
+ }
186
+
187
+ /* --- Step Indicator ----------------------------------------- */
188
+ .steps {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ gap: 0;
193
+ padding: 14px 0 4px;
194
+ }
195
+ .step {
196
+ display: flex;
197
+ align-items: center;
198
+ gap: 8px;
199
+ opacity: 0.38;
200
+ transition: opacity 0.3s;
201
+ }
202
+ .step.active { opacity: 1; }
203
+ .step.done { opacity: 0.7; }
204
+ .step-num {
205
+ width: 28px; height: 28px;
206
+ display: grid;
207
+ place-items: center;
208
+ border-radius: 50%;
209
+ font-size: 0.78rem;
210
+ font-weight: 700;
211
+ background: var(--bg-raised);
212
+ border: 1.5px solid var(--border);
213
+ color: var(--text-secondary);
214
+ transition: all 0.3s;
215
+ }
216
+ .step.active .step-num {
217
+ background: var(--accent);
218
+ border-color: var(--accent);
219
+ color: #fff;
220
+ box-shadow: 0 0 12px var(--accent-glow);
221
+ }
222
+ .step.done .step-num {
223
+ background: var(--success);
224
+ border-color: var(--success);
225
+ color: #fff;
226
+ }
227
+ .step-label {
228
+ font-size: 0.82rem;
229
+ font-weight: 500;
230
+ color: var(--text-secondary);
231
+ white-space: nowrap;
232
+ }
233
+ .step.active .step-label { color: var(--text-primary); }
234
+ .step-line {
235
+ width: 40px;
236
+ height: 2px;
237
+ background: var(--border);
238
+ margin: 0 6px;
239
+ flex-shrink: 0;
240
+ }
241
+
242
+ /* --- Panel (card) ------------------------------------------- */
243
+ .panel {
244
+ background: var(--bg-surface);
245
+ border: 1px solid var(--border);
246
+ border-radius: var(--radius-lg);
247
+ box-shadow: var(--shadow);
248
+ overflow: hidden;
249
+ }
250
+ .panel-head {
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: space-between;
254
+ padding: 14px 18px;
255
+ border-bottom: 1px solid var(--border);
256
+ }
257
+ .panel-title {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 8px;
261
+ font-size: 0.9rem;
262
+ font-weight: 600;
263
+ color: var(--text-primary);
264
+ }
265
+ .icon-sm { width: 16px; height: 16px; flex-shrink: 0; }
266
+ .icon-xs { width: 14px; height: 14px; }
267
+ .icon-btn { width: 16px; height: 16px; flex-shrink: 0; }
268
+
269
+ /* --- Upload Panel ------------------------------------------- */
270
+ .upload-panel { padding: 0; }
271
+ .drop-zone {
272
+ display: flex;
273
+ flex-direction: column;
274
+ align-items: center;
275
+ justify-content: center;
276
+ gap: 8px;
277
+ padding: 36px 24px;
278
+ cursor: pointer;
279
+ border: 2px dashed transparent;
280
+ transition: all 0.25s;
281
+ background: linear-gradient(180deg, rgba(99,102,241,0.04), transparent);
282
+ }
283
+ .drop-zone.drag-over {
284
+ border-color: var(--accent);
285
+ background: rgba(99,102,241,0.08);
286
+ }
287
+ .drop-icon { width: 44px; height: 44px; color: var(--accent); opacity: 0.7; }
288
+ .drop-title {
289
+ font-size: 1rem;
290
+ font-weight: 600;
291
+ color: var(--text-primary);
292
+ }
293
+ .drop-hint {
294
+ font-size: 0.8rem;
295
+ color: var(--text-muted);
296
+ }
297
+ .file-info {
298
+ display: flex;
299
+ align-items: center;
300
+ justify-content: space-between;
301
+ padding: 12px 18px;
302
+ background: rgba(99,102,241,0.06);
303
+ border-top: 1px solid var(--border);
304
+ }
305
+ .file-meta {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 8px;
309
+ font-size: 0.85rem;
310
+ font-weight: 500;
311
+ }
312
+ .file-icon { width: 16px; height: 16px; color: var(--accent); }
313
+ .file-size {
314
+ color: var(--text-muted);
315
+ font-size: 0.78rem;
316
+ font-family: var(--font-mono);
317
+ }
318
+
319
+ /* --- Two-column Grid ---------------------------------------- */
320
+ .grid-two {
321
+ display: grid;
322
+ grid-template-columns: 1.3fr 0.7fr;
323
+ gap: 18px;
324
+ }
325
+ @media (max-width: 960px) {
326
+ .grid-two { grid-template-columns: 1fr; }
327
+ .steps { flex-wrap: wrap; gap: 4px; }
328
+ .step-label { display: none; }
329
+ }
330
+
331
+ /* --- Video -------------------------------------------------- */
332
+ .video-panel .panel-head + * { padding: 0; }
333
+ .video-wrap {
334
+ position: relative;
335
+ background: #000;
336
+ aspect-ratio: 16/9;
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ }
341
+ .video-wrap video {
342
+ width: 100%;
343
+ height: 100%;
344
+ object-fit: contain;
345
+ display: none;
346
+ }
347
+ .video-wrap video.has-src { display: block; }
348
+ .video-placeholder {
349
+ display: flex;
350
+ flex-direction: column;
351
+ align-items: center;
352
+ gap: 8px;
353
+ color: var(--text-muted);
354
+ font-size: 0.85rem;
355
+ }
356
+ .video-placeholder.hidden { display: none; }
357
+ .placeholder-icon { width: 56px; height: 56px; opacity: 0.3; }
358
+
359
+ /* --- Action Panel ------------------------------------------- */
360
+ .action-panel { display: flex; flex-direction: column; }
361
+ .action-stack {
362
+ padding: 18px;
363
+ display: flex;
364
+ flex-direction: column;
365
+ gap: 14px;
366
+ flex: 1;
367
+ }
368
+ .divider {
369
+ border: none;
370
+ border-top: 1px solid var(--border);
371
+ margin: 2px 0;
372
+ }
373
+ .export-title {
374
+ font-size: 0.78rem;
375
+ font-weight: 600;
376
+ color: var(--text-muted);
377
+ text-transform: uppercase;
378
+ letter-spacing: 0.06em;
379
+ margin-bottom: 6px;
380
+ }
381
+
382
+ /* --- Buttons ------------------------------------------------ */
383
+ .btn {
384
+ display: inline-flex;
385
+ align-items: center;
386
+ justify-content: center;
387
+ gap: 8px;
388
+ font-family: var(--font-sans);
389
+ font-size: 0.85rem;
390
+ font-weight: 600;
391
+ border: 1px solid var(--border);
392
+ border-radius: var(--radius);
393
+ padding: 10px 16px;
394
+ cursor: pointer;
395
+ background: var(--bg-raised);
396
+ color: var(--text-primary);
397
+ transition: all 0.2s ease;
398
+ white-space: nowrap;
399
+ }
400
+ .btn:hover:not(:disabled) {
401
+ transform: translateY(-1px);
402
+ box-shadow: var(--shadow-sm);
403
+ }
404
+ .btn:active:not(:disabled) {
405
+ transform: translateY(0);
406
+ }
407
+ .btn:disabled {
408
+ opacity: 0.35;
409
+ cursor: not-allowed;
410
+ transform: none;
411
+ }
412
+ .btn-primary {
413
+ background: var(--accent);
414
+ border-color: transparent;
415
+ color: #fff;
416
+ }
417
+ .btn-primary:hover:not(:disabled) {
418
+ background: var(--accent-hover);
419
+ box-shadow: 0 4px 20px var(--accent-glow);
420
+ }
421
+ .btn-success {
422
+ background: var(--success);
423
+ border-color: transparent;
424
+ color: #fff;
425
+ }
426
+ .btn-success:hover:not(:disabled) {
427
+ background: #34d399;
428
+ box-shadow: 0 4px 20px rgba(16,185,129,0.3);
429
+ }
430
+ .btn-outline {
431
+ background: transparent;
432
+ border-color: var(--border);
433
+ }
434
+ .btn-outline:hover:not(:disabled) {
435
+ background: var(--bg-raised);
436
+ border-color: rgba(255,255,255,0.15);
437
+ }
438
+ .btn-ghost {
439
+ background: transparent;
440
+ border: none;
441
+ color: var(--text-secondary);
442
+ padding: 6px 10px;
443
+ }
444
+ .btn-ghost:hover:not(:disabled) {
445
+ color: var(--text-primary);
446
+ background: rgba(255,255,255,0.05);
447
+ }
448
+ .btn-danger-sm {
449
+ background: var(--danger-bg);
450
+ border: 1px solid rgba(239,68,68,0.25);
451
+ color: #fca5a5;
452
+ padding: 6px 10px;
453
+ font-size: 0.78rem;
454
+ }
455
+ .btn-danger-sm:hover:not(:disabled) {
456
+ background: rgba(239,68,68,0.2);
457
+ }
458
+ .btn-sm { padding: 6px 12px; font-size: 0.8rem; }
459
+ .btn-lg { padding: 12px 20px; font-size: 0.92rem; }
460
+ .btn-full { width: 100%; }
461
+ .btn-row {
462
+ display: flex;
463
+ gap: 8px;
464
+ flex-wrap: wrap;
465
+ }
466
+
467
+ /* --- Progress ----------------------------------------------- */
468
+ .progress-wrap {
469
+ display: flex;
470
+ flex-direction: column;
471
+ gap: 6px;
472
+ }
473
+ .progress-bar {
474
+ height: 6px;
475
+ background: var(--bg-raised);
476
+ border-radius: 99px;
477
+ overflow: hidden;
478
+ }
479
+ .progress-fill {
480
+ height: 100%;
481
+ width: 0%;
482
+ background: linear-gradient(90deg, var(--accent), #818cf8);
483
+ border-radius: 99px;
484
+ transition: width 0.4s ease;
485
+ animation: progressPulse 1.5s ease-in-out infinite;
486
+ }
487
+ @keyframes progressPulse {
488
+ 0%, 100% { opacity: 1; }
489
+ 50% { opacity: 0.6; }
490
+ }
491
+ .progress-text {
492
+ font-size: 0.78rem;
493
+ color: var(--text-secondary);
494
+ font-weight: 500;
495
+ }
496
+
497
+ /* --- Status Box --------------------------------------------- */
498
+ .status-box {
499
+ display: flex;
500
+ align-items: center;
501
+ gap: 8px;
502
+ padding: 10px 14px;
503
+ border-radius: var(--radius);
504
+ font-size: 0.83rem;
505
+ font-weight: 500;
506
+ transition: all 0.3s;
507
+ }
508
+ .status-icon { width: 16px; height: 16px; flex-shrink: 0; }
509
+ .status-idle {
510
+ background: rgba(255,255,255,0.03);
511
+ color: var(--text-secondary);
512
+ }
513
+ .status-loading {
514
+ background: var(--warning-bg);
515
+ color: #fde68a;
516
+ }
517
+ .status-success {
518
+ background: var(--success-bg);
519
+ color: #6ee7b7;
520
+ }
521
+ .status-error {
522
+ background: var(--danger-bg);
523
+ color: #fca5a5;
524
+ }
525
+
526
+ /* --- Download Links ----------------------------------------- */
527
+ .download-group {
528
+ display: flex;
529
+ gap: 10px;
530
+ flex-wrap: wrap;
531
+ }
532
+ .dl-link {
533
+ display: inline-flex;
534
+ align-items: center;
535
+ gap: 8px;
536
+ padding: 10px 16px;
537
+ border-radius: var(--radius);
538
+ font-size: 0.85rem;
539
+ font-weight: 600;
540
+ text-decoration: none;
541
+ transition: all 0.2s;
542
+ }
543
+ .dl-srt {
544
+ background: rgba(99,102,241,0.12);
545
+ color: #a5b4fc;
546
+ border: 1px solid rgba(99,102,241,0.25);
547
+ }
548
+ .dl-srt:hover { background: rgba(99,102,241,0.2); }
549
+ .dl-mp4 {
550
+ background: rgba(16,185,129,0.12);
551
+ color: #6ee7b7;
552
+ border: 1px solid rgba(16,185,129,0.25);
553
+ }
554
+ .dl-mp4:hover { background: rgba(16,185,129,0.2); }
555
+ .dl-link.disabled { pointer-events: none; opacity: 0.35; }
556
+
557
+ /* --- Table -------------------------------------------------- */
558
+ .table-panel { }
559
+ .table-meta {
560
+ display: flex;
561
+ align-items: center;
562
+ gap: 10px;
563
+ }
564
+ .seg-count {
565
+ font-size: 0.78rem;
566
+ font-weight: 600;
567
+ color: var(--text-muted);
568
+ font-family: var(--font-mono);
569
+ background: var(--bg-raised);
570
+ padding: 3px 10px;
571
+ border-radius: 999px;
572
+ }
573
+ .table-scroll {
574
+ overflow-x: auto;
575
+ max-height: 480px;
576
+ overflow-y: auto;
577
+ }
578
+ table {
579
+ width: 100%;
580
+ border-collapse: collapse;
581
+ min-width: 720px;
582
+ }
583
+ thead { position: sticky; top: 0; z-index: 5; }
584
+ th {
585
+ text-align: left;
586
+ padding: 10px 14px;
587
+ font-size: 0.73rem;
588
+ font-weight: 600;
589
+ text-transform: uppercase;
590
+ letter-spacing: 0.06em;
591
+ color: var(--text-muted);
592
+ background: var(--bg-raised);
593
+ border-bottom: 1px solid var(--border);
594
+ }
595
+ td {
596
+ padding: 8px 14px;
597
+ vertical-align: top;
598
+ border-bottom: 1px solid var(--border);
599
+ font-size: 0.85rem;
600
+ }
601
+ tr:last-child td { border-bottom: none; }
602
+ tr:hover td { background: rgba(255,255,255,0.02); }
603
+ .col-idx { width: 48px; text-align: center; }
604
+ .col-time { width: 155px; }
605
+ .col-text { }
606
+ .col-act { width: 72px; text-align: center; }
607
+
608
+ /* Row index number */
609
+ td.idx-cell {
610
+ text-align: center;
611
+ font-family: var(--font-mono);
612
+ font-size: 0.78rem;
613
+ color: var(--text-muted);
614
+ font-weight: 600;
615
+ }
616
+
617
+ /* --- Table Inputs ------------------------------------------- */
618
+ .time-input, .text-input {
619
+ width: 100%;
620
+ border-radius: var(--radius-sm);
621
+ border: 1px solid var(--border);
622
+ background: var(--bg-input);
623
+ color: var(--text-primary);
624
+ padding: 8px 10px;
625
+ font-family: var(--font-mono);
626
+ font-size: 0.82rem;
627
+ transition: border-color 0.2s, box-shadow 0.2s;
628
+ }
629
+ .time-input:focus, .text-input:focus {
630
+ outline: none;
631
+ border-color: var(--border-focus);
632
+ box-shadow: 0 0 0 3px var(--accent-glow);
633
+ }
634
+ .text-input {
635
+ font-family: var(--font-sans);
636
+ resize: vertical;
637
+ min-height: 54px;
638
+ line-height: 1.45;
639
+ }
640
+
641
+ /* --- Empty State -------------------------------------------- */
642
+ .empty-row td { padding: 40px 20px; }
643
+ .empty-state {
644
+ display: flex;
645
+ flex-direction: column;
646
+ align-items: center;
647
+ gap: 12px;
648
+ color: var(--text-muted);
649
+ text-align: center;
650
+ }
651
+ .empty-icon { width: 48px; height: 48px; opacity: 0.35; }
652
+ .empty-state p { font-size: 0.85rem; max-width: 380px; }
653
+
654
+ /* --- Footer ------------------------------------------------- */
655
+ .footer {
656
+ text-align: center;
657
+ padding: 20px 24px;
658
+ font-size: 0.75rem;
659
+ color: var(--text-muted);
660
+ border-top: 1px solid var(--border);
661
+ }
662
+
663
+ /* --- Utilities ---------------------------------------------- */
664
+ [hidden] { display: none !important; }
665
+
666
+ /* --- Scrollbar ---------------------------------------------- */
667
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
668
+ ::-webkit-scrollbar-track { background: transparent; }
669
+ ::-webkit-scrollbar-thumb {
670
+ background: rgba(255,255,255,0.1);
671
+ border-radius: 99px;
672
+ }
673
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
674
+
675
+ /* --- Spinner for loading button ----------------------------- */
676
+ .btn-loading {
677
+ position: relative;
678
+ pointer-events: none;
679
+ color: transparent !important;
680
+ }
681
+ .btn-loading::after {
682
+ content: '';
683
+ position: absolute;
684
+ width: 18px; height: 18px;
685
+ border: 2px solid rgba(255,255,255,0.3);
686
+ border-top-color: #fff;
687
+ border-radius: 50%;
688
+ animation: spin 0.6s linear infinite;
689
+ }
690
+ @keyframes spin { to { transform: rotate(360deg); } }
691
+
692
+ /* --- Mode Selector ------------------------------------------ */
693
+ .mode-selector {
694
+ display: flex;
695
+ flex-direction: column;
696
+ gap: 8px;
697
+ }
698
+ .mode-label {
699
+ font-size: 0.78rem;
700
+ font-weight: 600;
701
+ color: var(--text-muted);
702
+ text-transform: uppercase;
703
+ letter-spacing: 0.06em;
704
+ }
705
+ .mode-toggle {
706
+ display: flex;
707
+ gap: 6px;
708
+ background: var(--bg-base);
709
+ border-radius: var(--radius);
710
+ padding: 4px;
711
+ border: 1px solid var(--border);
712
+ }
713
+ .mode-btn {
714
+ flex: 1;
715
+ display: inline-flex;
716
+ align-items: center;
717
+ justify-content: center;
718
+ gap: 6px;
719
+ padding: 8px 12px;
720
+ border: none;
721
+ border-radius: var(--radius-sm);
722
+ background: transparent;
723
+ color: var(--text-secondary);
724
+ font-family: var(--font-sans);
725
+ font-size: 0.82rem;
726
+ font-weight: 600;
727
+ cursor: pointer;
728
+ transition: all 0.2s;
729
+ white-space: nowrap;
730
+ }
731
+ .mode-btn:hover:not(.active) {
732
+ color: var(--text-primary);
733
+ background: rgba(255,255,255,0.04);
734
+ }
735
+ .mode-btn.active {
736
+ background: var(--accent);
737
+ color: #fff;
738
+ box-shadow: 0 2px 8px var(--accent-glow);
739
+ }
740
+ .mode-btn .icon-btn {
741
+ width: 14px;
742
+ height: 14px;
743
+ }
744
+ .mode-hint {
745
+ font-size: 0.75rem;
746
+ color: var(--text-muted);
747
+ line-height: 1.4;
748
+ margin: 0;
749
+ }
750
+
751
+ /* --- Coverage Bar ------------------------------------------- */
752
+ .coverage-bar {
753
+ display: flex;
754
+ flex-direction: column;
755
+ gap: 6px;
756
+ padding: 10px 14px;
757
+ background: rgba(255,255,255,0.02);
758
+ border-radius: var(--radius);
759
+ border: 1px solid var(--border);
760
+ }
761
+ .coverage-header {
762
+ display: flex;
763
+ align-items: center;
764
+ justify-content: space-between;
765
+ }
766
+ .coverage-label {
767
+ font-size: 0.75rem;
768
+ font-weight: 600;
769
+ color: var(--text-muted);
770
+ text-transform: uppercase;
771
+ letter-spacing: 0.04em;
772
+ }
773
+ .coverage-pct {
774
+ font-size: 0.82rem;
775
+ font-weight: 700;
776
+ font-family: var(--font-mono);
777
+ color: var(--text-primary);
778
+ }
779
+ .coverage-track {
780
+ height: 6px;
781
+ background: var(--bg-raised);
782
+ border-radius: 99px;
783
+ overflow: hidden;
784
+ }
785
+ .coverage-fill {
786
+ height: 100%;
787
+ width: 0%;
788
+ border-radius: 99px;
789
+ transition: width 0.6s ease, background 0.3s;
790
+ }
791
+ .coverage-fill.cov-high {
792
+ background: linear-gradient(90deg, var(--success), #34d399);
793
+ }
794
+ .coverage-fill.cov-mid {
795
+ background: linear-gradient(90deg, var(--warning), #fbbf24);
796
+ }
797
+ .coverage-fill.cov-low {
798
+ background: linear-gradient(90deg, var(--danger), #f87171);
799
+ }
800
+
801
+ /* --- Responsive tweaks -------------------------------------- */
802
+ @media (max-width: 640px) {
803
+ .main { padding: 12px 12px 32px; gap: 12px; }
804
+ .topbar-inner { padding: 0 14px; }
805
+ .drop-zone { padding: 24px 16px; }
806
+ .action-stack { padding: 14px; }
807
+ .btn-lg { padding: 10px 14px; font-size: 0.85rem; }
808
+ table { min-width: 580px; }
809
+ .panel-head { padding: 12px 14px; }
810
+ .offline-banner { padding: 8px 14px; font-size: 0.78rem; }
811
+ .mode-btn { padding: 6px 8px; font-size: 0.78rem; }
812
+ }
templates/index.html ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Viet AutoSub Editor</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="static/styles.css" />
11
+ </head>
12
+ <body>
13
+
14
+ <!-- ===== OFFLINE BANNER ===== -->
15
+ <div class="offline-banner" id="offlineBanner" hidden>
16
+ <svg viewBox="0 0 20 20" fill="currentColor" class="offline-banner-icon">
17
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
18
+ </svg>
19
+ <span id="offlineBannerText">Đang chạy offline — Chức năng AI (auto sub, xuất MP4) cần kết nối server HF Space.</span>
20
+ <button class="offline-banner-close" id="offlineBannerClose" title="Đóng">&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">
106
+ <video id="preview" controls playsinline></video>
107
+ <div class="video-placeholder" id="videoPlaceholder">
108
+ <svg viewBox="0 0 64 64" fill="none" class="placeholder-icon">
109
+ <rect x="8" y="14" width="48" height="36" rx="6" stroke="currentColor" stroke-width="2"/>
110
+ <polygon points="26,24 42,32 26,40" fill="currentColor" opacity="0.3"/>
111
+ </svg>
112
+ <span>Chưa có video</span>
113
+ </div>
114
+ </div>
115
+ </section>
116
+
117
+ <!-- RIGHT: Action Panel -->
118
+ <section class="panel action-panel">
119
+ <div class="panel-head">
120
+ <h2 class="panel-title">
121
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-sm"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
122
+ Điều khiển
123
+ </h2>
124
+ </div>
125
+
126
+ <div class="action-stack">
127
+ <!-- Mode Selector -->
128
+ <div class="mode-selector">
129
+ <label class="mode-label">Chế độ nhận diện</label>
130
+ <div class="mode-toggle" id="modeToggle">
131
+ <button class="mode-btn active" data-mode="music" id="modeMusic">
132
+ <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>
133
+ Lời bài hát
134
+ </button>
135
+ <button class="mode-btn" data-mode="speech" id="modeSpeech">
136
+ <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>
137
+ Giọng nói
138
+ </button>
139
+ </div>
140
+ <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>
141
+ </div>
142
+
143
+ <!-- Transcribe -->
144
+ <button id="btnTranscribe" class="btn btn-primary btn-lg btn-full">
145
+ <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>
146
+ Auto sub tiếng Việt
147
+ </button>
148
+
149
+ <!-- Coverage Stats -->
150
+ <div class="coverage-bar" id="coverageBar" hidden>
151
+ <div class="coverage-header">
152
+ <span class="coverage-label">Phủ sóng timeline</span>
153
+ <span class="coverage-pct" id="coveragePct">0%</span>
154
+ </div>
155
+ <div class="coverage-track">
156
+ <div class="coverage-fill" id="coverageFill"></div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Progress Bar -->
161
+ <div class="progress-wrap" id="progressWrap" hidden>
162
+ <div class="progress-bar">
163
+ <div class="progress-fill" id="progressFill"></div>
164
+ </div>
165
+ <span class="progress-text" id="progressText">Đang xử lý...</span>
166
+ </div>
167
+
168
+ <!-- Status -->
169
+ <div id="status" class="status-box status-idle">
170
+ <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>
171
+ <span id="statusText">Sẵn sàng. Hãy upload video để bắt đầu.</span>
172
+ </div>
173
+
174
+ <hr class="divider" />
175
+
176
+ <!-- Edit Actions -->
177
+ <div class="btn-row">
178
+ <button id="btnAddRow" class="btn btn-outline" disabled>
179
+ <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>
180
+ Thêm dòng
181
+ </button>
182
+ </div>
183
+
184
+ <hr class="divider" />
185
+
186
+ <!-- Export Actions -->
187
+ <div class="export-group">
188
+ <h3 class="export-title">Xuất file</h3>
189
+ <div class="btn-row">
190
+ <button id="btnExportSrt" class="btn btn-outline" disabled>
191
+ <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>
192
+ Xuất .SRT
193
+ </button>
194
+ <button id="btnExportMp4" class="btn btn-success" disabled>
195
+ <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>
196
+ Xuất .MP4 burn sub
197
+ </button>
198
+ </div>
199
+ </div>
200
+
201
+ <!-- Download Links -->
202
+ <div class="download-group" id="downloadGroup" hidden>
203
+ <a id="downloadSrt" class="dl-link dl-srt" href="#" download>
204
+ <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>
205
+ Tải .SRT
206
+ </a>
207
+ <a id="downloadMp4" class="dl-link dl-mp4" href="#" download>
208
+ <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>
209
+ Tải .MP4
210
+ </a>
211
+ </div>
212
+ </div>
213
+ </section>
214
+ </div>
215
+
216
+ <!-- ===== SUBTITLE TABLE ===== -->
217
+ <section class="panel table-panel">
218
+ <div class="panel-head">
219
+ <h2 class="panel-title">
220
+ <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>
221
+ Bảng Subtitle
222
+ </h2>
223
+ <div class="table-meta">
224
+ <span class="seg-count" id="segmentCount">0 dòng</span>
225
+ <button class="btn btn-ghost btn-sm" id="btnCollapseTable" title="Thu gọn">
226
+ <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>
227
+ </button>
228
+ </div>
229
+ </div>
230
+ <div class="table-scroll" id="tableScroll">
231
+ <table>
232
+ <thead>
233
+ <tr>
234
+ <th class="col-idx">#</th>
235
+ <th class="col-time">Bắt đầu</th>
236
+ <th class="col-time">Kết thúc</th>
237
+ <th class="col-text">Nội dung</th>
238
+ <th class="col-act">Thao tác</th>
239
+ </tr>
240
+ </thead>
241
+ <tbody id="subtitleBody">
242
+ <tr class="empty-row">
243
+ <td colspan="5">
244
+ <div class="empty-state">
245
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
246
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
247
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
248
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
249
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
250
+ </svg>
251
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
252
+ </div>
253
+ </td>
254
+ </tr>
255
+ </tbody>
256
+ </table>
257
+ </div>
258
+ </section>
259
+
260
+ </main>
261
+
262
+ <!-- ===== FOOTER ===== -->
263
+ <footer class="footer">
264
+ <span>Viet AutoSub Editor &mdash; Nhận diện giọng nói tiếng Việt bằng Whisper</span>
265
+ </footer>
266
+
267
+ <script src="static/app.js"></script>
268
+ </body>
269
+ </html>