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