CVNSS commited on
Commit
ebc2b00
·
verified ·
1 Parent(s): 54a8c89

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +22 -0
  2. app.py +835 -0
  3. requirements.txt +6 -0
  4. static/app.js +968 -0
  5. static/cvnss4.0-converter.js +417 -0
  6. static/styles.css +1089 -0
  7. templates/index.html +373 -0
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ fontconfig \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /app
17
+ COPY requirements.txt ./
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ COPY . .
21
+ EXPOSE 7860
22
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,835 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ FONTS_DIR = APP_DIR / "fonts"
25
+ WORK_DIR.mkdir(parents=True, exist_ok=True)
26
+ FONTS_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+
29
+ app = FastAPI(title="Viet AutoSub Editor")
30
+ app.add_middleware(
31
+ CORSMiddleware,
32
+ allow_origins=["*"],
33
+ allow_credentials=True,
34
+ allow_methods=["*"],
35
+ allow_headers=["*"],
36
+ )
37
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
38
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
39
+
40
+
41
+ MODEL_LOCK = threading.Lock()
42
+ MODEL_CACHE = {}
43
+ DEFAULT_MODEL_SIZE = os.getenv("WHISPER_MODEL_SIZE", "small")
44
+ MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "250"))
45
+ KEEP_HOURS = int(os.getenv("KEEP_HOURS", "24"))
46
+ FFMPEG_TIMEOUT = int(os.getenv("FFMPEG_TIMEOUT", "600")) # seconds
47
+
48
+ # ============================================================
49
+ # FONT MANAGEMENT — download Google Fonts cho ffmpeg
50
+ # ============================================================
51
+
52
+ GOOGLE_FONT_URLS = {
53
+ "Bangers": "https://fonts.google.com/download?family=Bangers",
54
+ "Bebas Neue": "https://fonts.google.com/download?family=Bebas+Neue",
55
+ "Lobster": "https://fonts.google.com/download?family=Lobster",
56
+ "Permanent Marker": "https://fonts.google.com/download?family=Permanent+Marker",
57
+ "Pacifico": "https://fonts.google.com/download?family=Pacifico",
58
+ "Dancing Script": "https://fonts.google.com/download?family=Dancing+Script",
59
+ "Playfair Display": "https://fonts.google.com/download?family=Playfair+Display",
60
+ }
61
+
62
+ # Map font name → tên file TTF/OTF thực tế bên trong zip
63
+ FONT_FILE_MAP = {
64
+ "Bangers": "Bangers-Regular.ttf",
65
+ "Bebas Neue": "BebasNeue-Regular.ttf",
66
+ "Lobster": "Lobster-Regular.ttf",
67
+ "Permanent Marker": "PermanentMarker-Regular.ttf",
68
+ "Pacifico": "Pacifico-Regular.ttf",
69
+ "Dancing Script": "DancingScript-Regular.ttf",
70
+ "Playfair Display": "PlayfairDisplay-Regular.ttf",
71
+ }
72
+
73
+
74
+ def ensure_font_available(font_name: str) -> str:
75
+ """
76
+ Đảm bảo font có sẵn cho FFmpeg.
77
+ Trả về tên font mà FFmpeg sẽ dùng.
78
+ Nếu không tải được, fallback về DejaVu Sans.
79
+ """
80
+ if font_name == "DejaVu Sans" or font_name not in FONT_FILE_MAP:
81
+ return "DejaVu Sans"
82
+
83
+ ttf_name = FONT_FILE_MAP[font_name]
84
+ # Kiểm tra font đã cài chưa (trong /usr/share/fonts hoặc ~/.fonts)
85
+ user_fonts_dir = Path.home() / ".fonts"
86
+ user_fonts_dir.mkdir(parents=True, exist_ok=True)
87
+ target = user_fonts_dir / ttf_name
88
+
89
+ if target.exists():
90
+ return font_name
91
+
92
+ # Thử tải font từ Google Fonts
93
+ try:
94
+ import zipfile
95
+ import io
96
+ import urllib.request
97
+
98
+ url = GOOGLE_FONT_URLS.get(font_name)
99
+ if not url:
100
+ return "DejaVu Sans"
101
+
102
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
103
+ with urllib.request.urlopen(req, timeout=30) as resp:
104
+ data = resp.read()
105
+
106
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
107
+ # Tìm file TTF/OTF phù hợp
108
+ for name in zf.namelist():
109
+ basename = Path(name).name
110
+ if basename.lower().endswith((".ttf", ".otf")):
111
+ extracted = zf.read(name)
112
+ dest = user_fonts_dir / basename
113
+ dest.write_bytes(extracted)
114
+
115
+ # Cập nhật font cache
116
+ subprocess.run(["fc-cache", "-f", str(user_fonts_dir)],
117
+ capture_output=True, timeout=30)
118
+
119
+ if target.exists() or any(user_fonts_dir.glob(f"*{font_name.replace(' ', '')}*")):
120
+ return font_name
121
+ except Exception as e:
122
+ print(f"[FONT] Không tải được font '{font_name}': {e}")
123
+
124
+ return "DejaVu Sans"
125
+
126
+
127
+ class SegmentIn(BaseModel):
128
+ id: int
129
+ start: str
130
+ end: str
131
+ text: str = Field(default="")
132
+
133
+
134
+ class SubtitleStyle(BaseModel):
135
+ font_name: str = "DejaVu Sans"
136
+ font_color: str = "#FFFFFF" # Hex color for text
137
+ highlight_color: str = "#FFD700" # Hex color for karaoke highlight
138
+ outline_color: str = "#000000" # Hex color for outline
139
+ outline_width: int = 2 # Outline thickness (px)
140
+ font_size_pct: int = 100 # Font size percentage (50-200)
141
+ position_pct: int = 90 # Vertical position 0=top, 100=bottom
142
+ karaoke_mode: bool = False # Word-by-word karaoke highlight
143
+
144
+
145
+ class ExportRequest(BaseModel):
146
+ job_id: str
147
+ segments: List[SegmentIn]
148
+ burn_in: bool = True
149
+ style: Optional[SubtitleStyle] = None
150
+
151
+
152
+ class SegmentOut(BaseModel):
153
+ id: int
154
+ start: float
155
+ end: float
156
+ text: str
157
+
158
+
159
+
160
+ def cleanup_old_jobs() -> None:
161
+ cutoff = datetime.utcnow() - timedelta(hours=KEEP_HOURS)
162
+ for folder in WORK_DIR.iterdir():
163
+ if not folder.is_dir():
164
+ continue
165
+ try:
166
+ modified = datetime.utcfromtimestamp(folder.stat().st_mtime)
167
+ if modified < cutoff:
168
+ shutil.rmtree(folder, ignore_errors=True)
169
+ except Exception:
170
+ continue
171
+
172
+
173
+
174
+ def get_model(model_size: str = DEFAULT_MODEL_SIZE) -> WhisperModel:
175
+ with MODEL_LOCK:
176
+ if model_size not in MODEL_CACHE:
177
+ MODEL_CACHE[model_size] = WhisperModel(
178
+ model_size,
179
+ device="cpu",
180
+ compute_type="int8",
181
+ )
182
+ return MODEL_CACHE[model_size]
183
+
184
+
185
+
186
+ def ffmpeg_exists() -> bool:
187
+ return shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
188
+
189
+
190
+
191
+ def save_upload(upload: UploadFile, target_dir: Path) -> Path:
192
+ suffix = Path(upload.filename or "video.mp4").suffix or ".mp4"
193
+ video_path = target_dir / f"source{suffix}"
194
+ with video_path.open("wb") as f:
195
+ while True:
196
+ chunk = upload.file.read(1024 * 1024)
197
+ if not chunk:
198
+ break
199
+ f.write(chunk)
200
+ if f.tell() > MAX_UPLOAD_MB * 1024 * 1024:
201
+ raise HTTPException(status_code=413, detail=f"File quá lớn. Giới hạn {MAX_UPLOAD_MB} MB.")
202
+ return video_path
203
+
204
+
205
+
206
+ def run_ffprobe_duration(video_path: Path) -> Optional[float]:
207
+ try:
208
+ cmd = [
209
+ "ffprobe",
210
+ "-v",
211
+ "error",
212
+ "-show_entries",
213
+ "format=duration",
214
+ "-of",
215
+ "default=noprint_wrappers=1:nokey=1",
216
+ str(video_path),
217
+ ]
218
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
219
+ return float(result.stdout.strip())
220
+ except Exception:
221
+ return None
222
+
223
+
224
+ # ============================================================
225
+ # TRANSCRIPTION — 2 chế độ: "music" (lời bài hát) và "speech" (giọng nói)
226
+ # ============================================================
227
+
228
+ def merge_segments_music(raw_segments: list, max_gap: float = 0.8, max_len: float = 8.0) -> list:
229
+ """
230
+ 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.
231
+ - max_gap: khoảng trống tối đa giữa 2 segment để gộp (giây)
232
+ - max_len: độ dài tối đa 1 segment sau gộp (giây)
233
+ """
234
+ if not raw_segments:
235
+ return []
236
+
237
+ merged = []
238
+ current = {
239
+ "start": raw_segments[0]["start"],
240
+ "end": raw_segments[0]["end"],
241
+ "text": raw_segments[0]["text"],
242
+ }
243
+
244
+ for seg in raw_segments[1:]:
245
+ gap = seg["start"] - current["end"]
246
+ new_duration = seg["end"] - current["start"]
247
+
248
+ # Gộp nếu: khoảng trống nhỏ VÀ tổng thời lượng không quá dài
249
+ if gap <= max_gap and new_duration <= max_len:
250
+ current["end"] = seg["end"]
251
+ current["text"] = current["text"] + " " + seg["text"]
252
+ else:
253
+ merged.append(current)
254
+ current = {
255
+ "start": seg["start"],
256
+ "end": seg["end"],
257
+ "text": seg["text"],
258
+ }
259
+
260
+ merged.append(current)
261
+ return merged
262
+
263
+
264
+ def fill_timeline_gaps(segments: list, total_duration: Optional[float] = None, min_gap: float = 0.3) -> list:
265
+ """
266
+ Lấp khoảng trống lớn giữa các segment.
267
+ Nếu khoảng trống > min_gap, điều chỉnh end/start của segment kề cho liền mạch.
268
+ Giúp subtitle phủ toàn bộ timeline video.
269
+ """
270
+ if not segments:
271
+ return segments
272
+
273
+ result = []
274
+ for i, seg in enumerate(segments):
275
+ s = dict(seg)
276
+
277
+ # Kéo start sớm hơn để lấp gap phía trước
278
+ if i > 0:
279
+ prev_end = result[-1]["end"]
280
+ gap = s["start"] - prev_end
281
+ if 0 < gap <= 1.5:
282
+ # Gap nhỏ: kéo start segment hiện tại lùi lại
283
+ s["start"] = prev_end
284
+ elif gap > 1.5:
285
+ # Gap lớn: kéo end segment trước ra + kéo start hiện tại lùi
286
+ half = gap / 2
287
+ result[-1]["end"] = prev_end + min(half, 0.5)
288
+ s["start"] = s["start"] - min(half, 0.5)
289
+
290
+ result.append(s)
291
+
292
+ # Xử lý end của segment cuối nếu có total_duration
293
+ if total_duration and result:
294
+ last = result[-1]
295
+ remaining = total_duration - last["end"]
296
+ if 0 < remaining <= 2.0:
297
+ last["end"] = total_duration
298
+
299
+ return result
300
+
301
+
302
+ def transcribe_video_music(video_path: Path, duration: Optional[float] = None,
303
+ model_size: str = DEFAULT_MODEL_SIZE) -> List[SegmentOut]:
304
+ """
305
+ Chế độ LỜI BÀI HÁT: tối ưu để nhận diện toàn bộ lyrics.
306
+ - Tắt VAD filter (không cắt đoạn nhạc nền)
307
+ - Tăng beam_size cho accuracy
308
+ - Bật word_timestamps cho khớp chính xác
309
+ - Gộp segment thông minh
310
+ - Lấp khoảng trống timeline
311
+ """
312
+ model = get_model(model_size)
313
+
314
+ segments, info = model.transcribe(
315
+ str(video_path),
316
+ language="vi",
317
+ vad_filter=False, # QUAN TRỌNG: tắt VAD để không bỏ sót lời hát
318
+ beam_size=8, # Tăng beam cho accuracy lời bài hát
319
+ best_of=5, # Sample nhiều hơn, chọn tốt nhất
320
+ patience=1.5, # Kiên nhẫn hơn khi decode
321
+ condition_on_previous_text=True,
322
+ word_timestamps=True, # Timestamp cấp từ → khớp chính xác
323
+ no_speech_threshold=0.3, # Hạ threshold → ít bỏ sót đoạn hát nhỏ
324
+ log_prob_threshold=-1.5, # Chấp nhận xác suất thấp hơn (lời hát khó nghe)
325
+ compression_ratio_threshold=2.8, # Nới ngưỡng nén → ít reject segment
326
+ )
327
+
328
+ raw: list = []
329
+ for seg in segments:
330
+ text = (seg.text or "").strip()
331
+ if not text:
332
+ continue
333
+ raw.append({
334
+ "start": float(seg.start),
335
+ "end": float(seg.end),
336
+ "text": text,
337
+ })
338
+
339
+ if not raw:
340
+ raise HTTPException(status_code=400, detail="Không nhận diện được lời thoại/lời hát trong video.")
341
+
342
+ # Gộp segment ngắn thành câu lời bài hát tự nhiên
343
+ merged = merge_segments_music(raw, max_gap=0.8, max_len=8.0)
344
+
345
+ # Lấp khoảng trống timeline
346
+ filled = fill_timeline_gaps(merged, total_duration=duration)
347
+
348
+ rows: List[SegmentOut] = []
349
+ for idx, seg in enumerate(filled, start=1):
350
+ rows.append(SegmentOut(
351
+ id=idx,
352
+ start=seg["start"],
353
+ end=seg["end"],
354
+ text=seg["text"],
355
+ ))
356
+
357
+ return rows
358
+
359
+
360
+ def transcribe_video_speech(video_path: Path, model_size: str = DEFAULT_MODEL_SIZE) -> List[SegmentOut]:
361
+ """
362
+ Chế độ GIỌNG NÓI: giữ nguyên logic cũ, tối ưu cho lời thoại/thuyết trình.
363
+ - Bật VAD filter (lọc tiếng ồn)
364
+ - beam_size vừa phải
365
+ """
366
+ model = get_model(model_size)
367
+ segments, _info = model.transcribe(
368
+ str(video_path),
369
+ language="vi",
370
+ vad_filter=True,
371
+ beam_size=5,
372
+ condition_on_previous_text=True,
373
+ )
374
+ rows: List[SegmentOut] = []
375
+ for idx, seg in enumerate(segments, start=1):
376
+ text = (seg.text or "").strip()
377
+ if not text:
378
+ continue
379
+ rows.append(
380
+ SegmentOut(
381
+ id=idx,
382
+ start=float(seg.start),
383
+ end=float(seg.end),
384
+ text=text,
385
+ )
386
+ )
387
+ if not rows:
388
+ raise HTTPException(status_code=400, detail="Không nhận diện được lời thoại trong video.")
389
+ return rows
390
+
391
+
392
+
393
+ def format_srt_time(seconds: float) -> str:
394
+ total_ms = max(0, int(round(seconds * 1000)))
395
+ hours = total_ms // 3600000
396
+ total_ms %= 3600000
397
+ minutes = total_ms // 60000
398
+ total_ms %= 60000
399
+ secs = total_ms // 1000
400
+ millis = total_ms % 1000
401
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
402
+
403
+
404
+
405
+ def parse_time_string(value: str) -> float:
406
+ value = value.strip()
407
+ if not value:
408
+ return 0.0
409
+ value = value.replace(".", ",")
410
+ try:
411
+ hhmmss, ms = value.split(",") if "," in value else (value, "0")
412
+ parts = hhmmss.split(":")
413
+ if len(parts) == 2:
414
+ hours = 0
415
+ minutes, secs = parts
416
+ elif len(parts) == 3:
417
+ hours, minutes, secs = parts
418
+ else:
419
+ raise ValueError
420
+ return int(hours) * 3600 + int(minutes) * 60 + int(secs) + int(ms.ljust(3, "0")[:3]) / 1000.0
421
+ except Exception as exc:
422
+ raise HTTPException(status_code=400, detail=f"Sai định dạng thời gian: {value}") from exc
423
+
424
+
425
+
426
+ def write_srt(job_dir: Path, segments: List[SegmentIn]) -> Path:
427
+ srt_path = job_dir / "edited.srt"
428
+ lines: List[str] = []
429
+ cleaned = sorted(segments, key=lambda s: parse_time_string(s.start))
430
+ for idx, seg in enumerate(cleaned, start=1):
431
+ start_sec = parse_time_string(seg.start)
432
+ end_sec = parse_time_string(seg.end)
433
+ if end_sec <= start_sec:
434
+ end_sec = start_sec + 1.0
435
+ text = (seg.text or "").strip()
436
+ if not text:
437
+ continue
438
+ lines.extend(
439
+ [
440
+ str(idx),
441
+ f"{format_srt_time(start_sec)} --> {format_srt_time(end_sec)}",
442
+ text,
443
+ "",
444
+ ]
445
+ )
446
+ if not lines:
447
+ raise HTTPException(status_code=400, detail="Không có subtitle hợp lệ để xuất SRT.")
448
+ srt_path.write_text("\n".join(lines), encoding="utf-8")
449
+ return srt_path
450
+
451
+
452
+
453
+ def hex_to_ass_color(hex_color: str) -> str:
454
+ """
455
+ Chuyển đổi hex color (#RRGGBB) thành ASS color (&HBBGGRR&).
456
+ ASS dùng format BGR ngược lại.
457
+ """
458
+ h = hex_color.lstrip("#")
459
+ if len(h) != 6:
460
+ h = "FFFFFF" # fallback white
461
+ r, g, b = h[0:2], h[2:4], h[4:6]
462
+ return f"&H00{b.upper()}{g.upper()}{r.upper()}&"
463
+
464
+
465
+ def build_force_style(style: Optional["SubtitleStyle"] = None) -> str:
466
+ """
467
+ Tạo chuỗi force_style cho FFmpeg subtitles filter dựa trên SubtitleStyle.
468
+ """
469
+ if style is None:
470
+ return "FontName=DejaVu Sans,FontSize=20,Outline=1,Shadow=0,MarginV=18,Alignment=2"
471
+
472
+ # Font name — dùng font_name gửi từ frontend
473
+ font_name = style.font_name or "DejaVu Sans"
474
+
475
+ # Font size: base 20, scale theo pct
476
+ base_size = 20
477
+ font_size = max(10, int(base_size * style.font_size_pct / 100))
478
+
479
+ # Colors (ASS format)
480
+ primary_color = hex_to_ass_color(style.font_color)
481
+ outline_color = hex_to_ass_color(style.outline_color)
482
+
483
+ # Outline width
484
+ outline = max(0, min(6, style.outline_width))
485
+
486
+ # MarginV: convert position_pct (0=top, 100=bottom)
487
+ # ASS MarginV: khoảng cách từ cạnh (lớn = xa cạnh dưới hơn = lên cao hơn)
488
+ # position_pct 90 = gần đáy → MarginV nhỏ
489
+ # position_pct 10 = gần đỉnh → MarginV lớn
490
+ # Quy đổi: MarginV = (100 - position_pct) * 3, clamp 5..280
491
+ margin_v = max(5, min(280, int((100 - style.position_pct) * 3)))
492
+
493
+ # Alignment: 2 = bottom center (mặc định phụ đề)
494
+ # Nếu position < 50, dùng alignment 8 (top center)
495
+ alignment = 8 if style.position_pct < 40 else 2
496
+
497
+ parts = [
498
+ f"FontName={font_name}",
499
+ f"FontSize={font_size}",
500
+ f"PrimaryColour={primary_color}",
501
+ f"OutlineColour={outline_color}",
502
+ f"Outline={outline}",
503
+ f"Shadow=0",
504
+ f"MarginV={margin_v}",
505
+ f"Alignment={alignment}",
506
+ f"Bold=1",
507
+ ]
508
+ return ",".join(parts)
509
+
510
+
511
+ def write_ass_karaoke(job_dir: Path, segments: List["SegmentIn"], style: Optional["SubtitleStyle"] = None, resolved_font: Optional[str] = None) -> Path:
512
+ """
513
+ Tạo file ASS với karaoke word-by-word highlight (\kf tags).
514
+ Mỗi từ được highlight lần lượt theo thời gian segment.
515
+ resolved_font: tên font thực tế đã được ensure_font_available() kiểm tra.
516
+ """
517
+ ass_path = job_dir / "karaoke.ass"
518
+ s = style or SubtitleStyle()
519
+
520
+ # Ưu tiên resolved_font (font đã kiểm tra tồn tại), fallback về s.font_name
521
+ font_name = resolved_font or s.font_name or "DejaVu Sans"
522
+ base_size = 20
523
+ font_size = max(10, int(base_size * s.font_size_pct / 100))
524
+ primary_color = hex_to_ass_color(s.font_color)
525
+ highlight_color = hex_to_ass_color(s.highlight_color)
526
+ outline_color = hex_to_ass_color(s.outline_color)
527
+ outline = max(0, min(6, s.outline_width))
528
+ margin_v = max(5, min(280, int((100 - s.position_pct) * 3)))
529
+ alignment = 8 if s.position_pct < 40 else 2
530
+
531
+ header = f"""[Script Info]
532
+ Title: Viet AutoSub Karaoke
533
+ ScriptType: v4.00+
534
+ PlayResX: 1280
535
+ PlayResY: 720
536
+ ScaledBorderAndShadow: yes
537
+
538
+ [V4+ Styles]
539
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
540
+ 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
541
+
542
+ [Events]
543
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
544
+ """
545
+ lines_out: List[str] = [header.strip()]
546
+
547
+ cleaned = sorted(segments, key=lambda seg: parse_time_string(seg.start))
548
+ for seg in cleaned:
549
+ text = (seg.text or "").strip()
550
+ if not text:
551
+ continue
552
+
553
+ start_sec = parse_time_string(seg.start)
554
+ end_sec = parse_time_string(seg.end)
555
+ if end_sec <= start_sec:
556
+ end_sec = start_sec + 1.0
557
+
558
+ # ASS time format: H:MM:SS.cc
559
+ def sec_to_ass(seconds: float) -> str:
560
+ total_cs = max(0, int(round(seconds * 100)))
561
+ h = total_cs // 360000
562
+ total_cs %= 360000
563
+ m = total_cs // 6000
564
+ total_cs %= 6000
565
+ ss = total_cs // 100
566
+ cs = total_cs % 100
567
+ return f"{h}:{m:02d}:{ss:02d}.{cs:02d}"
568
+
569
+ ass_start = sec_to_ass(start_sec)
570
+ ass_end = sec_to_ass(end_sec)
571
+
572
+ # Split text into words, distribute time evenly
573
+ words = text.split()
574
+ if not words:
575
+ continue
576
+
577
+ duration_cs = max(1, int(round((end_sec - start_sec) * 100)))
578
+ per_word_cs = max(1, duration_cs // len(words))
579
+
580
+ # Build karaoke text with \kf tags
581
+ # \kf = smooth fill karaoke effect
582
+ karaoke_parts = []
583
+ for word in words:
584
+ karaoke_parts.append(f"{{\\kf{per_word_cs}}}{word}")
585
+
586
+ karaoke_text = " ".join(karaoke_parts)
587
+ # Override highlight color for karaoke fill: use SecondaryColour via \1c for filled portion
588
+ # Use \K (uppercase) style coloring: {\1c&highlight&} before karaoke
589
+ color_override = f"{{\\1c{highlight_color}}}"
590
+ line = f"Dialogue: 0,{ass_start},{ass_end},Default,,0,0,0,,{color_override}{karaoke_text}"
591
+ lines_out.append(line)
592
+
593
+ ass_path.write_text("\n".join(lines_out), encoding="utf-8")
594
+ return ass_path
595
+
596
+
597
+ def burn_subtitles(job_dir: Path, video_path: Path, srt_path: Path,
598
+ segments: Optional[List["SegmentIn"]] = None,
599
+ style: Optional["SubtitleStyle"] = None) -> Path:
600
+ """
601
+ Burn subtitle vào video bằng FFmpeg.
602
+ - Nếu karaoke_mode: tạo file ASS với \kf tags từ segments, rồi dùng ass= filter
603
+ - Nếu không: dùng subtitles= filter với SRT + force_style
604
+ """
605
+ output_path = job_dir / "output_subtitled.mp4"
606
+
607
+ # Đảm bảo font khả dụng cho FFmpeg
608
+ actual_font = "DejaVu Sans"
609
+ if style and style.font_name:
610
+ actual_font = ensure_font_available(style.font_name)
611
+ if actual_font != style.font_name:
612
+ print(f"[FONT] Fallback: '{style.font_name}' → '{actual_font}'")
613
+
614
+ # Xác định dùng karaoke ASS hay SRT thường
615
+ if style and style.karaoke_mode and segments:
616
+ # Tạo file ASS karaoke từ segments thực
617
+ write_ass_karaoke(job_dir, segments, style, resolved_font=actual_font)
618
+ # Dùng absolute path để tránh escaping issues
619
+ ass_abs = str((job_dir / "karaoke.ass").resolve()).replace("\\", "/").replace(":", r"\\:")
620
+ subtitle_filter = f"ass='{ass_abs}'"
621
+ else:
622
+ # Cập nhật font name trong style thành font thực tế
623
+ effective_style = style
624
+ if effective_style and effective_style.font_name != actual_font:
625
+ effective_style = effective_style.model_copy()
626
+ effective_style.font_name = actual_font
627
+
628
+ force_style = build_force_style(effective_style)
629
+ # Escape đường dẫn SRT cho FFmpeg filter
630
+ srt_abs = str(srt_path.resolve()).replace("\\", "/").replace(":", r"\\:")
631
+ subtitle_filter = f"subtitles='{srt_abs}':force_style='{force_style}'"
632
+
633
+ cmd = [
634
+ "ffmpeg",
635
+ "-y",
636
+ "-i",
637
+ str(video_path.resolve()),
638
+ "-vf",
639
+ subtitle_filter,
640
+ "-c:v",
641
+ "libx264",
642
+ "-preset",
643
+ "veryfast",
644
+ "-crf",
645
+ "23",
646
+ "-c:a",
647
+ "aac",
648
+ "-b:a",
649
+ "192k",
650
+ "-movflags",
651
+ "+faststart",
652
+ str(output_path.resolve()),
653
+ ]
654
+ try:
655
+ result = subprocess.run(
656
+ cmd,
657
+ cwd=str(job_dir),
658
+ capture_output=True,
659
+ text=True,
660
+ check=True,
661
+ timeout=FFMPEG_TIMEOUT,
662
+ )
663
+ except subprocess.TimeoutExpired:
664
+ raise HTTPException(
665
+ status_code=500,
666
+ detail=f"FFmpeg quá thời gian ({FFMPEG_TIMEOUT}s). Video có thể quá lớn."
667
+ )
668
+ except subprocess.CalledProcessError as exc:
669
+ stderr = (exc.stderr or "").strip()
670
+ # Log full error for debugging
671
+ print(f"[FFMPEG ERROR] cmd: {' '.join(cmd)}")
672
+ print(f"[FFMPEG STDERR] {stderr}")
673
+ raise HTTPException(
674
+ status_code=500,
675
+ detail=f"FFmpeg lỗi khi xuất MP4: {stderr[:1200]}"
676
+ ) from exc
677
+
678
+ if not output_path.exists() or output_path.stat().st_size < 1000:
679
+ raise HTTPException(
680
+ status_code=500,
681
+ detail="FFmpeg chạy xong nhưng file MP4 bị lỗi hoặc trống."
682
+ )
683
+
684
+ return output_path
685
+
686
+
687
+
688
+ def job_meta_path(job_dir: Path) -> Path:
689
+ return job_dir / "meta.json"
690
+
691
+
692
+
693
+ def save_job_meta(job_dir: Path, data: dict) -> None:
694
+ job_meta_path(job_dir).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
695
+
696
+
697
+
698
+ def load_job_meta(job_id: str) -> dict:
699
+ meta = job_meta_path(WORK_DIR / job_id)
700
+ if not meta.exists():
701
+ raise HTTPException(status_code=404, detail="Không tìm thấy job.")
702
+ return json.loads(meta.read_text(encoding="utf-8"))
703
+
704
+
705
+ @app.get("/", response_class=HTMLResponse)
706
+ def home(request: Request):
707
+ return templates.TemplateResponse("index.html", {"request": request})
708
+
709
+
710
+ @app.get("/health")
711
+ def health():
712
+ return {
713
+ "ok": True,
714
+ "ffmpeg": ffmpeg_exists(),
715
+ "workspace": str(WORK_DIR),
716
+ "default_model": DEFAULT_MODEL_SIZE,
717
+ }
718
+
719
+
720
+ @app.post("/api/transcribe")
721
+ def api_transcribe(
722
+ file: UploadFile = File(...),
723
+ mode: str = Form(default="music"),
724
+ ):
725
+ """
726
+ mode: "music" (lời bài hát) hoặc "speech" (giọng nói/thuyết trình)
727
+ """
728
+ cleanup_old_jobs()
729
+ if not ffmpeg_exists():
730
+ raise HTTPException(status_code=500, detail="Máy chủ chưa có FFmpeg.")
731
+
732
+ filename = file.filename or "video.mp4"
733
+ if not filename.lower().endswith((".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v")):
734
+ raise HTTPException(status_code=400, detail="Chỉ hỗ trợ video mp4, mov, mkv, avi, webm, m4v.")
735
+
736
+ if mode not in ("music", "speech"):
737
+ mode = "music"
738
+
739
+ job_id = uuid.uuid4().hex
740
+ job_dir = WORK_DIR / job_id
741
+ job_dir.mkdir(parents=True, exist_ok=True)
742
+ try:
743
+ video_path = save_upload(file, job_dir)
744
+ duration = run_ffprobe_duration(video_path)
745
+
746
+ if mode == "music":
747
+ segments = transcribe_video_music(video_path, duration=duration)
748
+ else:
749
+ segments = transcribe_video_speech(video_path)
750
+
751
+ # Tính coverage: tổng thời lượng sub / tổng video
752
+ total_sub_time = sum(s.end - s.start for s in segments)
753
+ coverage_pct = round((total_sub_time / duration * 100), 1) if duration and duration > 0 else 0
754
+
755
+ save_job_meta(
756
+ job_dir,
757
+ {
758
+ "job_id": job_id,
759
+ "video_path": video_path.name,
760
+ "duration": duration,
761
+ "mode": mode,
762
+ "created_at": datetime.utcnow().isoformat() + "Z",
763
+ },
764
+ )
765
+ return JSONResponse(
766
+ {
767
+ "job_id": job_id,
768
+ "duration": duration,
769
+ "mode": mode,
770
+ "coverage_pct": coverage_pct,
771
+ "segments": [
772
+ {
773
+ "id": seg.id,
774
+ "start": format_srt_time(seg.start),
775
+ "end": format_srt_time(seg.end),
776
+ "text": seg.text,
777
+ }
778
+ for seg in segments
779
+ ],
780
+ }
781
+ )
782
+ except Exception:
783
+ shutil.rmtree(job_dir, ignore_errors=True)
784
+ raise
785
+
786
+
787
+ @app.post("/api/export")
788
+ def api_export(payload: ExportRequest):
789
+ job_dir = WORK_DIR / payload.job_id
790
+ if not job_dir.exists():
791
+ raise HTTPException(status_code=404, detail="Job đã hết hạn hoặc không tồn tại.")
792
+
793
+ meta = load_job_meta(payload.job_id)
794
+ video_path = job_dir / meta["video_path"]
795
+ if not video_path.exists():
796
+ raise HTTPException(status_code=404, detail="Không tìm thấy video gốc để xuất lại.")
797
+
798
+ srt_path = write_srt(job_dir, payload.segments)
799
+ response = {
800
+ "job_id": payload.job_id,
801
+ "srt_url": f"/download/{payload.job_id}/srt",
802
+ "mp4_url": None,
803
+ }
804
+
805
+ if payload.burn_in:
806
+ # burn_subtitles xử lý cả karaoke ASS lẫn SRT thường
807
+ mp4_path = burn_subtitles(job_dir, video_path, srt_path,
808
+ segments=payload.segments, style=payload.style)
809
+ response["mp4_url"] = f"/download/{payload.job_id}/mp4"
810
+ response["mp4_size_mb"] = round(mp4_path.stat().st_size / (1024 * 1024), 2)
811
+
812
+ return JSONResponse(response)
813
+
814
+
815
+ @app.get("/download/{job_id}/srt")
816
+ def download_srt(job_id: str):
817
+ path = WORK_DIR / job_id / "edited.srt"
818
+ if not path.exists():
819
+ raise HTTPException(status_code=404, detail="Chưa có file SRT.")
820
+ return FileResponse(path, media_type="application/x-subrip", filename=f"{job_id}.srt")
821
+
822
+
823
+ @app.get("/download/{job_id}/mp4")
824
+ def download_mp4(job_id: str):
825
+ path = WORK_DIR / job_id / "output_subtitled.mp4"
826
+ if not path.exists():
827
+ raise HTTPException(status_code=404, detail="Chưa có file MP4.")
828
+ return FileResponse(path, media_type="video/mp4", filename=f"{job_id}.mp4")
829
+
830
+
831
+ if __name__ == "__main__":
832
+ import uvicorn
833
+
834
+ port = int(os.getenv("PORT", "7860"))
835
+ 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,968 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ cvnssMode: false, // false = Vietnamese (CQN), true = CVNSS4.0
24
+ originalTexts: [], // store original Vietnamese texts for round-trip conversion
25
+ subtitlePreviewVisible: false, // track overlay state
26
+ };
27
+
28
+ /* --- Detect environment ------------------------------------ */
29
+ const IS_FILE_PROTOCOL = window.location.protocol === "file:";
30
+
31
+ function getApiBase() {
32
+ if (IS_FILE_PROTOCOL) return null;
33
+ return "";
34
+ }
35
+
36
+ /* --- DOM refs ---------------------------------------------- */
37
+ const $ = (id) => document.getElementById(id);
38
+
39
+ const els = {
40
+ fileInput: $("videoFile"),
41
+ preview: $("preview"),
42
+ videoPlaceholder:$("videoPlaceholder"),
43
+ status: $("status"),
44
+ statusText: $("statusText"),
45
+ btnTranscribe: $("btnTranscribe"),
46
+ btnAddRow: $("btnAddRow"),
47
+ btnExportSrt: $("btnExportSrt"),
48
+ btnExportMp4: $("btnExportMp4"),
49
+ btnClearFile: $("btnClearFile"),
50
+ subtitleBody: $("subtitleBody"),
51
+ segmentCount: $("segmentCount"),
52
+ downloadSrt: $("downloadSrt"),
53
+ downloadMp4: $("downloadMp4"),
54
+ downloadGroup: $("downloadGroup"),
55
+ dropZone: $("dropZone"),
56
+ uploadPanel: $("uploadPanel"),
57
+ fileInfo: $("fileInfo"),
58
+ fileName: $("fileName"),
59
+ fileSize: $("fileSize"),
60
+ progressWrap: $("progressWrap"),
61
+ progressFill: $("progressFill"),
62
+ progressText: $("progressText"),
63
+ // Offline banner + badge
64
+ offlineBanner: $("offlineBanner"),
65
+ offlineBannerText: $("offlineBannerText"),
66
+ offlineBannerClose:$("offlineBannerClose"),
67
+ badgeEnv: $("badgeEnv"),
68
+ badgeEnvText: $("badgeEnvText"),
69
+ pulseDot: $("pulseDot"),
70
+ // Mode selector
71
+ modeToggle: $("modeToggle"),
72
+ modeMusic: $("modeMusic"),
73
+ modeSpeech: $("modeSpeech"),
74
+ modeHint: $("modeHint"),
75
+ // Coverage
76
+ coverageBar: $("coverageBar"),
77
+ coveragePct: $("coveragePct"),
78
+ coverageFill: $("coverageFill"),
79
+ // CVNSS Toggle
80
+ btnToggleCvnss: $("btnToggleCvnss"),
81
+ cvnssToggleLabel:$("cvnssToggleLabel"),
82
+ // Karaoke Style
83
+ ksFont: $("ksFont"),
84
+ ksColor: $("ksColor"),
85
+ ksColorHex: $("ksColorHex"),
86
+ ksHighlight: $("ksHighlight"),
87
+ ksHighlightHex: $("ksHighlightHex"),
88
+ ksOutline: $("ksOutline"),
89
+ ksOutlineHex: $("ksOutlineHex"),
90
+ ksOutlineWidth: $("ksOutlineWidth"),
91
+ ksOutlineWidthVal: $("ksOutlineWidthVal"),
92
+ ksSize: $("ksSize"),
93
+ ksSizeVal: $("ksSizeVal"),
94
+ ksPosition: $("ksPosition"),
95
+ ksPositionVal: $("ksPositionVal"),
96
+ ksKaraokeMode: $("ksKaraokeMode"),
97
+ ksKaraokeHint: $("ksKaraokeHint"),
98
+ btnPreviewStyle: $("btnPreviewStyle"),
99
+ videoWrap: $("videoWrap"),
100
+ subPreviewOverlay: $("subPreviewOverlay"),
101
+ subPreviewText: $("subPreviewText"),
102
+ };
103
+
104
+ /* --- Mode selector ----------------------------------------- */
105
+ const MODE_HINTS = {
106
+ music: "Tối ưu cho Vietsub lời bài hát, nhận diện toàn bộ lyrics khớp timeline.",
107
+ speech: "Tối ưu cho giọng nói, thuyết trình, podcast. Lọc tiếng ồn nền.",
108
+ };
109
+
110
+ function setMode(mode) {
111
+ state.mode = mode;
112
+ // Update toggle buttons
113
+ if (els.modeMusic) els.modeMusic.classList.toggle("active", mode === "music");
114
+ if (els.modeSpeech) els.modeSpeech.classList.toggle("active", mode === "speech");
115
+ if (els.modeHint) els.modeHint.textContent = MODE_HINTS[mode] || "";
116
+ }
117
+
118
+ // Mode toggle event listeners
119
+ if (els.modeToggle) {
120
+ els.modeToggle.addEventListener("click", (e) => {
121
+ const btn = e.target.closest(".mode-btn");
122
+ if (!btn) return;
123
+ const mode = btn.dataset.mode;
124
+ if (mode) setMode(mode);
125
+ });
126
+ }
127
+
128
+ /* --- Coverage display -------------------------------------- */
129
+ function showCoverage(pct) {
130
+ if (!els.coverageBar) return;
131
+ els.coverageBar.hidden = false;
132
+ const val = Math.min(100, Math.max(0, pct));
133
+ if (els.coveragePct) els.coveragePct.textContent = val + "%";
134
+ if (els.coverageFill) els.coverageFill.style.width = val + "%";
135
+
136
+ // Color coding
137
+ if (els.coverageFill) {
138
+ els.coverageFill.classList.remove("cov-low", "cov-mid", "cov-high");
139
+ if (val >= 80) els.coverageFill.classList.add("cov-high");
140
+ else if (val >= 50) els.coverageFill.classList.add("cov-mid");
141
+ else els.coverageFill.classList.add("cov-low");
142
+ }
143
+ }
144
+
145
+ function hideCoverage() {
146
+ if (els.coverageBar) els.coverageBar.hidden = true;
147
+ }
148
+
149
+ /* --- Health check ------------------------------------------ */
150
+ let healthRetryTimer = null;
151
+
152
+ async function checkHealth() {
153
+ if (IS_FILE_PROTOCOL) {
154
+ setOnlineState(false, "Offline (file://)");
155
+ return;
156
+ }
157
+
158
+ try {
159
+ const res = await fetch("/health", { method: "GET", cache: "no-store" });
160
+ if (res.ok) {
161
+ const data = await res.json();
162
+ setOnlineState(true, "HF Space");
163
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
164
+ } else {
165
+ setOnlineState(false, "Server lỗi");
166
+ }
167
+ } catch (_) {
168
+ setOnlineState(false, "Không kết nối");
169
+ }
170
+ }
171
+
172
+ function setOnlineState(online, label) {
173
+ state.isOnline = online;
174
+
175
+ if (els.badgeEnv) {
176
+ els.badgeEnv.classList.toggle("badge-online", online);
177
+ els.badgeEnv.classList.toggle("badge-offline", !online);
178
+ }
179
+ if (els.badgeEnvText) {
180
+ els.badgeEnvText.textContent = label || (online ? "Online" : "Offline");
181
+ }
182
+ if (els.pulseDot) {
183
+ els.pulseDot.className = online ? "pulse-dot pulse-online" : "pulse-dot pulse-offline";
184
+ }
185
+
186
+ if (!online) {
187
+ if (els.offlineBanner) els.offlineBanner.hidden = false;
188
+ if (els.offlineBannerText) {
189
+ els.offlineBannerText.textContent = IS_FILE_PROTOCOL
190
+ ? "Đ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."
191
+ : "Không kết nối được server — Đang thử lại mỗi 30 giây...";
192
+ }
193
+ if (!IS_FILE_PROTOCOL && !healthRetryTimer) {
194
+ healthRetryTimer = setInterval(checkHealth, 30000);
195
+ }
196
+ } else {
197
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
198
+ if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; }
199
+ }
200
+ }
201
+
202
+ if (els.offlineBannerClose) {
203
+ els.offlineBannerClose.addEventListener("click", () => {
204
+ if (els.offlineBanner) els.offlineBanner.hidden = true;
205
+ });
206
+ }
207
+
208
+ /* --- Steps ------------------------------------------------- */
209
+ function setStep(num) {
210
+ document.querySelectorAll(".step").forEach((el) => {
211
+ const s = parseInt(el.dataset.step, 10);
212
+ el.classList.toggle("active", s === num);
213
+ el.classList.toggle("done", s < num);
214
+ });
215
+ }
216
+
217
+ /* --- Status ------------------------------------------------ */
218
+ function setStatus(message, type = "idle") {
219
+ els.status.className = `status-box status-${type}`;
220
+ els.statusText.textContent = message;
221
+ }
222
+
223
+ /* --- Buttons state ----------------------------------------- */
224
+ function setEditButtons(enabled) {
225
+ els.btnAddRow.disabled = !enabled;
226
+ els.btnExportSrt.disabled = !enabled;
227
+ els.btnExportMp4.disabled = !enabled;
228
+ }
229
+
230
+ /* --- Download link helpers --------------------------------- */
231
+ function showDownload(el, url, visible) {
232
+ el.href = visible ? url : "#";
233
+ el.classList.toggle("disabled", !visible);
234
+ }
235
+ function showDownloadGroup(show) {
236
+ els.downloadGroup.hidden = !show;
237
+ }
238
+
239
+ /* --- Progress simulation ----------------------------------- */
240
+ let progressTimer = null;
241
+ function startProgress(label) {
242
+ els.progressWrap.hidden = false;
243
+ els.progressFill.style.width = "0%";
244
+ els.progressText.textContent = label || "Đang xử lý...";
245
+
246
+ let pct = 0;
247
+ clearInterval(progressTimer);
248
+ progressTimer = setInterval(() => {
249
+ const remaining = 90 - pct;
250
+ const step = Math.max(0.3, remaining * 0.04);
251
+ pct = Math.min(90, pct + step);
252
+ els.progressFill.style.width = pct + "%";
253
+ }, 300);
254
+ }
255
+ function finishProgress() {
256
+ clearInterval(progressTimer);
257
+ els.progressFill.style.width = "100%";
258
+ setTimeout(() => {
259
+ els.progressWrap.hidden = true;
260
+ els.progressFill.style.width = "0%";
261
+ }, 600);
262
+ }
263
+ function cancelProgress() {
264
+ clearInterval(progressTimer);
265
+ els.progressWrap.hidden = true;
266
+ els.progressFill.style.width = "0%";
267
+ }
268
+
269
+ /* --- File size formatter ----------------------------------- */
270
+ function formatSize(bytes) {
271
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
272
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
273
+ }
274
+
275
+ /* --- Create table cell inputs ------------------------------ */
276
+ function createInput(value, className) {
277
+ const input = document.createElement("input");
278
+ input.type = "text";
279
+ input.value = value || "";
280
+ input.className = className;
281
+ input.spellcheck = false;
282
+ return input;
283
+ }
284
+
285
+ function createTextArea(value) {
286
+ const textarea = document.createElement("textarea");
287
+ textarea.value = value || "";
288
+ textarea.rows = 2;
289
+ textarea.className = "text-input";
290
+ return textarea;
291
+ }
292
+
293
+ /* --- Collect segments from table --------------------------- */
294
+ function collectSegmentsFromTable() {
295
+ const rows = Array.from(els.subtitleBody.querySelectorAll("tr[data-row='1']"));
296
+ return rows.map((row, index) => ({
297
+ id: index + 1,
298
+ start: row.querySelector(".start-input").value.trim(),
299
+ end: row.querySelector(".end-input").value.trim(),
300
+ text: row.querySelector(".text-input").value.trim(),
301
+ }));
302
+ }
303
+
304
+ /* --- Render table ------------------------------------------ */
305
+ function renderTable() {
306
+ els.subtitleBody.innerHTML = "";
307
+
308
+ if (!state.segments.length) {
309
+ els.subtitleBody.innerHTML = `
310
+ <tr class="empty-row">
311
+ <td colspan="5">
312
+ <div class="empty-state">
313
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
314
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
315
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
316
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
317
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
318
+ </svg>
319
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
320
+ </div>
321
+ </td>
322
+ </tr>`;
323
+ els.segmentCount.textContent = "0 dòng";
324
+ setEditButtons(false);
325
+ return;
326
+ }
327
+
328
+ state.segments.forEach((seg, index) => {
329
+ const tr = document.createElement("tr");
330
+ tr.dataset.row = "1";
331
+
332
+ const tdIdx = document.createElement("td");
333
+ tdIdx.className = "idx-cell";
334
+ tdIdx.textContent = String(index + 1);
335
+
336
+ const tdStart = document.createElement("td");
337
+ tdStart.appendChild(createInput(seg.start, "start-input time-input"));
338
+
339
+ const tdEnd = document.createElement("td");
340
+ tdEnd.appendChild(createInput(seg.end, "end-input time-input"));
341
+
342
+ const tdText = document.createElement("td");
343
+ tdText.appendChild(createTextArea(seg.text));
344
+
345
+ const tdAct = document.createElement("td");
346
+ tdAct.style.textAlign = "center";
347
+ const delBtn = document.createElement("button");
348
+ delBtn.className = "btn btn-danger-sm";
349
+ 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>`;
350
+ delBtn.title = "Xóa dòng";
351
+ delBtn.addEventListener("click", () => {
352
+ state.segments = collectSegmentsFromTable();
353
+ state.segments.splice(index, 1);
354
+ renderTable();
355
+ });
356
+ tdAct.appendChild(delBtn);
357
+
358
+ tr.append(tdIdx, tdStart, tdEnd, tdText, tdAct);
359
+ els.subtitleBody.appendChild(tr);
360
+ });
361
+
362
+ els.segmentCount.textContent = `${state.segments.length} dòng`;
363
+ setEditButtons(true);
364
+ }
365
+
366
+ /* --- Transcribe -------------------------------------------- */
367
+ async function transcribeVideo() {
368
+ if (!state.file) {
369
+ setStatus("Hãy chọn video trước.", "error");
370
+ return;
371
+ }
372
+
373
+ if (!state.isOnline) {
374
+ setStatus(
375
+ IS_FILE_PROTOCOL
376
+ ? "Đ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."
377
+ : "Server không phản hồi. Đang thử kết nối lại...",
378
+ "error"
379
+ );
380
+ if (!IS_FILE_PROTOCOL) checkHealth();
381
+ return;
382
+ }
383
+
384
+ const fd = new FormData();
385
+ fd.append("file", state.file);
386
+ fd.append("mode", state.mode);
387
+
388
+ els.btnTranscribe.disabled = true;
389
+ els.btnTranscribe.classList.add("btn-loading");
390
+ const modeLabel = state.mode === "music" ? "lời bài hát" : "giọng nói";
391
+ setStatus(`Đang nhận diện ${modeLabel} tiếng Việt...`, "loading");
392
+ setStep(2);
393
+ startProgress(`Đang upload và nhận diện ${modeLabel}...`);
394
+ showDownload(els.downloadSrt, "#", false);
395
+ showDownload(els.downloadMp4, "#", false);
396
+ showDownloadGroup(false);
397
+ hideCoverage();
398
+
399
+ try {
400
+ const res = await fetch("/api/transcribe", {
401
+ method: "POST",
402
+ body: fd,
403
+ });
404
+ const data = await res.json();
405
+ if (!res.ok) throw new Error(data.detail || "Không thể nhận diện subtitle.");
406
+
407
+ state.jobId = data.job_id;
408
+ state.segments = data.segments || [];
409
+ renderTable();
410
+ finishProgress();
411
+
412
+ // Show coverage
413
+ if (data.coverage_pct !== undefined) {
414
+ showCoverage(data.coverage_pct);
415
+ }
416
+
417
+ const coverageInfo = data.coverage_pct ? ` (phủ ${data.coverage_pct}% timeline)` : "";
418
+ setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng Vietsub${coverageInfo}.`, "success");
419
+ setStep(3);
420
+ } catch (err) {
421
+ cancelProgress();
422
+ const msg = err.message.includes("Failed to fetch")
423
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
424
+ : (err.message || "Có lỗi khi auto sub.");
425
+ setStatus(msg, "error");
426
+ setStep(1);
427
+ checkHealth();
428
+ } finally {
429
+ els.btnTranscribe.disabled = false;
430
+ els.btnTranscribe.classList.remove("btn-loading");
431
+ }
432
+ }
433
+
434
+ /* --- Client-side SRT generation (offline-capable) ---------- */
435
+ function generateSrtString(segments) {
436
+ let lines = [];
437
+ segments.forEach((seg, idx) => {
438
+ const start = seg.start || "00:00:00,000";
439
+ const end = seg.end || "00:00:02,000";
440
+ const text = (seg.text || "").trim();
441
+ if (!text) return;
442
+ lines.push(String(idx + 1));
443
+ lines.push(`${start} --> ${end}`);
444
+ lines.push(text);
445
+ lines.push("");
446
+ });
447
+ return lines.join("\n");
448
+ }
449
+
450
+ function downloadSrtOffline() {
451
+ const segments = collectSegmentsFromTable();
452
+ if (!segments.length) {
453
+ setStatus("Chưa có subtitle để xuất.", "error");
454
+ return;
455
+ }
456
+ const srtContent = generateSrtString(segments);
457
+ const blob = new Blob([srtContent], { type: "text/plain;charset=utf-8" });
458
+ const url = URL.createObjectURL(blob);
459
+ const a = document.createElement("a");
460
+ a.href = url;
461
+ a.download = "subtitle.srt";
462
+ document.body.appendChild(a);
463
+ a.click();
464
+ document.body.removeChild(a);
465
+ URL.revokeObjectURL(url);
466
+ setStatus("Đã xuất file SRT thành công (offline).", "success");
467
+ setStep(4);
468
+ }
469
+
470
+ /* --- Export ------------------------------------------------- */
471
+ async function exportResult(burnIn) {
472
+ if (!burnIn && (!state.isOnline || !state.jobId)) {
473
+ downloadSrtOffline();
474
+ return;
475
+ }
476
+
477
+ if (burnIn && !state.isOnline) {
478
+ setStatus(
479
+ IS_FILE_PROTOCOL
480
+ ? "Xuất MP4 burn sub cần server HF Space. Hãy deploy ứng dụng lên HF Space trước."
481
+ : "Server không phản hồi. Xuất MP4 cần kết nối server.",
482
+ "error"
483
+ );
484
+ return;
485
+ }
486
+
487
+ if (!state.jobId) {
488
+ setStatus("Chưa có job để xuất file. Hãy bấm Auto sub trước.", "error");
489
+ return;
490
+ }
491
+
492
+ const ks = getKaraokeStyle();
493
+ const payload = {
494
+ job_id: state.jobId,
495
+ burn_in: burnIn,
496
+ segments: collectSegmentsFromTable(),
497
+ style: {
498
+ font_name: ks.font,
499
+ font_color: ks.color,
500
+ highlight_color: ks.highlight,
501
+ outline_color: ks.outline,
502
+ outline_width: ks.outlineWidth,
503
+ font_size_pct: ks.sizePct,
504
+ position_pct: ks.positionPct,
505
+ karaoke_mode: ks.karaokeMode,
506
+ },
507
+ };
508
+
509
+ const label = burnIn ? "Đang xuất MP4 có sub..." : "Đang tạo file SRT...";
510
+ setStatus(label, "loading");
511
+ startProgress(label);
512
+ setStep(4);
513
+ els.btnExportSrt.disabled = true;
514
+ els.btnExportMp4.disabled = true;
515
+
516
+ try {
517
+ const res = await fetch("/api/export", {
518
+ method: "POST",
519
+ headers: { "Content-Type": "application/json" },
520
+ body: JSON.stringify(payload),
521
+ });
522
+ const data = await res.json();
523
+ if (!res.ok) throw new Error(data.detail || "Xuất file thất bại.");
524
+
525
+ finishProgress();
526
+ showDownloadGroup(true);
527
+ showDownload(els.downloadSrt, data.srt_url, true);
528
+ if (data.mp4_url) {
529
+ showDownload(els.downloadMp4, data.mp4_url, true);
530
+ }
531
+
532
+ const msg = burnIn
533
+ ? `Xuất MP4 thành công${data.mp4_size_mb ? ` (${data.mp4_size_mb} MB)` : ""}.`
534
+ : "Đã tạo file SRT thành công.";
535
+ setStatus(msg, "success");
536
+ } catch (err) {
537
+ cancelProgress();
538
+ const msg = err.message.includes("Failed to fetch")
539
+ ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space."
540
+ : (err.message || "Có lỗi khi xuất file.");
541
+ setStatus(msg, "error");
542
+ checkHealth();
543
+ } finally {
544
+ setEditButtons(true);
545
+ }
546
+ }
547
+
548
+ /* --- File selection ---------------------------------------- */
549
+ function handleFile(file) {
550
+ if (!file) return;
551
+ state.file = file;
552
+ state.jobId = null;
553
+ state.segments = [];
554
+ renderTable();
555
+ showDownload(els.downloadSrt, "#", false);
556
+ showDownload(els.downloadMp4, "#", false);
557
+ showDownloadGroup(false);
558
+ hideCoverage();
559
+
560
+ const url = URL.createObjectURL(file);
561
+ els.preview.src = url;
562
+ els.preview.classList.add("has-src");
563
+ els.videoPlaceholder.classList.add("hidden");
564
+
565
+ els.fileInfo.hidden = false;
566
+ els.fileName.textContent = file.name;
567
+ els.fileSize.textContent = formatSize(file.size);
568
+
569
+ setStatus(`Đã chọn: ${file.name}`, "idle");
570
+ setStep(1);
571
+ }
572
+
573
+ function clearFile() {
574
+ state.file = null;
575
+ state.jobId = null;
576
+ state.segments = [];
577
+ renderTable();
578
+ els.preview.removeAttribute("src");
579
+ els.preview.classList.remove("has-src");
580
+ els.videoPlaceholder.classList.remove("hidden");
581
+ els.fileInfo.hidden = true;
582
+ els.fileInput.value = "";
583
+ showDownloadGroup(false);
584
+ hideCoverage();
585
+ setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle");
586
+ setStep(1);
587
+ }
588
+
589
+ /* --- Event listeners --------------------------------------- */
590
+
591
+ els.fileInput.addEventListener("change", (e) => {
592
+ const [file] = e.target.files || [];
593
+ if (file) handleFile(file);
594
+ });
595
+
596
+ els.dropZone.addEventListener("click", () => els.fileInput.click());
597
+
598
+ els.dropZone.addEventListener("dragover", (e) => {
599
+ e.preventDefault();
600
+ els.dropZone.classList.add("drag-over");
601
+ });
602
+ els.dropZone.addEventListener("dragleave", () => {
603
+ els.dropZone.classList.remove("drag-over");
604
+ });
605
+ els.dropZone.addEventListener("drop", (e) => {
606
+ e.preventDefault();
607
+ els.dropZone.classList.remove("drag-over");
608
+ const file = e.dataTransfer.files[0];
609
+ if (file) {
610
+ const dt = new DataTransfer();
611
+ dt.items.add(file);
612
+ els.fileInput.files = dt.files;
613
+ handleFile(file);
614
+ }
615
+ });
616
+
617
+ els.btnClearFile.addEventListener("click", clearFile);
618
+ els.btnTranscribe.addEventListener("click", transcribeVideo);
619
+ els.btnExportSrt.addEventListener("click", () => exportResult(false));
620
+ els.btnExportMp4.addEventListener("click", () => exportResult(true));
621
+
622
+ els.btnAddRow.addEventListener("click", () => {
623
+ state.segments = collectSegmentsFromTable();
624
+ state.segments.push({
625
+ id: state.segments.length + 1,
626
+ start: "00:00:00,000",
627
+ end: "00:00:02,000",
628
+ text: "Subtitle mới",
629
+ });
630
+ renderTable();
631
+ const scroll = $("tableScroll");
632
+ if (scroll) scroll.scrollTop = scroll.scrollHeight;
633
+ });
634
+
635
+ const btnCollapse = $("btnCollapseTable");
636
+ const tableScroll = $("tableScroll");
637
+ if (btnCollapse && tableScroll) {
638
+ btnCollapse.addEventListener("click", () => {
639
+ const collapsed = tableScroll.style.display === "none";
640
+ tableScroll.style.display = collapsed ? "" : "none";
641
+ btnCollapse.querySelector("svg").style.transform = collapsed ? "" : "rotate(180deg)";
642
+ });
643
+ }
644
+
645
+ /* --- Karaoke Style ----------------------------------------- */
646
+
647
+ function getKaraokeStyle() {
648
+ return {
649
+ font: els.ksFont ? els.ksFont.value : "Bangers",
650
+ color: els.ksColor ? els.ksColor.value : "#FFFFFF",
651
+ highlight: els.ksHighlight ? els.ksHighlight.value : "#FFD700",
652
+ outline: els.ksOutline ? els.ksOutline.value : "#000000",
653
+ outlineWidth: els.ksOutlineWidth ? parseInt(els.ksOutlineWidth.value, 10) : 2,
654
+ sizePct: els.ksSize ? parseInt(els.ksSize.value, 10) : 100,
655
+ positionPct: els.ksPosition ? parseInt(els.ksPosition.value, 10) : 90,
656
+ karaokeMode: els.ksKaraokeMode ? els.ksKaraokeMode.checked : false,
657
+ };
658
+ }
659
+
660
+ /* --- Parse SRT timestamp to seconds ------------------------- */
661
+ function srtTimeToSeconds(timeStr) {
662
+ if (!timeStr) return 0;
663
+ // Format: HH:MM:SS,mmm or HH:MM:SS.mmm
664
+ const parts = timeStr.replace(',', '.').split(':');
665
+ if (parts.length !== 3) return 0;
666
+ const h = parseFloat(parts[0]) || 0;
667
+ const m = parseFloat(parts[1]) || 0;
668
+ const s = parseFloat(parts[2]) || 0;
669
+ return h * 3600 + m * 60 + s;
670
+ }
671
+
672
+ /* --- Get current subtitle text at a given time -------------- */
673
+ function getSubtitleAtTime(currentTime) {
674
+ const segs = state.segments;
675
+ if (!segs || !segs.length) return null;
676
+ for (let i = 0; i < segs.length; i++) {
677
+ const start = srtTimeToSeconds(segs[i].start);
678
+ const end = srtTimeToSeconds(segs[i].end);
679
+ if (currentTime >= start && currentTime <= end) {
680
+ return { text: segs[i].text, index: i, start, end };
681
+ }
682
+ }
683
+ return null;
684
+ }
685
+
686
+ function updatePreviewOverlay() {
687
+ const ks = getKaraokeStyle();
688
+ state.karaokeStyle = ks;
689
+
690
+ const overlay = els.subPreviewOverlay;
691
+ const textEl = els.subPreviewText;
692
+ if (!overlay || !textEl) return;
693
+
694
+ // Position: convert 0-100% to bottom offset
695
+ // 0% = top (bottom: 90%), 100% = bottom (bottom: 2%)
696
+ const bottomPct = Math.max(2, 90 - (ks.positionPct / 100) * 88);
697
+ overlay.style.bottom = bottomPct + "%";
698
+
699
+ // Font
700
+ textEl.style.fontFamily = "'" + ks.font + "', sans-serif";
701
+
702
+ // Size: base 1.3rem * sizePct / 100
703
+ textEl.style.fontSize = (1.3 * ks.sizePct / 100) + "rem";
704
+
705
+ // Color
706
+ textEl.style.color = ks.color;
707
+
708
+ // Text shadow for outline
709
+ const ow = ks.outlineWidth;
710
+ const oc = ks.outline;
711
+ if (ow > 0) {
712
+ textEl.style.textShadow = [
713
+ `${ow}px ${ow}px 0 ${oc}`,
714
+ `-${ow}px -${ow}px 0 ${oc}`,
715
+ `${ow}px -${ow}px 0 ${oc}`,
716
+ `-${ow}px ${ow}px 0 ${oc}`,
717
+ `0 0 8px rgba(0,0,0,0.7)`,
718
+ ].join(", ");
719
+ } else {
720
+ textEl.style.textShadow = "0 0 8px rgba(0,0,0,0.7)";
721
+ }
722
+
723
+ // Show actual subtitle text synced to video time, or demo text if no subtitles
724
+ const video = els.preview;
725
+ const currentTime = video && video.duration ? video.currentTime : 0;
726
+ const activeSub = getSubtitleAtTime(currentTime);
727
+
728
+ if (activeSub && activeSub.text) {
729
+ // Show real subtitle content
730
+ if (ks.karaokeMode && activeSub.start !== undefined) {
731
+ // Karaoke word-by-word: highlight words progressively
732
+ const words = activeSub.text.split(/\s+/);
733
+ const duration = activeSub.end - activeSub.start;
734
+ const elapsed = currentTime - activeSub.start;
735
+ const progress = duration > 0 ? elapsed / duration : 0;
736
+ const highlightCount = Math.ceil(progress * words.length);
737
+
738
+ let html = '';
739
+ words.forEach((word, i) => {
740
+ if (i < highlightCount) {
741
+ html += '<span class="ks-word-active" style="color:' + ks.highlight + '">' + escapeHtml(word) + '</span> ';
742
+ } else {
743
+ html += escapeHtml(word) + ' ';
744
+ }
745
+ });
746
+ textEl.innerHTML = html.trim();
747
+ } else {
748
+ textEl.textContent = activeSub.text;
749
+ }
750
+ overlay.hidden = false;
751
+ } else if (state.segments.length > 0) {
752
+ // Has subtitles but none active at this time — hide overlay
753
+ textEl.textContent = '';
754
+ overlay.hidden = true;
755
+ } else {
756
+ // No subtitles at all — show demo text
757
+ if (ks.karaokeMode) {
758
+ textEl.innerHTML = 'Ph\u1EE5 \u0111\u1EC1 <span class="ks-word-active" style="color:' + ks.highlight + '">m\u1EABu</span> — Xem tr\u01B0\u1EDBc <span class="ks-word-active" style="color:' + ks.highlight + '">Karaoke</span>';
759
+ } else {
760
+ textEl.textContent = "Ph\u1EE5 \u0111\u1EC1 m\u1EABu — Xem tr\u01B0\u1EDBc Karaoke";
761
+ }
762
+ }
763
+ }
764
+
765
+ /* --- HTML escape helper ------------------------------------ */
766
+ function escapeHtml(text) {
767
+ const div = document.createElement('div');
768
+ div.textContent = text;
769
+ return div.innerHTML;
770
+ }
771
+
772
+ function showSubtitlePreview() {
773
+ state.subtitlePreviewVisible = true;
774
+ if (els.subPreviewOverlay) {
775
+ els.subPreviewOverlay.hidden = false;
776
+ updatePreviewOverlay();
777
+ }
778
+ startSubtitleSync();
779
+ }
780
+
781
+ function hideSubtitlePreview() {
782
+ state.subtitlePreviewVisible = false;
783
+ if (els.subPreviewOverlay) els.subPreviewOverlay.hidden = true;
784
+ stopSubtitleSync();
785
+ }
786
+
787
+ /* --- Live subtitle sync with video playback ----------------- */
788
+ let subtitleSyncRAF = null;
789
+ let lastSyncCollectTime = 0;
790
+
791
+ function syncSubtitleLoop() {
792
+ if (!state.subtitlePreviewVisible) return;
793
+ // Re-collect segments from table every 500ms (not every frame, for perf)
794
+ const now = Date.now();
795
+ if (now - lastSyncCollectTime > 500) {
796
+ const tableSegs = collectSegmentsFromTable();
797
+ if (tableSegs.length) state.segments = tableSegs;
798
+ lastSyncCollectTime = now;
799
+ }
800
+ updatePreviewOverlay();
801
+ subtitleSyncRAF = requestAnimationFrame(syncSubtitleLoop);
802
+ }
803
+
804
+ function startSubtitleSync() {
805
+ stopSubtitleSync();
806
+ lastSyncCollectTime = 0;
807
+ subtitleSyncRAF = requestAnimationFrame(syncSubtitleLoop);
808
+ }
809
+
810
+ function stopSubtitleSync() {
811
+ if (subtitleSyncRAF) {
812
+ cancelAnimationFrame(subtitleSyncRAF);
813
+ subtitleSyncRAF = null;
814
+ }
815
+ }
816
+
817
+ // Wire up karaoke control events
818
+ function setupKaraokeEvents() {
819
+ // Font selector → update preview font display
820
+ if (els.ksFont) {
821
+ els.ksFont.addEventListener("change", () => {
822
+ els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif";
823
+ updatePreviewOverlay();
824
+ });
825
+ // Set initial font display
826
+ els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif";
827
+ }
828
+
829
+ // Color pickers
830
+ if (els.ksColor) {
831
+ els.ksColor.addEventListener("input", () => {
832
+ if (els.ksColorHex) els.ksColorHex.textContent = els.ksColor.value.toUpperCase();
833
+ updatePreviewOverlay();
834
+ });
835
+ }
836
+ if (els.ksHighlight) {
837
+ els.ksHighlight.addEventListener("input", () => {
838
+ if (els.ksHighlightHex) els.ksHighlightHex.textContent = els.ksHighlight.value.toUpperCase();
839
+ updatePreviewOverlay();
840
+ });
841
+ }
842
+ if (els.ksOutline) {
843
+ els.ksOutline.addEventListener("input", () => {
844
+ if (els.ksOutlineHex) els.ksOutlineHex.textContent = els.ksOutline.value.toUpperCase();
845
+ updatePreviewOverlay();
846
+ });
847
+ }
848
+ if (els.ksOutlineWidth) {
849
+ els.ksOutlineWidth.addEventListener("input", () => {
850
+ if (els.ksOutlineWidthVal) els.ksOutlineWidthVal.textContent = els.ksOutlineWidth.value + "px";
851
+ updatePreviewOverlay();
852
+ });
853
+ }
854
+
855
+ // Size slider
856
+ if (els.ksSize) {
857
+ els.ksSize.addEventListener("input", () => {
858
+ if (els.ksSizeVal) els.ksSizeVal.textContent = els.ksSize.value + "%";
859
+ updatePreviewOverlay();
860
+ });
861
+ }
862
+
863
+ // Position slider
864
+ if (els.ksPosition) {
865
+ els.ksPosition.addEventListener("input", () => {
866
+ if (els.ksPositionVal) els.ksPositionVal.textContent = els.ksPosition.value + "%";
867
+ updatePreviewOverlay();
868
+ });
869
+ }
870
+
871
+ // Karaoke mode toggle
872
+ if (els.ksKaraokeMode) {
873
+ els.ksKaraokeMode.addEventListener("change", () => {
874
+ updatePreviewOverlay();
875
+ });
876
+ }
877
+
878
+ // Preview button
879
+ if (els.btnPreviewStyle) {
880
+ let previewVisible = false;
881
+ els.btnPreviewStyle.addEventListener("click", () => {
882
+ previewVisible = !previewVisible;
883
+ if (previewVisible) {
884
+ showSubtitlePreview();
885
+ 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ụ đề';
886
+ } else {
887
+ hideSubtitlePreview();
888
+ 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ụ đề';
889
+ }
890
+ });
891
+ }
892
+ }
893
+
894
+ /* --- CVNSS4.0 Toggle --------------------------------------- */
895
+
896
+ function toggleCvnss() {
897
+ if (typeof CVNSSConverter === 'undefined') {
898
+ setStatus('Th\u01B0 vi\u1EC7n CVNSS4.0 ch\u01B0a \u0111\u01B0\u1EE3c t\u1EA3i. Vui l\u00F2ng refresh trang.', 'error');
899
+ return;
900
+ }
901
+
902
+ // Collect current table data
903
+ state.segments = collectSegmentsFromTable();
904
+
905
+ if (!state.segments.length) {
906
+ setStatus('Ch\u01B0a c\u00F3 subtitle \u0111\u1EC3 chuy\u1EC3n \u0111\u1ED5i.', 'error');
907
+ return;
908
+ }
909
+
910
+ if (!state.cvnssMode) {
911
+ // Vietnamese → CVNSS4.0
912
+ // Save originals for round-trip
913
+ state.originalTexts = state.segments.map(s => s.text);
914
+ state.segments = state.segments.map(seg => {
915
+ try {
916
+ const result = CVNSSConverter.convert(seg.text, 'cqn');
917
+ return { ...seg, text: result.cvss };
918
+ } catch (_) {
919
+ return seg;
920
+ }
921
+ });
922
+ state.cvnssMode = true;
923
+ } else {
924
+ // CVNSS4.0 → Vietnamese
925
+ // Use saved originals if available, otherwise convert back
926
+ if (state.originalTexts.length === state.segments.length) {
927
+ state.segments = state.segments.map((seg, i) => ({
928
+ ...seg,
929
+ text: state.originalTexts[i],
930
+ }));
931
+ } else {
932
+ // Fallback: convert from CVSS back
933
+ state.segments = state.segments.map(seg => {
934
+ try {
935
+ const result = CVNSSConverter.convert(seg.text, 'cvss');
936
+ return { ...seg, text: result.cqn };
937
+ } catch (_) {
938
+ return seg;
939
+ }
940
+ });
941
+ }
942
+ state.cvnssMode = false;
943
+ state.originalTexts = [];
944
+ }
945
+
946
+ renderTable();
947
+ updateCvnssUI();
948
+ }
949
+
950
+ function updateCvnssUI() {
951
+ const btn = els.btnToggleCvnss;
952
+ const label = els.cvnssToggleLabel;
953
+ if (btn) btn.classList.toggle('cvnss-active', state.cvnssMode);
954
+ if (label) label.textContent = state.cvnssMode ? 'Ti\u1EBFng Vi\u1EC7t' : 'CVNSS4.0';
955
+ }
956
+
957
+ // Wire up CVNSS toggle button
958
+ if (els.btnToggleCvnss) {
959
+ els.btnToggleCvnss.addEventListener('click', toggleCvnss);
960
+ }
961
+
962
+ /* --- Init -------------------------------------------------- */
963
+ setStep(1);
964
+ setMode("music");
965
+ renderTable();
966
+ checkHealth();
967
+ setupKaraokeEvents();
968
+ updateCvnssUI();
static/cvnss4.0-converter.js ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CVNSSConverter = (function () {
2
+ // Bảng ánh xạ ký tự đặc biệt
3
+ const specialChars = [
4
+ "`", "“", "”", "<", ">", "@", "-", ";", "=", "…", " ", ",", ".", "?", "!",
5
+ '"', "'", "(", ")", "[", "]", "{", "}", "%", "#", "$", "&", "_", "\\", "/",
6
+ "*", ":", "+", "~", "^", "|", "\r\n", "\r", "\n"
7
+ ];
8
+
9
+ // Bảng ánh xạ phụ âm
10
+ const consonants = {
11
+ cqn: ["ngh", "ng", "ch", "gh", "kh", "nh", "ph", "th", "tr", "gi", "qu", "b", "k", "d", "đ", "g", "h", "c", "l", "m", "n", "r", "s", "t", "v", "x"],
12
+ cvn: ["w", "w", "ch", "g", "k", "nh", "f", "th", "tr", "j", "q", "b", "c", "z", "d", "g", "h", "c", "l", "m", "n", "r", "s", "t", "v", "x"]
13
+ };
14
+
15
+ // Bảng ánh xạ nguyên âm
16
+ const vowels = {
17
+ cqn: [
18
+ "a", "à", "ả", "ã", "á", "ạ", "oa", "òa", "ỏa", "õa", "óa", "ọa", "oà", "oả", "oã", "oá", "oạ", "oác", "oạc", "oách", "oạch",
19
+ "oai", "oài", "oải", "oãi", "oái", "oại", "oao", "oào", "oảo", "oão", "oáo", "oạo", "oáp", "oạp", "oát", "oạt", "oắt", "oặt", "oắc", "oặc",
20
+ "oăn", "oằn", "oẳn", "oẵn", "oắn", "oặn", "oăm", "oằm", "oẳm", "oẵm", "oắm", "oặm", "oăng", "oằng", "oẳng", "oẵng", "oắng", "oặng", "oay", "oày",
21
+ "oảy", "oãy", "oáy", "oạy", "ác", "ạc", "ách", "ạch", "ai", "ài", "ải", "ãi", "ái", "ại", "am", "àm", "ảm", "ãm", "ám", "ạm", "an", "àn",
22
+ "ản", "ãn", "án", "ạn", "oan", "oàn", "oản", "oãn", "oán", "oạn", "oanh", "oành", "oảnh", "oãnh", "oánh", "oạnh", "ang", "àng", "ảng", "ãng",
23
+ "áng", "ạng", "oang", "oàng", "oảng", "oãng", "oáng", "oạng", "anh", "ành", "ảnh", "ãnh", "ánh", "ạnh", "ao", "ào", "ảo", "ão", "áo", "ạo",
24
+ "áp", "ạp", "át", "ạt", "au", "àu", "ảu", "áu", "ạu", "ay", "ày", "ảy", "ãy", "áy", "ạy", "ắc", "ặc", "ăm", "ằm", "ẳm", "ẵm", "ắm", "ặm",
25
+ "ăn", "ằn", "ẳn", "ẵn", "ắn", "ặn", "ăng", "ằng", "ẳng", "ẵng", "ắng", "ặng", "ắp", "ặp", "ắt", "ặt", "ấc", "ậc", "âm", "ầm", "ẩm", "ẫm",
26
+ "ấm", "ậm", "ân", "ần", "ẩn", "ẫn", "ấn", "ận", "âng", "ầng", "ẩng", "ẫng", "ấng", "ậng", "uân", "uần", "uẩn", "uẫn", "uấn", "uận", "uâng",
27
+ "uầng", "uẩng", "uẫng", "uấng", "uậng", "ấp", "ập", "ất", "ật", "uất", "uật", "âu", "ầu", "ẩu", "ẫu", "ấu", "ậu", "ây", "ầy", "ẩy", "ẫy",
28
+ "ấy", "ậy", "uây", "uầy", "uẩy", "uẫy", "uấy", "uậy", "e", "è", "ẻ", "ẽ", "é", "ẹ", "oe", "òe", "ỏe", "õe", "óe", "ọe", "éc", "ẹc", "em",
29
+ "èm", "ẻm", "ẽm", "ém", "ẹm", "en", "èn", "ẻn", "ẽn", "én", "ẹn", "oen", "oèn", "oẻn", "oẽn", "oén", "oẹn", "eng", "èng", "ẻng", "ẽng",
30
+ "éng", "ẹng", "eo", "èo", "ẻo", "ẽo", "éo", "ẹo", "oeo", "oèo", "oẻo", "oẽo", "oéo", "oẹo", "ép", "ẹp", "ét", "ẹt", "oét", "oẹt", "ê",
31
+ "ề", "ể", "ễ", "ế", "ệ", "uê", "uề", "uể", "uễ", "uế", "uệ", "ếch", "ệch", "uếch", "uệch", "êm", "ềm", "ểm", "ễm", "ếm", "ệm", "ên", "ền",
32
+ "ển", "ễn", "ến", "ện", "ênh", "ềnh", "ểnh", "ễnh", "ếnh", "ệnh", "uênh", "uềnh", "uểnh", "uễnh", "uếnh", "uệnh", "ếp", "ệp", "ết", "ệt",
33
+ "êu", "ều", "ểu", "ễu", "ếu", "ệu", "i", "ì", "ỉ", "ĩ", "í", "ị", "uy", "ùy", "ủy", "ũy", "úy", "ụy", "uỳ", "uỷ", "uỹ", "uý", "uỵ", "ia",
34
+ "ìa", "ỉa", "ĩa", "ía", "ịa", "uya", "íc", "ích", "ịch", "uých", "uỵch", "iếc", "iệc", "iêm", "iềm", "iểm", "iễm", "iếm", "iệm", "iên",
35
+ "iền", "iển", "iễn", "iến", "iện", "uyên", "uyền", "uyển", "uyễn", "uyến", "uyện", "iêng", "iềng", "iểng", "iễng", "iếng", "iệng", "iếp",
36
+ "iệp", "iết", "iệt", "uyết", "uyệt", "iêu", "iều", "iểu", "iễu", "iếu", "iệu", "yêt", "yệt", "yên", "yền", "yển", "yễn", "yến", "yện",
37
+ "yêm", "yềm", "yểm", "yễm", "yếm", "yệm", "yêng", "yềng", "yểng", "yễng", "yếng", "yệnh", "yêu", "yều", "yểu", "yễu", "yếu", "yệu", "im",
38
+ "ìm", "ỉm", "ĩm", "ím", "ịm", "in", "ìn", "ỉn", "ĩn", "ín", "ịn", "inh", "ình", "ỉnh", "ĩnh", "ính", "ịnh", "uynh", "uỳnh", "uỷnh", "uỹnh",
39
+ "uýnh", "uỵnh", "íp", "ịp", "uýp", "uỵp", "ít", "ịt", "uýt", "uỵt", "iu", "ìu", "ỉu", "ĩu", "íu", "ịu", "uyu", "uỳu", "uỷu", "uỹu", "uýu",
40
+ "uỵu", "uỳn", "uỷn", "uỹn", "uýn", "uỵn", "o", "ò", "ỏ", "õ", "ó", "ọ", "óc", "ọc", "oi", "òi", "ỏi", "õi", "ói", "ọi", "om", "òm", "ỏm",
41
+ "õm", "óm", "ọm", "on", "òn", "ỏn", "õn", "ón", "ọn", "ong", "òng", "ỏng", "õng", "óng", "ọng", "oóc", "oong", "oòng", "oỏng", "oõng",
42
+ "oóng", "oòng", "oọng", "óp", "ọp", "ót", "ọt", "ô", "ồ", "ổ", "ỗ", "ố", "ộ", "ốc", "ộc", "ôi", "ồi", "ổi", "ỗi", "ối", "ội", "ôm", "ồm",
43
+ "ổm", "ỗm", "ốm", "ộm", "ôn", "ồn", "ổn", "ỗn", "ốn", "ộn", "ông", "ồng", "ổng", "ỗng", "ống", "ộng", "ốp", "ộp", "ốt", "ột", "ơ", "ờ",
44
+ "ở", "ỡ", "ớ", "ợ", "ơi", "ời", "ởi", "ỡi", "ới", "ợi", "ơm", "ờm", "ởm", "ỡm", "ớm", "ợm", "ơn", "ờn", "ởn", "ỡn", "ớn", "ợn", "ơng",
45
+ "ờng", "ởng", "ỡng", "ớng", "ợng", "ớp", "ợp", "ớt", "ợt", "u", "ù", "ủ", "ũ", "ú", "ụ", "ua", "ùa", "ủa", "ũa", "úa", "ụa", "úc", "ục",
46
+ "ui", "ùi", "ủi", "ũi", "úi", "ụi", "um", "ùm", "ủm", "ũm", "úm", "ụm", "un", "ùn", "ủn", "ũn", "ún", "ụn", "ung", "ùng", "ủng", "ũng",
47
+ "úng", "ụng", "uơ", "uờ", "uở", "uỡ", "uớ", "uợ", "uơn", "uờn", "uởn", "uỡn", "uớn", "uợn", "uớt", "uợt", "uốc", "uộc", "uôi", "uồi", "uổi",
48
+ "uỗi", "uối", "uội", "uôm", "uồm", "uổm", "uỗm", "uốm", "uộm", "uôn", "uồn", "uổn", "uỗn", "uốn", "uộn", "uông", "uồng", "uổng", "uỗng",
49
+ "uống", "uộng", "uốt", "uột", "uốp", "uộp", "úp", "ụp", "út", "ụt", "ư", "ừ", "ử", "ữ", "ứ", "ự", "ưa", "ừa", "ửa", "ữa", "ứa", "ựa",
50
+ "ức", "ực", "ưi", "ừi", "ửi", "ữi", "ứi", "ựi", "ưm", "ừm", "ửm", "ữm", "ứm", "ựm", "ưn", "ừn", "ửn", "ữn", "ứn", "ựn", "ưng", "ừng",
51
+ "ửng", "ững", "ứng", "ựng", "ước", "ược", "ươi", "ười", "ưởi", "ưỡi", "ưới", "ượi", "ươm", "ườm", "ưởm", "ưỡm", "ướm", "ượm", "ươn",
52
+ "ườn", "ưởn", "ưỡn", "ướn", "ượn", "ương", "ường", "ưởng", "ưỡng", "ướng", "ượng", "ướp", "ượp", "ướt", "ượt", "ươu", "ườu", "ưởu",
53
+ "ưỡu", "ướu", "ượu", "ứt", "ựt", "ưu", "ừu", "ửu", "ữu", "ứu", "ựu", "y", "ỳ", "ỷ", "ỹ", "ý", "ỵ", "ỳa", "ỷa", "ỹa", "ýa", "ỵa"
54
+ ],
55
+ cvn: [
56
+ "a", "al", "az", "as", "aj", "ar", "oa", "oal", "oaz", "oas", "oaj", "oar", "oal", "oaz", "oas", "oaj", "oar", "osj", "osr", "oakj", "oakr",
57
+ "ojp", "ojl", "ojz", "ojs", "ojj", "ojr", "owp", "owl", "owz", "ows", "owj", "owr", "ofj", "ofr", "odj", "odr", "adx", "adh", "asx", "ash",
58
+ "alo", "alk", "alv", "alw", "alx", "alh", "avo", "avk", "avv", "avw", "avx", "avh", "azo", "azk", "azv", "azw", "azx", "azh", "ajp", "ajl",
59
+ "ajz", "ajs", "ajj", "ajr", "ac", "acr", "akj", "akr", "ai", "ail", "aiz", "ais", "aij", "air", "am", "aml", "amz", "ams", "amj", "amr",
60
+ "an", "anl", "anz", "ans", "anj", "anr", "olp", "oll", "olz", "ols", "olj", "olr", "oahp", "oahl", "oahz", "oahs", "oahj", "oahr", "agp",
61
+ "agl", "agz", "ags", "agj", "agr", "ozp", "ozl", "ozz", "ozs", "ozj", "ozr", "ahp", "ahl", "ahz", "ahs", "ahj", "ahr", "ao", "aol", "aoz",
62
+ "aos", "aoj", "aor", "ap", "apr", "at", "atr", "au", "aul", "auz", "auj", "aur", "ay", "ayl", "ayz", "ays", "ayj", "ayr", "acx", "ach",
63
+ "amo", "amk", "amv", "amw", "amx", "amh", "ano", "ank", "anv", "anw", "anx", "anh", "ago", "agk", "agv", "agw", "agx", "agh", "apx", "aph",
64
+ "atx", "ath", "acb", "acf", "amy", "amd", "amq", "amg", "amb", "amf", "any", "and", "anq", "ang", "anb", "anf", "agy", "agd", "agq", "agg",
65
+ "agb", "agf", "aly", "ald", "alq", "alg", "alb", "alf", "azy", "azd", "azq", "azg", "azb", "azf", "apb", "apf", "atb", "atf", "adb", "adf",
66
+ "auy", "aud", "auq", "aug", "aub", "auf", "ayy", "ayd", "ayq", "ayg", "ayb", "ayf", "ajy", "ajd", "ajq", "ajg", "ajb", "ajf", "e", "el",
67
+ "ez", "es", "ej", "er", "oe", "oel", "oez", "oes", "oej", "oer", "ec", "ecr", "em", "eml", "emz", "ems", "emj", "emr", "en", "enl", "enz",
68
+ "ens", "enj", "enr", "elp", "ell", "elz", "els", "elj", "elr", "egp", "egl", "egz", "egs", "egj", "egr", "eo", "eol", "eoz", "eos", "eoj",
69
+ "eor", "ewp", "ewl", "ewz", "ews", "ewj", "ewr", "ep", "epr", "et", "etr", "edj", "edr", "ey", "ed", "eq", "eg", "eb", "ef", "uey", "ued",
70
+ "ueq", "ueg", "ueb", "uef", "ekb", "ekf", "uekb", "uekf", "emy", "emd", "emq", "emg", "emb", "emf", "eny", "end", "enq", "eng", "enb", "enf",
71
+ "ehy", "ehd", "ehq", "ehg", "ehb", "ehf", "uehy", "uehd", "uehq", "uehg", "uehb", "uehf", "epb", "epf", "etb", "etf", "euy", "eud", "euq",
72
+ "eug", "eub", "euf", "i", "il", "iz", "is", "ij", "ir", "y", "yl", "yz", "ys", "yj", "yr", "yl", "yz", "ys", "yj", "yr", "ia", "ial", "iaz",
73
+ "ias", "iaj", "iar", "ya", "ic", "ikj", "ikr", "ykj", "ykr", "isb", "isf", "ivy", "ivd", "ivq", "ivg", "ivb", "ivf", "ily", "ild", "ilq",
74
+ "ilg", "ilb", "ilf", "yly", "yld", "ylq", "ylg", "ylb", "ylf", "izy", "izd", "izq", "izg", "izb", "izf", "ifb", "iff", "idb", "idf", "ydb",
75
+ "ydf", "iwy", "iwd", "iwq", "iwg", "iwb", "iwf", "idb", "idf", "ily", "ild", "ilq", "ilg", "ilb", "ilf", "ivy", "ivd", "ivq", "ivg", "ivb",
76
+ "ivf", "izy", "izd", "izq", "izg", "izb", "izf", "iwy", "iwd", "iwq", "iwg", "iwb", "iwf", "im", "iml", "imz", "ims", "imj", "imr", "in",
77
+ "inl", "inz", "ins", "inj", "inr", "ihp", "ihl", "ihz", "ihs", "ihj", "ihr", "yhp", "yhl", "yhz", "yhs", "yhj", "yhr", "ip", "ipr", "yp",
78
+ "ypr", "it", "itr", "yt", "ytr", "iu", "iul", "iuz", "ius", "iuj", "iur", "yu", "yul", "yuz", "yus", "yuj", "yur", "ynl", "ynz", "yns",
79
+ "ynj", "ynr", "o", "ol", "oz", "os", "oj", "or", "oc", "ocr", "oi", "oil", "oiz", "ois", "oij", "oir", "om", "oml", "omz", "oms", "omj",
80
+ "omr", "on", "onl", "onz", "ons", "onj", "onr", "ogp", "ogl", "ogz", "ogs", "ogj", "ogr", "ooc", "oog", "oogl", "oogz", "oogs", "oogj",
81
+ "oogl", "oogr", "op", "opr", "ot", "otr", "oy", "od", "oq", "og", "ob", "of", "ocb", "ocf", "oiy", "oid", "oiq", "oig", "oib", "oif",
82
+ "omy", "omd", "omq", "omg", "omb", "omf", "ony", "ond", "onq", "ong", "onb", "onf", "ogy", "ogd", "ogq", "ogg", "ogb", "ogf", "opb",
83
+ "opf", "otb", "otf", "oo", "ok", "ov", "ow", "ox", "oh", "oio", "oik", "oiv", "oiw", "oix", "oih", "omo", "omk", "omv", "omw", "omx",
84
+ "omh", "ono", "onk", "onv", "onw", "onx", "onh", "ogo", "ogk", "ogv", "ogw", "ogx", "ogh", "opx", "oph", "otx", "oth", "u", "ul", "uz",
85
+ "us", "uj", "ur", "ua", "ual", "uaz", "uas", "uaj", "uar", "uc", "ucr", "ui", "uil", "uiz", "uis", "uij", "uir", "um", "uml", "umz", "ums",
86
+ "umj", "umr", "un", "unl", "unz", "uns", "unj", "unr", "ugp", "ugl", "ugz", "ugs", "ugj", "ugr", "uoo", "uok", "uov", "uow", "uox", "uoh",
87
+ "olo", "olk", "olv", "olw", "olx", "olh", "odx", "odh", "usb", "usf", "ujy", "ujd", "ujq", "ujg", "ujb", "ujf", "uvy", "uvd", "uvq", "uvg",
88
+ "uvb", "uvf", "uly", "uld", "ulq", "ulg", "ulb", "ulf", "uzy", "uzd", "uzq", "uzg", "uzb", "uzf", "udb", "udf", "ufb", "uff", "up", "upr",
89
+ "ut", "utr", "uo", "uk", "uv", "uw", "ux", "uh", "uao", "uak", "uav", "uaw", "uax", "uah", "ucx", "uch", "uio", "uik", "uiv", "uiw", "uix",
90
+ "uih", "umo", "umk", "umv", "umw", "umx", "umh", "uno", "unk", "unv", "unw", "unx", "unh", "ugo", "ugk", "ugv", "ugw", "ugx", "ugh", "usx",
91
+ "ush", "ujo", "ujk", "ujv", "ujw", "ujx", "ujh", "uvo", "uvk", "uvv", "uvw", "uvx", "uvh", "ulo", "ulk", "ulv", "ulw", "ulx", "ulh", "uzo",
92
+ "uzk", "uzv", "uzw", "uzx", "uzh", "ufx", "ufh", "udx", "udh", "uwo", "uwk", "uwv", "uww", "uwx", "uwh", "utx", "uth", "uuo", "uuk", "uuv",
93
+ "uuw", "uux", "uuh", "i", "il", "iz", "is", "ij", "ir", "ial", "iaz", "ias", "iaj", "iar"
94
+ ],
95
+ cvss: [
96
+ "a", "al", "az", "as", "aj", "ar", "oa", "oal", "oaz", "oas", "oaj", "oar", "oal", "oaz", "oas", "oaj", "oar", "osj", "osr", "oakj", "oakr",
97
+ "ojp", "ojl", "ojz", "ojs", "ojj", "ojr", "owp", "owl", "owz", "ows", "owj", "owr", "ofj", "ofr", "odj", "odr", "adx", "adh", "asx", "ash",
98
+ "alo", "alk", "alv", "alw", "alx", "alh", "avo", "avk", "avv", "avw", "avx", "avh", "azo", "azk", "azv", "azw", "azx", "azh", "ajp", "ajl",
99
+ "ajz", "ajs", "ajj", "ajr", "ac", "acr", "akj", "akr", "ai", "ail", "aiz", "ais", "aij", "air", "am", "aml", "amz", "ams", "amj", "amr",
100
+ "an", "anl", "anz", "ans", "anj", "anr", "olp", "oll", "olz", "ols", "olj", "olr", "oahp", "oahl", "oahz", "oahs", "oahj", "oahr", "agp",
101
+ "agl", "agz", "ags", "agj", "agr", "ozp", "ozl", "ozz", "ozs", "ozj", "ozr", "ahp", "ahl", "ahz", "ahs", "ahj", "ahr", "ao", "aol", "aoz",
102
+ "aos", "aoj", "aor", "ap", "apr", "at", "atr", "au", "aul", "auz", "auj", "aur", "ay", "ayl", "ayz", "ays", "ayj", "ayr", "acx", "ach",
103
+ "amo", "amk", "amv", "amw", "amx", "amh", "ano", "ank", "anv", "anw", "anx", "anh", "ago", "agk", "agv", "agw", "agx", "agh", "apx", "aph",
104
+ "atx", "ath", "acb", "acf", "amy", "amd", "amq", "amg", "amb", "amf", "any", "and", "anq", "ang", "anb", "anf", "agy", "agd", "agq", "agg",
105
+ "agb", "agf", "aly", "ald", "alq", "alg", "alb", "alf", "azy", "azd", "azq", "azg", "azb", "azf", "apb", "apf", "atb", "atf", "adb", "adf",
106
+ "auy", "aud", "auq", "aug", "aub", "auf", "ayy", "ayd", "ayq", "ayg", "ayb", "ayf", "ajy", "ajd", "ajq", "ajg", "ajb", "ajf", "e", "el",
107
+ "ez", "es", "ej", "er", "oe", "oel", "oez", "oes", "oej", "oer", "ec", "ecr", "em", "eml", "emz", "ems", "emj", "emr", "en", "enl", "enz",
108
+ "ens", "enj", "enr", "elp", "ell", "elz", "els", "elj", "elr", "egp", "egl", "egz", "egs", "egj", "egr", "eo", "eol", "eoz", "eos", "eoj",
109
+ "eor", "ewp", "ewl", "ewz", "ews", "ewj", "ewr", "ep", "epr", "et", "etr", "edj", "edr", "ey", "ed", "eq", "eg", "eb", "ef", "uey", "ued",
110
+ "ueq", "ueg", "ueb", "uef", "ekb", "ekf", "uekb", "uekf", "emy", "emd", "emq", "emg", "emb", "emf", "eny", "end", "enq", "eng", "enb", "enf",
111
+ "ehy", "ehd", "ehq", "ehg", "ehb", "ehf", "uehy", "uehd", "uehq", "uehg", "uehb", "uehf", "epb", "epf", "etb", "etf", "euy", "eud", "euq",
112
+ "eug", "eub", "euf", "i", "il", "iz", "is", "ij", "ir", "y", "yl", "yz", "ys", "yj", "yr", "yl", "yz", "ys", "yj", "yr", "ia", "ial", "iaz",
113
+ "ias", "iaj", "iar", "ya", "ic", "ikj", "ikr", "ykj", "ykr", "isb", "isf", "ivy", "ivd", "ivq", "ivg", "ivb", "ivf", "ily", "ild", "ilq",
114
+ "ilg", "ilb", "ilf", "yly", "yld", "ylq", "ylg", "ylb", "ylf", "izy", "izd", "izq", "izg", "izb", "izf", "ifb", "iff", "idb", "idf", "ydb",
115
+ "ydf", "iwy", "iwd", "iwq", "iwg", "iwb", "iwf", "idb", "idf", "ily", "ild", "ilq", "ilg", "ilb", "ilf", "ivy", "ivd", "ivq", "ivg", "ivb",
116
+ "ivf", "izy", "izd", "izq", "izg", "izb", "izf", "iwy", "iwd", "iwq", "iwg", "iwb", "iwf", "im", "iml", "imz", "ims", "imj", "imr", "in",
117
+ "inl", "inz", "ins", "inj", "inr", "ihp", "ihl", "ihz", "ihs", "ihj", "ihr", "yhp", "yhl", "yhz", "yhs", "yhj", "yhr", "ip", "ipr", "yp",
118
+ "ypr", "it", "itr", "yt", "ytr", "iu", "iul", "iuz", "ius", "iuj", "iur", "yu", "yul", "yuz", "yus", "yuj", "yur", "ynl", "ynz", "yns",
119
+ "ynj", "ynr", "o", "ol", "oz", "os", "oj", "or", "oc", "ocr", "oi", "oil", "oiz", "ois", "oij", "oir", "om", "oml", "omz", "oms", "omj",
120
+ "omr", "on", "onl", "onz", "ons", "onj", "onr", "ogp", "ogl", "ogz", "ogs", "ogj", "ogr", "ooc", "oog", "oogl", "oogz", "oogs", "oogj",
121
+ "oogl", "oogr", "op", "opr", "ot", "otr", "oy", "od", "oq", "og", "ob", "of", "ocb", "ocf", "oiy", "oid", "oiq", "oig", "oib", "oif",
122
+ "omy", "omd", "omq", "omg", "omb", "omf", "ony", "ond", "onq", "ong", "onb", "onf", "ogy", "ogd", "ogq", "ogg", "ogb", "ogf", "opb",
123
+ "opf", "otb", "otf", "oo", "ok", "ov", "ow", "ox", "oh", "oio", "oik", "oiv", "oiw", "oix", "oih", "omo", "omk", "omv", "omw", "omx",
124
+ "omh", "ono", "onk", "onv", "onw", "onx", "onh", "ogo", "ogk", "ogv", "ogw", "ogx", "ogh", "opx", "oph", "otx", "oth", "u", "ul", "uz",
125
+ "us", "uj", "ur", "ua", "ual", "uaz", "uas", "uaj", "uar", "uc", "ucr", "ui", "uil", "uiz", "uis", "uij", "uir", "um", "uml", "umz", "ums",
126
+ "umj", "umr", "un", "unl", "unz", "uns", "unj", "unr", "ugp", "ugl", "ugz", "ugs", "ugj", "ugr", "uoo", "uok", "uov", "uow", "uox", "uoh",
127
+ "olo", "olk", "olv", "olw", "olx", "olh", "odx", "odh", "usb", "usf", "ujy", "ujd", "ujq", "ujg", "ujb", "ujf", "uvy", "uvd", "uvq", "uvg",
128
+ "uvb", "uvf", "uly", "uld", "ulq", "ulg", "ulb", "ulf", "uzy", "uzd", "uzq", "uzg", "uzb", "uzf", "udb", "udf", "ufb", "uff", "up", "upr",
129
+ "ut", "utr", "uo", "uk", "uv", "uw", "ux", "uh", "uao", "uak", "uav", "uaw", "uax", "uah", "ucx", "uch", "uio", "uik", "uiv", "uiw", "uix",
130
+ "uih", "umo", "umk", "umv", "umw", "umx", "umh", "uno", "unk", "unv", "unw", "unx", "unh", "ugo", "ugk", "ugv", "ugw", "ugx", "ugh", "usx",
131
+ "ush", "ujo", "ujk", "ujv", "ujw", "ujx", "ujh", "uvo", "uvk", "uvv", "uvw", "uvx", "uvh", "ulo", "ulk", "ulv", "ulw", "ulx", "ulh", "uzo",
132
+ "uzk", "uzv", "uzw", "uzx", "uzh", "ufx", "ufh", "udx", "udh", "uwo", "uwk", "uwv", "uww", "uwx", "uwh", "utx", "uth", "uuo", "uuk", "uuv",
133
+ "uuw", "uux", "uuh", "i", "il", "iz", "is", "ij", "ir", "ial", "iaz", "ias", "iaj", "iar"
134
+ ]
135
+ };
136
+
137
+ // Bảng ánh xạ nguyên âm cơ bản
138
+ const baseVowels = [
139
+ "aàảãáạ", "ăằẳẵắặ", "âầẩẫấậ", "eèẻẽéẹ", "êềểễếệ", "iìỉĩíị",
140
+ "oòỏõóọ", "ôồổỗốộ", "ơờởỡớợ", "uùủũúụ", "ưừửữứự", "yỳỷỹýỵ"
141
+ ];
142
+
143
+ // Bảng ánh xạ thay thế đặc biệt
144
+ const specialReplacements = {
145
+ y: "yỳỷỹýỵ",
146
+ i: "iìỉĩíị"
147
+ };
148
+
149
+ // Quy tắc điều chỉnh phụ âm
150
+ const consonantAdjustments = {
151
+ phu_am: ["ngh", "gh", "k"],
152
+ phu_am_chuyen_doi: ["ng", "g", "c"],
153
+ nguyen_am: "ieê"
154
+ };
155
+
156
+ // Hàm kiểm tra chữ in hoa
157
+ function isUpperCase(str) {
158
+ return /^[A-ZÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬĐÈÉẺẼẸÊỀẾỂỄỆÌÍỈĨỊÒÓỎÕỌÔỒỐỔỖỘƠỜỚỞỠỢÙÚỦŨỤƯỪỨỬỮỰỲÝỶỸỴ]+$/.test(str);
159
+ }
160
+
161
+ // Hàm lấy nguyên âm cơ bản
162
+ function getBaseVowel(char) {
163
+ for (const vowelGroup of baseVowels) {
164
+ if (vowelGroup.includes(char)) return vowelGroup[0];
165
+ }
166
+ return char;
167
+ }
168
+
169
+ // Hàm tách chuỗi thành token
170
+ function splitString(str) {
171
+ str = str.normalize("NFC");
172
+ const tokens = str.split(/([\s|,|;|`|@|<|>|“|”|.|=|…|?|!|\\|'|"|(|)|[|\]|{|}|%|#|$|&|\-|_|/|*|:|+|~|^|||\r\n|\n|\r])/gm);
173
+ return tokens.filter(token => token !== "");
174
+ }
175
+
176
+ // Hàm điều chỉnh phụ âm và nguyên âm
177
+ function adjustConsonantVowel(cqnPad, cqnVan) {
178
+ const firstChar = cqnVan[0] || "";
179
+ if (cqnPad === "qu" && getBaseVowel(firstChar) === "u") {
180
+ cqnPad = "q";
181
+ }
182
+ if (!cqnPad && getBaseVowel(firstChar) === "i") {
183
+ cqnVan = cqnVan.replace(firstChar, specialReplacements.y[specialReplacements.i.indexOf(firstChar)]);
184
+ }
185
+ if (cqnPad === "gi" && getBaseVowel(firstChar) === "i") {
186
+ cqnPad = "g";
187
+ }
188
+ if (consonantAdjustments.phu_am.includes(cqnPad) && !consonantAdjustments.nguyen_am.includes(getBaseVowel(firstChar))) {
189
+ cqnPad = consonantAdjustments.phu_am_chuyen_doi[consonantAdjustments.phu_am.indexOf(cqnPad)];
190
+ }
191
+ return { cqnPad, cqnVan };
192
+ }
193
+
194
+ // Hàm chuyển từ CQN sang CVN và CVSS
195
+ function cqnToCvnAndCvss(word) {
196
+ const lowerWord = word.toLowerCase();
197
+ let consonant = "", vowelPart = lowerWord, cvnConsonant = "", cqnResult = "", cvnResult = "", cvssResult = "";
198
+
199
+ // Tìm phụ âm
200
+ for (const c of consonants.cqn) {
201
+ if (lowerWord.startsWith(c)) {
202
+ consonant = c;
203
+ vowelPart = lowerWord.replace(c, "");
204
+ break;
205
+ }
206
+ }
207
+
208
+ // Điều chỉnh đặc biệt
209
+ if (consonant === "gi" && vowelPart !== "a" && vowels.cqn.includes("i" + vowelPart)) {
210
+ vowelPart = "i" + vowelPart;
211
+ }
212
+ if (consonant === "qu" && getBaseVowel(vowelPart[0]) === "y") {
213
+ vowelPart = "u" + vowelPart;
214
+ }
215
+ if (consonant === "g" && getBaseVowel(vowelPart[0]) === "i") {
216
+ consonant = "gi";
217
+ }
218
+
219
+ // Ánh xạ phụ âm
220
+ cvnConsonant = consonants.cqn.includes(consonant) ? consonants.cvn[consonants.cqn.indexOf(consonant)] : consonant;
221
+
222
+ // Ánh xạ nguyên âm
223
+ const vowelIndex = vowels.cqn.indexOf(vowelPart);
224
+ if (vowelIndex !== -1) {
225
+ cqnResult = vowelPart;
226
+ cvnResult = vowels.cvn[vowelIndex];
227
+ cvssResult = vowels.cvss[vowelIndex];
228
+ } else {
229
+ cqnResult = vowelPart;
230
+ cvnResult = vowelPart;
231
+ cvssResult = vowelPart;
232
+ }
233
+
234
+ // Kết hợp kết quả
235
+ let cvnOutput = cvnConsonant + cvnResult;
236
+ let cvssOutput = cvnConsonant + cvssResult;
237
+
238
+ // Xử lý chữ hoa
239
+ if (word[0] !== word[0].toLowerCase()) {
240
+ if (cqnResult.length) cqnResult = cqnResult[0].toUpperCase() + cqnResult.slice(1);
241
+ if (cvnOutput.length) cvnOutput = cvnOutput[0].toUpperCase() + cvnOutput.slice(1);
242
+ if (cvssOutput.length) cvssOutput = cvssOutput[0].toUpperCase() + cvssOutput.slice(1);
243
+ }
244
+ if (isUpperCase(word)) {
245
+ cqnResult = cqnResult.toUpperCase();
246
+ cvnOutput = cvnOutput.toUpperCase();
247
+ cvssOutput = cvssOutput.toUpperCase();
248
+ }
249
+
250
+ return { cqn: cqnResult, cvn: cvnOutput, cvss: cvssOutput };
251
+ }
252
+
253
+ // Hàm chuyển từ CVN sang CQN và CVSS
254
+ function cvnToCqnAndCvss(word) {
255
+ const lowerWord = word.toLowerCase();
256
+ let consonant = "", vowelPart = lowerWord, cqnConsonant = "", cqnResult = "", cvnResult = "", cvssResult = "";
257
+
258
+ // Tìm phụ âm
259
+ for (const c of consonants.cvn) {
260
+ if (lowerWord.startsWith(c)) {
261
+ consonant = c;
262
+ cqnConsonant = consonants.cqn[consonants.cvn.indexOf(c)];
263
+ vowelPart = lowerWord.replace(c, "");
264
+ break;
265
+ }
266
+ }
267
+
268
+ // Ánh xạ nguyên âm
269
+ const vowelIndex = vowels.cvn.indexOf(vowelPart);
270
+ if (vowelIndex !== -1) {
271
+ cqnResult = vowels.cqn[vowelIndex];
272
+ cvnResult = vowelPart;
273
+ cvssResult = vowels.cvss[vowelIndex];
274
+ } else {
275
+ cqnResult = vowelPart;
276
+ cvnResult = vowelPart;
277
+ cvssResult = vowelPart;
278
+ }
279
+
280
+ // Điều chỉnh đặc biệt
281
+ if (consonant === "j" && vowelPart === "ịa") {
282
+ cqnConsonant = "gi";
283
+ cqnResult = "ỵa";
284
+ }
285
+
286
+ // Điều chỉnh phụ âm và nguyên âm
287
+ const adjusted = adjustConsonantVowel(cqnConsonant, cqnResult);
288
+ cqnConsonant = adjusted.cqnPad;
289
+ cqnResult = adjusted.cqnVan;
290
+
291
+ // Kết hợp kết quả
292
+ let cqnOutput = cqnConsonant + cqnResult;
293
+ let cvssOutput = consonant + cvssResult;
294
+
295
+ // Xử lý chữ hoa
296
+ if (word[0] !== word[0].toLowerCase()) {
297
+ if (cqnOutput.length) cqnOutput = cqnOutput[0].toUpperCase() + cqnOutput.slice(1);
298
+ if (cvnResult.length) cvnResult = cvnResult[0].toUpperCase() + cvnResult.slice(1);
299
+ if (cvssOutput.length) cvssOutput = cvssOutput[0].toUpperCase() + cvssOutput.slice(1);
300
+ }
301
+ if (isUpperCase(word)) {
302
+ cqnOutput = cqnOutput.toUpperCase();
303
+ cvnResult = cvnResult.toUpperCase();
304
+ cvssOutput = cvssOutput.toUpperCase();
305
+ }
306
+
307
+ return { cqn: cqnOutput, cvn: cvnResult, cvss: cvssOutput };
308
+ }
309
+
310
+ // Hàm chuyển từ CVSS sang CQN và CVN
311
+ function cvssToCqnAndCvn(word) {
312
+ const lowerWord = word.toLowerCase();
313
+ let consonant = "", vowelPart = lowerWord, cqnConsonant = "", cqnResult = "", cvnResult = "", cvssResult = "";
314
+
315
+ // Tìm phụ âm
316
+ for (const c of consonants.cvn) {
317
+ if (lowerWord.startsWith(c)) {
318
+ consonant = c;
319
+ cqnConsonant = consonants.cqn[consonants.cvn.indexOf(c)];
320
+ vowelPart = lowerWord.replace(c, "");
321
+ break;
322
+ }
323
+ }
324
+
325
+ // Ánh xạ nguyên âm
326
+ const vowelIndex = vowels.cvss.indexOf(vowelPart);
327
+ if (vowelIndex !== -1) {
328
+ cqnResult = vowels.cqn[vowelIndex];
329
+ cvnResult = vowels.cvn[vowelIndex];
330
+ cvssResult = vowelPart;
331
+ } else {
332
+ cqnResult = vowelPart;
333
+ cvnResult = vowelPart;
334
+ cvssResult = vowelPart;
335
+ }
336
+
337
+ // Điều chỉnh đặc biệt
338
+ if (consonant === "j" && vowelPart === "iar") {
339
+ cqnConsonant = "gi";
340
+ cqnResult = "ỵa";
341
+ }
342
+ if (lowerWord === "it") {
343
+ cqnResult = "ít";
344
+ }
345
+ if (lowerWord === "ikj") {
346
+ cqnResult = "ích";
347
+ }
348
+
349
+ // Điều chỉnh phụ âm và nguyên âm
350
+ const adjusted = adjustConsonantVowel(cqnConsonant, cqnResult);
351
+ cqnConsonant = adjusted.cqnPad;
352
+ cqnResult = adjusted.cqnVan;
353
+
354
+ // Kết hợp kết quả
355
+ let cqnOutput = cqnConsonant + cqnResult;
356
+ let cvnOutput = consonant + cvnResult;
357
+
358
+ // Xử lý chữ hoa
359
+ if (word[0] !== word[0].toLowerCase()) {
360
+ if (cqnOutput.length) cqnOutput = cqnOutput[0].toUpperCase() + cqnOutput.slice(1);
361
+ if (cvnOutput.length) cvnOutput = cvnOutput[0].toUpperCase() + cvnOutput.slice(1);
362
+ if (cvssResult.length) cvssResult = cvssResult[0].toUpperCase() + cvssResult.slice(1);
363
+ }
364
+ if (isUpperCase(word)) {
365
+ cqnOutput = cqnOutput.toUpperCase();
366
+ cvnOutput = cvnOutput.toUpperCase();
367
+ cvssResult = cvssResult.toUpperCase();
368
+ }
369
+
370
+ return { cqn: cqnOutput, cvn: cvnOutput, cvss: cvssResult };
371
+ }
372
+
373
+ // Hàm chuyển đổi toàn bộ văn bản
374
+ function convertText(input, mode) {
375
+ const tokens = splitString(input);
376
+ const result = { cqn: [], cvn: [], cvss: [] };
377
+
378
+ tokens.forEach(token => {
379
+ if (specialChars.includes(token)) {
380
+ result.cqn.push(token);
381
+ result.cvn.push(token);
382
+ result.cvss.push(token);
383
+ } else {
384
+ let converted;
385
+ if (mode === "cqn") {
386
+ converted = cqnToCvnAndCvss(token);
387
+ } else if (mode === "cvn") {
388
+ converted = cvnToCqnAndCvss(token);
389
+ } else if (mode === "cvss") {
390
+ converted = cvssToCqnAndCvn(token);
391
+ }
392
+ result.cqn.push(converted.cqn);
393
+ result.cvn.push(converted.cvn);
394
+ result.cvss.push(converted.cvss);
395
+ }
396
+ });
397
+
398
+ return {
399
+ cqn: result.cqn.join(""),
400
+ cvn: result.cvn.join(""),
401
+ cvss: result.cvss.join("")
402
+ };
403
+ }
404
+
405
+ // Xuất module
406
+ return {
407
+ convert: convertText,
408
+ specialChars
409
+ };
410
+ })();
411
+
412
+ // Xuất module cho môi trường Node.js hoặc trình duyệt
413
+ if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
414
+ module.exports = CVNSSConverter;
415
+ } else {
416
+ window.CVNSSConverter = CVNSSConverter;
417
+ }
static/styles.css ADDED
@@ -0,0 +1,1089 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ /* --- CVNSS Toggle Button ------------------------------------ */
657
+ .btn-cvnss {
658
+ background: rgba(168,85,247,0.12);
659
+ border: 1px solid rgba(168,85,247,0.3);
660
+ color: #c4b5fd;
661
+ font-weight: 700;
662
+ font-size: 0.73rem;
663
+ letter-spacing: 0.03em;
664
+ transition: all 0.2s;
665
+ }
666
+ .btn-cvnss:hover:not(:disabled) {
667
+ background: rgba(168,85,247,0.22);
668
+ border-color: rgba(168,85,247,0.45);
669
+ box-shadow: 0 2px 10px rgba(168,85,247,0.2);
670
+ }
671
+ .btn-cvnss.cvnss-active {
672
+ background: rgba(168,85,247,0.3);
673
+ border-color: #a855f7;
674
+ color: #e9d5ff;
675
+ box-shadow: 0 0 12px rgba(168,85,247,0.25);
676
+ }
677
+
678
+ /* --- Footer ------------------------------------------------- */
679
+ .footer {
680
+ text-align: center;
681
+ padding: 20px 24px;
682
+ font-size: 0.75rem;
683
+ color: var(--text-muted);
684
+ border-top: 1px solid var(--border);
685
+ line-height: 1.6;
686
+ }
687
+ .footer-link {
688
+ color: var(--accent-hover);
689
+ text-decoration: none;
690
+ font-weight: 600;
691
+ transition: color 0.2s;
692
+ }
693
+ .footer-link:hover {
694
+ color: #a5b4fc;
695
+ text-decoration: underline;
696
+ }
697
+
698
+ /* --- Utilities ---------------------------------------------- */
699
+ [hidden] { display: none !important; }
700
+
701
+ /* --- Scrollbar ---------------------------------------------- */
702
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
703
+ ::-webkit-scrollbar-track { background: transparent; }
704
+ ::-webkit-scrollbar-thumb {
705
+ background: rgba(255,255,255,0.1);
706
+ border-radius: 99px;
707
+ }
708
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
709
+
710
+ /* --- Spinner for loading button ----------------------------- */
711
+ .btn-loading {
712
+ position: relative;
713
+ pointer-events: none;
714
+ color: transparent !important;
715
+ }
716
+ .btn-loading::after {
717
+ content: '';
718
+ position: absolute;
719
+ width: 18px; height: 18px;
720
+ border: 2px solid rgba(255,255,255,0.3);
721
+ border-top-color: #fff;
722
+ border-radius: 50%;
723
+ animation: spin 0.6s linear infinite;
724
+ }
725
+ @keyframes spin { to { transform: rotate(360deg); } }
726
+
727
+ /* --- Mode Selector ------------------------------------------ */
728
+ .mode-selector {
729
+ display: flex;
730
+ flex-direction: column;
731
+ gap: 8px;
732
+ }
733
+ .mode-label {
734
+ font-size: 0.78rem;
735
+ font-weight: 600;
736
+ color: var(--text-muted);
737
+ text-transform: uppercase;
738
+ letter-spacing: 0.06em;
739
+ }
740
+ .mode-toggle {
741
+ display: flex;
742
+ gap: 6px;
743
+ background: var(--bg-base);
744
+ border-radius: var(--radius);
745
+ padding: 4px;
746
+ border: 1px solid var(--border);
747
+ }
748
+ .mode-btn {
749
+ flex: 1;
750
+ display: inline-flex;
751
+ align-items: center;
752
+ justify-content: center;
753
+ gap: 6px;
754
+ padding: 8px 12px;
755
+ border: none;
756
+ border-radius: var(--radius-sm);
757
+ background: transparent;
758
+ color: var(--text-secondary);
759
+ font-family: var(--font-sans);
760
+ font-size: 0.82rem;
761
+ font-weight: 600;
762
+ cursor: pointer;
763
+ transition: all 0.2s;
764
+ white-space: nowrap;
765
+ }
766
+ .mode-btn:hover:not(.active) {
767
+ color: var(--text-primary);
768
+ background: rgba(255,255,255,0.04);
769
+ }
770
+ .mode-btn.active {
771
+ background: var(--accent);
772
+ color: #fff;
773
+ box-shadow: 0 2px 8px var(--accent-glow);
774
+ }
775
+ .mode-btn .icon-btn {
776
+ width: 14px;
777
+ height: 14px;
778
+ }
779
+ .mode-hint {
780
+ font-size: 0.75rem;
781
+ color: var(--text-muted);
782
+ line-height: 1.4;
783
+ margin: 0;
784
+ }
785
+
786
+ /* --- Coverage Bar ------------------------------------------- */
787
+ .coverage-bar {
788
+ display: flex;
789
+ flex-direction: column;
790
+ gap: 6px;
791
+ padding: 10px 14px;
792
+ background: rgba(255,255,255,0.02);
793
+ border-radius: var(--radius);
794
+ border: 1px solid var(--border);
795
+ }
796
+ .coverage-header {
797
+ display: flex;
798
+ align-items: center;
799
+ justify-content: space-between;
800
+ }
801
+ .coverage-label {
802
+ font-size: 0.75rem;
803
+ font-weight: 600;
804
+ color: var(--text-muted);
805
+ text-transform: uppercase;
806
+ letter-spacing: 0.04em;
807
+ }
808
+ .coverage-pct {
809
+ font-size: 0.82rem;
810
+ font-weight: 700;
811
+ font-family: var(--font-mono);
812
+ color: var(--text-primary);
813
+ }
814
+ .coverage-track {
815
+ height: 6px;
816
+ background: var(--bg-raised);
817
+ border-radius: 99px;
818
+ overflow: hidden;
819
+ }
820
+ .coverage-fill {
821
+ height: 100%;
822
+ width: 0%;
823
+ border-radius: 99px;
824
+ transition: width 0.6s ease, background 0.3s;
825
+ }
826
+ .coverage-fill.cov-high {
827
+ background: linear-gradient(90deg, var(--success), #34d399);
828
+ }
829
+ .coverage-fill.cov-mid {
830
+ background: linear-gradient(90deg, var(--warning), #fbbf24);
831
+ }
832
+ .coverage-fill.cov-low {
833
+ background: linear-gradient(90deg, var(--danger), #f87171);
834
+ }
835
+
836
+ /* --- Subtitle Preview Overlay ------------------------------- */
837
+ .sub-preview-overlay {
838
+ position: absolute;
839
+ left: 0;
840
+ right: 0;
841
+ bottom: 10%;
842
+ display: flex;
843
+ justify-content: center;
844
+ pointer-events: none;
845
+ z-index: 10;
846
+ transition: bottom 0.3s ease;
847
+ }
848
+ .sub-preview-text {
849
+ display: inline-block;
850
+ padding: 6px 18px;
851
+ font-family: 'Bangers', sans-serif;
852
+ font-size: 1.3rem;
853
+ color: #fff;
854
+ text-shadow: 0 0 6px rgba(0,0,0,0.9), 2px 2px 4px rgba(0,0,0,0.7);
855
+ background: rgba(0,0,0,0.45);
856
+ border-radius: var(--radius-sm);
857
+ letter-spacing: 0.02em;
858
+ line-height: 1.4;
859
+ text-align: center;
860
+ max-width: 90%;
861
+ word-break: break-word;
862
+ transition: all 0.3s ease;
863
+ }
864
+ .sub-preview-text .ks-word-active {
865
+ color: #FFD700;
866
+ transition: color 0.15s;
867
+ }
868
+
869
+ /* --- Karaoke Style Panel ------------------------------------ */
870
+ .karaoke-style-panel {
871
+ display: flex;
872
+ flex-direction: column;
873
+ gap: 10px;
874
+ padding: 14px;
875
+ background: rgba(99,102,241,0.04);
876
+ border: 1px solid rgba(99,102,241,0.12);
877
+ border-radius: var(--radius);
878
+ }
879
+ .ks-title {
880
+ display: flex;
881
+ align-items: center;
882
+ gap: 8px;
883
+ font-size: 0.82rem;
884
+ font-weight: 700;
885
+ color: var(--accent-hover);
886
+ text-transform: uppercase;
887
+ letter-spacing: 0.05em;
888
+ margin: 0;
889
+ }
890
+ .ks-group {
891
+ display: flex;
892
+ flex-direction: column;
893
+ gap: 4px;
894
+ }
895
+ .ks-label {
896
+ font-size: 0.73rem;
897
+ font-weight: 600;
898
+ color: var(--text-muted);
899
+ text-transform: uppercase;
900
+ letter-spacing: 0.04em;
901
+ }
902
+
903
+ /* Font select */
904
+ .ks-select {
905
+ width: 100%;
906
+ padding: 8px 10px;
907
+ border-radius: var(--radius-sm);
908
+ border: 1px solid var(--border);
909
+ background-color: var(--bg-input);
910
+ color: var(--text-primary);
911
+ font-size: 0.85rem;
912
+ font-family: var(--font-sans);
913
+ cursor: pointer;
914
+ transition: border-color 0.2s;
915
+ appearance: none;
916
+ 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");
917
+ background-repeat: no-repeat;
918
+ background-position: right 10px center;
919
+ padding-right: 28px;
920
+ }
921
+ .ks-select option {
922
+ font-family: var(--font-sans);
923
+ font-size: 0.85rem;
924
+ padding: 6px 10px;
925
+ }
926
+ .ks-select:focus {
927
+ outline: none;
928
+ border-color: var(--border-focus);
929
+ box-shadow: 0 0 0 3px var(--accent-glow);
930
+ }
931
+
932
+ /* Color pickers */
933
+ .ks-color-row {
934
+ flex-direction: row;
935
+ gap: 10px;
936
+ }
937
+ .ks-color-item {
938
+ flex: 1;
939
+ display: flex;
940
+ flex-direction: column;
941
+ gap: 4px;
942
+ }
943
+ .ks-color-wrap {
944
+ display: flex;
945
+ align-items: center;
946
+ gap: 8px;
947
+ }
948
+ .ks-color-input {
949
+ width: 32px;
950
+ height: 32px;
951
+ border: 2px solid var(--border);
952
+ border-radius: var(--radius-sm);
953
+ cursor: pointer;
954
+ padding: 0;
955
+ background: none;
956
+ flex-shrink: 0;
957
+ }
958
+ .ks-color-input::-webkit-color-swatch-wrapper { padding: 2px; }
959
+ .ks-color-input::-webkit-color-swatch {
960
+ border: none;
961
+ border-radius: 4px;
962
+ }
963
+ .ks-color-hex {
964
+ font-family: var(--font-mono);
965
+ font-size: 0.73rem;
966
+ color: var(--text-secondary);
967
+ font-weight: 500;
968
+ }
969
+
970
+ /* Range sliders */
971
+ .ks-slider-wrap {
972
+ display: flex;
973
+ align-items: center;
974
+ gap: 10px;
975
+ }
976
+ .ks-range {
977
+ flex: 1;
978
+ height: 4px;
979
+ -webkit-appearance: none;
980
+ appearance: none;
981
+ background: var(--bg-raised);
982
+ border-radius: 99px;
983
+ outline: none;
984
+ cursor: pointer;
985
+ }
986
+ .ks-range::-webkit-slider-thumb {
987
+ -webkit-appearance: none;
988
+ width: 16px;
989
+ height: 16px;
990
+ border-radius: 50%;
991
+ background: var(--accent);
992
+ border: 2px solid var(--bg-surface);
993
+ box-shadow: 0 0 6px var(--accent-glow);
994
+ cursor: pointer;
995
+ transition: transform 0.15s;
996
+ }
997
+ .ks-range::-webkit-slider-thumb:hover {
998
+ transform: scale(1.2);
999
+ }
1000
+ .ks-range::-moz-range-thumb {
1001
+ width: 16px;
1002
+ height: 16px;
1003
+ border-radius: 50%;
1004
+ background: var(--accent);
1005
+ border: 2px solid var(--bg-surface);
1006
+ box-shadow: 0 0 6px var(--accent-glow);
1007
+ cursor: pointer;
1008
+ }
1009
+ .ks-range-val {
1010
+ font-family: var(--font-mono);
1011
+ font-size: 0.75rem;
1012
+ font-weight: 600;
1013
+ color: var(--text-primary);
1014
+ min-width: 40px;
1015
+ text-align: right;
1016
+ }
1017
+
1018
+ /* Toggle switch */
1019
+ .ks-toggle-row {
1020
+ flex-direction: row;
1021
+ align-items: center;
1022
+ justify-content: space-between;
1023
+ }
1024
+ .ks-switch {
1025
+ position: relative;
1026
+ display: inline-block;
1027
+ width: 40px;
1028
+ height: 22px;
1029
+ flex-shrink: 0;
1030
+ }
1031
+ .ks-switch input {
1032
+ opacity: 0;
1033
+ width: 0;
1034
+ height: 0;
1035
+ }
1036
+ .ks-switch-slider {
1037
+ position: absolute;
1038
+ cursor: pointer;
1039
+ top: 0; left: 0; right: 0; bottom: 0;
1040
+ background: var(--bg-raised);
1041
+ border: 1px solid var(--border);
1042
+ border-radius: 99px;
1043
+ transition: all 0.25s;
1044
+ }
1045
+ .ks-switch-slider::before {
1046
+ content: '';
1047
+ position: absolute;
1048
+ width: 16px;
1049
+ height: 16px;
1050
+ left: 2px;
1051
+ bottom: 2px;
1052
+ background: var(--text-secondary);
1053
+ border-radius: 50%;
1054
+ transition: all 0.25s;
1055
+ }
1056
+ .ks-switch input:checked + .ks-switch-slider {
1057
+ background: var(--accent);
1058
+ border-color: var(--accent);
1059
+ }
1060
+ .ks-switch input:checked + .ks-switch-slider::before {
1061
+ transform: translateX(18px);
1062
+ background: #fff;
1063
+ }
1064
+
1065
+ /* Hints */
1066
+ .ks-hint {
1067
+ font-size: 0.7rem;
1068
+ color: var(--text-muted);
1069
+ margin: 0;
1070
+ line-height: 1.3;
1071
+ }
1072
+ .ks-hint-karaoke {
1073
+ padding-left: 2px;
1074
+ }
1075
+
1076
+ /* --- Responsive tweaks -------------------------------------- */
1077
+ @media (max-width: 640px) {
1078
+ .main { padding: 12px 12px 32px; gap: 12px; }
1079
+ .topbar-inner { padding: 0 14px; }
1080
+ .drop-zone { padding: 24px 16px; }
1081
+ .action-stack { padding: 14px; }
1082
+ .btn-lg { padding: 10px 14px; font-size: 0.85rem; }
1083
+ table { min-width: 580px; }
1084
+ .panel-head { padding: 12px 14px; }
1085
+ .offline-banner { padding: 8px 14px; font-size: 0.78rem; }
1086
+ .mode-btn { padding: 6px 8px; font-size: 0.78rem; }
1087
+ .ks-color-row { flex-direction: column; }
1088
+ .sub-preview-text { font-size: 1rem; }
1089
+ }
templates/index.html ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <script src="static/cvnss4.0-converter.js"></script>
12
+ </head>
13
+ <body>
14
+
15
+ <!-- ===== OFFLINE BANNER ===== -->
16
+ <div class="offline-banner" id="offlineBanner" hidden>
17
+ <svg viewBox="0 0 20 20" fill="currentColor" class="offline-banner-icon">
18
+ <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"/>
19
+ </svg>
20
+ <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>
21
+ <button class="offline-banner-close" id="offlineBannerClose" title="Đóng">&times;</button>
22
+ </div>
23
+
24
+ <!-- ===== TOP NAV ===== -->
25
+ <nav class="topbar">
26
+ <div class="topbar-inner">
27
+ <div class="logo-group">
28
+ <svg class="logo-icon" viewBox="0 0 32 32" fill="none" aria-label="Viet AutoSub">
29
+ <rect x="2" y="6" width="28" height="20" rx="4" stroke="currentColor" stroke-width="2"/>
30
+ <rect x="6" y="20" width="20" height="4" rx="1.5" fill="currentColor" opacity="0.25"/>
31
+ <rect x="8" y="21" width="7" height="2" rx="1" fill="currentColor"/>
32
+ <rect x="17" y="21" width="5" height="2" rx="1" fill="currentColor" opacity="0.6"/>
33
+ <circle cx="16" cy="13" r="4" stroke="currentColor" stroke-width="1.5"/>
34
+ <polygon points="14.5,11.5 18.5,13 14.5,14.5" fill="currentColor"/>
35
+ </svg>
36
+ <span class="logo-text">Viet AutoSub</span>
37
+ </div>
38
+ <div class="topbar-right">
39
+ <span class="badge badge-env" id="badgeEnv">
40
+ <span class="pulse-dot" id="pulseDot"></span>
41
+ <span id="badgeEnvText">Đang kiểm tra...</span>
42
+ </span>
43
+ <span class="badge badge-model" id="modelBadge">whisper-small</span>
44
+ </div>
45
+ </div>
46
+ </nav>
47
+
48
+ <!-- ===== MAIN LAYOUT ===== -->
49
+ <main class="main">
50
+
51
+ <!-- ===== STEP INDICATOR ===== -->
52
+ <div class="steps">
53
+ <div class="step active" data-step="1">
54
+ <div class="step-num">1</div>
55
+ <div class="step-label">Upload video</div>
56
+ </div>
57
+ <div class="step-line"></div>
58
+ <div class="step" data-step="2">
59
+ <div class="step-num">2</div>
60
+ <div class="step-label">Auto sub tiếng Việt</div>
61
+ </div>
62
+ <div class="step-line"></div>
63
+ <div class="step" data-step="3">
64
+ <div class="step-num">3</div>
65
+ <div class="step-label">Chỉnh sửa subtitle</div>
66
+ </div>
67
+ <div class="step-line"></div>
68
+ <div class="step" data-step="4">
69
+ <div class="step-num">4</div>
70
+ <div class="step-label">Xuất SRT / MP4</div>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- ===== UPLOAD ZONE ===== -->
75
+ <section class="panel upload-panel" id="uploadPanel">
76
+ <div class="drop-zone" id="dropZone">
77
+ <svg class="drop-icon" viewBox="0 0 48 48" fill="none">
78
+ <rect x="4" y="8" width="40" height="32" rx="6" stroke="currentColor" stroke-width="2" stroke-dasharray="4 3"/>
79
+ <path d="M24 18v12M18 24l6-6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
80
+ </svg>
81
+ <p class="drop-title">Kéo thả video vào đây</p>
82
+ <p class="drop-hint">hoặc click để chọn file &mdash; MP4, MOV, MKV, AVI, WebM &le; 250 MB</p>
83
+ <input id="videoFile" type="file" accept="video/*" hidden />
84
+ </div>
85
+ <div class="file-info" id="fileInfo" hidden>
86
+ <div class="file-meta">
87
+ <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>
88
+ <span id="fileName">video.mp4</span>
89
+ <span class="file-size" id="fileSize">0 MB</span>
90
+ </div>
91
+ <button class="btn btn-ghost btn-sm" id="btnClearFile">Đổi file</button>
92
+ </div>
93
+ </section>
94
+
95
+ <!-- ===== TWO-COLUMN: VIDEO + CONTROLS ===== -->
96
+ <div class="grid-two">
97
+
98
+ <!-- LEFT: Video Preview -->
99
+ <section class="panel video-panel">
100
+ <div class="panel-head">
101
+ <h2 class="panel-title">
102
+ <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>
103
+ Xem trước
104
+ </h2>
105
+ </div>
106
+ <div class="video-wrap" id="videoWrap">
107
+ <video id="preview" controls playsinline></video>
108
+ <div class="video-placeholder" id="videoPlaceholder">
109
+ <svg viewBox="0 0 64 64" fill="none" class="placeholder-icon">
110
+ <rect x="8" y="14" width="48" height="36" rx="6" stroke="currentColor" stroke-width="2"/>
111
+ <polygon points="26,24 42,32 26,40" fill="currentColor" opacity="0.3"/>
112
+ </svg>
113
+ <span>Chưa có video</span>
114
+ </div>
115
+ <!-- Subtitle Preview Overlay -->
116
+ <div class="sub-preview-overlay" id="subPreviewOverlay" hidden>
117
+ <span class="sub-preview-text" id="subPreviewText">Phụ đề mẫu — Xem trước Karaoke</span>
118
+ </div>
119
+ </div>
120
+ </section>
121
+
122
+ <!-- RIGHT: Action Panel -->
123
+ <section class="panel action-panel">
124
+ <div class="panel-head">
125
+ <h2 class="panel-title">
126
+ <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>
127
+ Điều khiển
128
+ </h2>
129
+ </div>
130
+
131
+ <div class="action-stack">
132
+ <!-- Mode Selector -->
133
+ <div class="mode-selector">
134
+ <label class="mode-label">Chế độ nhận diện</label>
135
+ <div class="mode-toggle" id="modeToggle">
136
+ <button class="mode-btn active" data-mode="music" id="modeMusic">
137
+ <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>
138
+ Lời bài hát
139
+ </button>
140
+ <button class="mode-btn" data-mode="speech" id="modeSpeech">
141
+ <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>
142
+ Giọng nói
143
+ </button>
144
+ </div>
145
+ <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>
146
+ </div>
147
+
148
+ <!-- Transcribe -->
149
+ <button id="btnTranscribe" class="btn btn-primary btn-lg btn-full">
150
+ <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>
151
+ Auto sub tiếng Việt
152
+ </button>
153
+
154
+ <!-- Coverage Stats -->
155
+ <div class="coverage-bar" id="coverageBar" hidden>
156
+ <div class="coverage-header">
157
+ <span class="coverage-label">Phủ sóng timeline</span>
158
+ <span class="coverage-pct" id="coveragePct">0%</span>
159
+ </div>
160
+ <div class="coverage-track">
161
+ <div class="coverage-fill" id="coverageFill"></div>
162
+ </div>
163
+ </div>
164
+
165
+ <!-- Progress Bar -->
166
+ <div class="progress-wrap" id="progressWrap" hidden>
167
+ <div class="progress-bar">
168
+ <div class="progress-fill" id="progressFill"></div>
169
+ </div>
170
+ <span class="progress-text" id="progressText">Đang xử lý...</span>
171
+ </div>
172
+
173
+ <!-- Status -->
174
+ <div id="status" class="status-box status-idle">
175
+ <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>
176
+ <span id="statusText">Sẵn sàng. Hãy upload video để bắt đầu.</span>
177
+ </div>
178
+
179
+ <hr class="divider" />
180
+
181
+ <!-- ===== KARAOKE STYLE ===== -->
182
+ <div class="karaoke-style-panel">
183
+ <h3 class="ks-title">
184
+ <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>
185
+ Kiểu Karaoke
186
+ </h3>
187
+
188
+ <!-- Font Selector -->
189
+ <div class="ks-group">
190
+ <label class="ks-label" for="ksFont">Font phụ đề</label>
191
+ <select class="ks-select" id="ksFont">
192
+ <option value="Bangers" style="font-family:'Bangers'">Bangers</option>
193
+ <option value="Bebas Neue" style="font-family:'Bebas Neue'">Bebas Neue</option>
194
+ <option value="Lobster" style="font-family:'Lobster'">Lobster</option>
195
+ <option value="Permanent Marker" style="font-family:'Permanent Marker'">Permanent Marker</option>
196
+ <option value="Pacifico" style="font-family:'Pacifico'">Pacifico</option>
197
+ <option value="Dancing Script" style="font-family:'Dancing Script'">Dancing Script</option>
198
+ <option value="Playfair Display" style="font-family:'Playfair Display'">Playfair Display</option>
199
+ </select>
200
+ </div>
201
+
202
+ <!-- Color Pickers -->
203
+ <div class="ks-group ks-color-row">
204
+ <div class="ks-color-item">
205
+ <label class="ks-label" for="ksColor">Màu chữ</label>
206
+ <div class="ks-color-wrap">
207
+ <input type="color" id="ksColor" value="#FFFFFF" class="ks-color-input" />
208
+ <span class="ks-color-hex" id="ksColorHex">#FFFFFF</span>
209
+ </div>
210
+ </div>
211
+ <div class="ks-color-item">
212
+ <label class="ks-label" for="ksHighlight">Màu highlight</label>
213
+ <div class="ks-color-wrap">
214
+ <input type="color" id="ksHighlight" value="#FFD700" class="ks-color-input" />
215
+ <span class="ks-color-hex" id="ksHighlightHex">#FFD700</span>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
220
+ <!-- Outline Color -->
221
+ <div class="ks-group ks-color-row">
222
+ <div class="ks-color-item">
223
+ <label class="ks-label" for="ksOutline">Màu viền</label>
224
+ <div class="ks-color-wrap">
225
+ <input type="color" id="ksOutline" value="#000000" class="ks-color-input" />
226
+ <span class="ks-color-hex" id="ksOutlineHex">#000000</span>
227
+ </div>
228
+ </div>
229
+ <div class="ks-color-item">
230
+ <label class="ks-label" for="ksOutlineWidth">Độ dày viền</label>
231
+ <div class="ks-slider-wrap">
232
+ <input type="range" id="ksOutlineWidth" min="0" max="6" step="1" value="2" class="ks-range" />
233
+ <span class="ks-range-val" id="ksOutlineWidthVal">2px</span>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ <!-- Font Size Slider -->
239
+ <div class="ks-group">
240
+ <label class="ks-label" for="ksSize">Cỡ chữ</label>
241
+ <div class="ks-slider-wrap">
242
+ <input type="range" id="ksSize" min="50" max="200" step="5" value="100" class="ks-range" />
243
+ <span class="ks-range-val" id="ksSizeVal">100%</span>
244
+ </div>
245
+ </div>
246
+
247
+ <!-- Vertical Position Slider -->
248
+ <div class="ks-group">
249
+ <label class="ks-label" for="ksPosition">Vị trí dọc</label>
250
+ <div class="ks-slider-wrap">
251
+ <input type="range" id="ksPosition" min="0" max="100" step="1" value="90" class="ks-range" />
252
+ <span class="ks-range-val" id="ksPositionVal">90%</span>
253
+ </div>
254
+ <p class="ks-hint">0% = trên cùng, 100% = dưới cùng</p>
255
+ </div>
256
+
257
+ <!-- Karaoke Word-by-word toggle -->
258
+ <div class="ks-group ks-toggle-row">
259
+ <label class="ks-label" for="ksKaraokeMode">Chế độ Karaoke từng từ</label>
260
+ <label class="ks-switch">
261
+ <input type="checkbox" id="ksKaraokeMode" />
262
+ <span class="ks-switch-slider"></span>
263
+ </label>
264
+ </div>
265
+ <p class="ks-hint ks-hint-karaoke" id="ksKaraokeHint">Bật để highlight từng từ theo nhạc khi xuất MP4.</p>
266
+
267
+ <!-- Preview Button -->
268
+ <button id="btnPreviewStyle" class="btn btn-outline btn-sm btn-full">
269
+ <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>
270
+ Xem trước phụ đề
271
+ </button>
272
+ </div>
273
+
274
+ <hr class="divider" />
275
+
276
+ <!-- Edit Actions -->
277
+ <div class="btn-row">
278
+ <button id="btnAddRow" class="btn btn-outline" disabled>
279
+ <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>
280
+ Thêm dòng
281
+ </button>
282
+ </div>
283
+
284
+ <hr class="divider" />
285
+
286
+ <!-- Export Actions -->
287
+ <div class="export-group">
288
+ <h3 class="export-title">Xuất file</h3>
289
+ <div class="btn-row">
290
+ <button id="btnExportSrt" class="btn btn-outline" disabled>
291
+ <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>
292
+ Xuất .SRT
293
+ </button>
294
+ <button id="btnExportMp4" class="btn btn-success" disabled>
295
+ <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>
296
+ Xuất .MP4 burn sub
297
+ </button>
298
+ </div>
299
+ </div>
300
+
301
+ <!-- Download Links -->
302
+ <div class="download-group" id="downloadGroup" hidden>
303
+ <a id="downloadSrt" class="dl-link dl-srt" href="#" download>
304
+ <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>
305
+ Tải .SRT
306
+ </a>
307
+ <a id="downloadMp4" class="dl-link dl-mp4" href="#" download>
308
+ <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>
309
+ Tải .MP4
310
+ </a>
311
+ </div>
312
+ </div>
313
+ </section>
314
+ </div>
315
+
316
+ <!-- ===== SUBTITLE TABLE ===== -->
317
+ <section class="panel table-panel">
318
+ <div class="panel-head">
319
+ <h2 class="panel-title">
320
+ <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>
321
+ Bảng Subtitle
322
+ </h2>
323
+ <div class="table-meta">
324
+ <button class="btn btn-cvnss btn-sm" id="btnToggleCvnss" title="Chuyển đổi Tiếng Việt ↔ CVNSS4.0">
325
+ <svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
326
+ <span id="cvnssToggleLabel">CVNSS4.0</span>
327
+ </button>
328
+ <span class="seg-count" id="segmentCount">0 dòng</span>
329
+ <button class="btn btn-ghost btn-sm" id="btnCollapseTable" title="Thu gọn">
330
+ <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>
331
+ </button>
332
+ </div>
333
+ </div>
334
+ <div class="table-scroll" id="tableScroll">
335
+ <table>
336
+ <thead>
337
+ <tr>
338
+ <th class="col-idx">#</th>
339
+ <th class="col-time">Bắt đầu</th>
340
+ <th class="col-time">Kết thúc</th>
341
+ <th class="col-text">Nội dung</th>
342
+ <th class="col-act">Thao tác</th>
343
+ </tr>
344
+ </thead>
345
+ <tbody id="subtitleBody">
346
+ <tr class="empty-row">
347
+ <td colspan="5">
348
+ <div class="empty-state">
349
+ <svg viewBox="0 0 48 48" fill="none" class="empty-icon">
350
+ <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/>
351
+ <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
352
+ <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
353
+ <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
354
+ </svg>
355
+ <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p>
356
+ </div>
357
+ </td>
358
+ </tr>
359
+ </tbody>
360
+ </table>
361
+ </div>
362
+ </section>
363
+
364
+ </main>
365
+
366
+ <!-- ===== FOOTER ===== -->
367
+ <footer class="footer">
368
+ <span>Viet AutoSub Editor, 2026 &mdash; Ứng dụng này được sự tài trợ bởi CVNSS4.0 và Thầy Trần Tư Bình. Hoàn toàn miễn phí. Ủng hộ và học CVNSS4.0 <a href="https://chuvnsongsong.com/" target="_blank" rel="noopener noreferrer" class="footer-link">tại đây</a></span>
369
+ </footer>
370
+
371
+ <script src="static/app.js"></script>
372
+ </body>
373
+ </html>