titusgiap commited on
Commit
cc13dca
·
1 Parent(s): 824497d

update tiktok download

Browse files
HANDOFF_SUBTITLE_PREVIEW.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BÀN GIAO: Khớp "xem trước" phụ đề với video burn (hard subtitle)
2
+
3
+ > Tài liệu context để 1 agent khác tiếp tục. Đọc hết trước khi sửa. Repo:
4
+ > `website_content` (HF Space Docker, base `python:3.11-slim`). FE React+Vite,
5
+ > BE FastAPI. Team 2 người, đồng nghiệp `dangdev` hay **force-push main** → cẩn thận.
6
+
7
+ ---
8
+
9
+ ## 1. MỤC TIÊU
10
+ Tool "Dịch/đổi giọng video" → "Chèn phụ đề (hard sub)": trình sửa phụ đề kiểu CapCut.
11
+ Yêu cầu của user: **bản XEM TRƯỚC trong editor phải giống HỆT video BURN ra** (font,
12
+ cỡ chữ, vị trí, ngắt dòng, viền/nền). User rất khó tính về điểm này, đã phàn nàn
13
+ nhiều lần "preview khác burn".
14
+
15
+ ## 2. FILE LIÊN QUAN
16
+ - `frontend/src/components/SubtitleEditor.jsx` — toàn bộ editor (preview CSS + overlay
17
+ ảnh chuẩn + kéo/thả/sửa text + timeline). **Trọng tâm.**
18
+ - `backend/app/services/dubber/subtitle_render.py` — dựng ASS + burn ffmpeg/libass.
19
+ Hàm chính: `build_ass_str` (1 NGUỒN ASS), `build_ass` (ghi file), `burn`,
20
+ `render_frame` (render 1 khung = xem trước chuẩn), `_style_line`, `_bg_event`,
21
+ `_fontsdir_arg`, `probe_size`.
22
+ - `backend/app/api/dub.py` — endpoint: `POST /api/dub/subtitle` (burn job),
23
+ `/subtitle/preview-frame` (render 1 khung → PNG), `/subtitle/plan`, `/subtitle/import`.
24
+ - `frontend/src/api.js` — `subtitleBurn`, `subtitlePreviewFrame`.
25
+ - `frontend/src/theme.css` — `@font-face` cho 6 font nhúng (dùng cho CSS preview).
26
+ - `backend/app/assets/fonts/` — 6 file .ttf nhúng (Git LFS).
27
+ - `Dockerfile` — cài `fontconfig` + copy font vào `/usr/share/fonts` + `fc-cache`.
28
+ - `.github/workflows/deploy-hf-spaces.yml` — push toàn bộ history sang HF Space.
29
+
30
+ ## 3. KIẾN TRÚC HIỆN TẠI (sau nhiều vòng sửa)
31
+ **Nguyên tắc vàng đã chốt:** KHÔNG dùng bộ vẽ thứ 2 để "bắt chước" libass. Muốn
32
+ preview = burn thì phải render preview bằng CHÍNH ffmpeg/libass của bản burn.
33
+
34
+ - `build_ass_str(segments, mode, res)` = NGUỒN ASS DUY NHẤT. Cả `burn` (video cuối)
35
+ lẫn `render_frame` (ảnh xem trước) đều gọi nó → cùng ASS.
36
+ - FE gửi `segPayload()` (mỗi đoạn: start,end,text,style,breaks) — **breaks** đo bằng
37
+ canvas ở FE (`measureBreaks`) để ngắt dòng khớp. CÙNG payload cho burn và preview.
38
+ - **Xem trước khớp 100%** (toggle `exact`, mặc định BẬT): khi video **TẠM DỪNG** +
39
+ không gõ/kéo (`idle`), FE gọi `/subtitle/preview-frame` → server ffmpeg render đúng
40
+ 1 khung tại thời điểm `t` → trả PNG → **đè lên video**. Ảnh đó = ĐÚNG bản burn.
41
+ - Lúc **đang phát / đang chờ ảnh / lỗi / gõ / kéo**: hiện lớp **CSS gần đúng** làm nền
42
+ (không bao giờ để trống chữ). Ảnh chuẩn về thì đè lên (ẩn CSS qua `showExact`).
43
+ - `render_frame`: `-ss {t} -copyts -i video` (seek đầu vào, nhanh ~0.2s) + subtitles
44
+ filter + `-update 1` ra PNG. Đã verify pixel trùng KHÍT cách seek đầu ra, nhanh gấp 3.
45
+
46
+ ## 4. LỊCH SỬ ĐÃ LÀM (commit mới → cũ)
47
+ - `1b53507` fix: **không bao giờ trống chữ** — chỉ ẩn CSS khi ảnh chuẩn ĐÃ hiện
48
+ (`showExact = idle && previewImg`); còn lại luôn hiện CSS nền. `<img onError>` lùi CSS.
49
+ - `6547b89` fix: lúc phát ẩn phụ đề gần-đúng (sau đó `1b53507` chỉnh lại thành luôn
50
+ hiện CSS nền); `render_frame` seek đầu vào +copyts nhanh 3x; debounce 200ms.
51
+ - `4f9859d` fix: **BỎ libass-wasm**, chuyển sang render khung ở server (xem mục 5).
52
+ - `d908e31` feat: (ĐÃ HỦY) thử libass-wasm/SubtitlesOctopus real-time — thất bại.
53
+ - `d4a04dd` fix: **nhúng 6 font mã nguồn mở** (Be Vietnam Pro, Roboto, Open Sans,
54
+ Montserrat, Arimo≈Arial, Tinos≈Times) vào `assets/fonts`, đặt **Git LFS**; ASS
55
+ `Bold=0` (font đã đậm sẵn); Dockerfile cài fontconfig + font hệ thống. `@font-face`
56
+ cho CSS preview. Sửa lỗi gốc: server Linux thiếu font Microsoft → libass fallback
57
+ DejaVu → lệch. (Đã verify trên Debian WSL: libass chọn đúng font nhúng, 0 fallback.)
58
+
59
+ ## 5. NGHIÊN CỨU QUAN TRỌNG (đừng lặp lại sai lầm)
60
+ - **CSS ≠ libass:** trình duyệt vẽ chữ theo luật riêng (wrap, viền, cỡ, nền) khác
61
+ libass → CSS preview KHÔNG BAO GIỜ giống hệt burn. Đừng cố tinh chỉnh CSS cho khớp.
62
+ - **libass-wasm ≠ ffmpeg libass:** cả 2 đều libass NHƯNG **khác phiên bản build** →
63
+ render khác nhau. libass còn có bug [#460](https://github.com/libass/libass/issues/460):
64
+ logic đổi/fallback font **làm RỚT vài chữ/glyph** (phụ thuộc phiên bản). Đây là lý do
65
+ đã BỎ libass-wasm. → Kết luận: chỉ có server ffmpeg mới đảm bảo giống hệt.
66
+ - **Lỗi hình học letterbox (đã sửa):** trước đây overlay tính theo khung thẻ `<video>`
67
+ (width 100% + maxHeight 460 + objectFit contain) → có viền đen 2 bên → overlay lệch
68
+ ngang + hộp sai + wrap khác. ĐÃ SỬA: `fit` = tính cỡ hiển thị = nội dung video THẬT
69
+ (bỏ letterbox), `wrapRef` đúng cỡ đó, overlay ánh xạ đúng toạ độ. (Xác nhận trong
70
+ trình duyệt: video clientW/H = đúng nội dung.)
71
+ - **Deploy HF:** workflow push TOÀN BỘ history sang HF. HF `pre-receive hook` TỪ CHỐI
72
+ binary thô (báo "contains binary files / dùng xet") nhưng CHẤP NHẬN binary dạng LFS.
73
+ → Mọi file nhị phân (.ttf, .wasm...) PHẢI Git LFS. `.gitattributes` đã có `*.ttf *.otf`.
74
+ - **Windows dev bẫy:** render thử trên Windows KHỚP vì có sẵn font MS → phải test môi
75
+ trường GIỐNG SERVER. Cách test Linux libass thật (máy này không có Docker):
76
+ `wsl --install -d Debian` rồi `apt install ffmpeg fontconfig`, render bằng ffmpeg
77
+ `-loglevel verbose`, tìm dòng `fontselect: (Family,...) -> /path.ttf` để biết libass
78
+ nạp đúng font (không fallback DejaVu / không "Glyph not found").
79
+ - **render_frame nhanh:** `-ss {t}` TRƯỚC `-i` (seek keyframe, nhanh) + `-copyts`
80
+ (giữ PTS đúng để phụ đề hiện đúng thời điểm). Đã đo: 0.22s vs 0.62s (seek đầu ra),
81
+ ảnh y hệt.
82
+
83
+ ## 6. BUG ĐANG MỞ (cần agent tiếp theo xử lý)
84
+ ### 6.1. Cỡ chữ preview (CSS) TO hơn burn — user vừa báo
85
+ - Triệu chứng: bản preview lúc toggle TẮT (CSS) chữ to; video burn chữ nhỏ hơn.
86
+ - **Cần verify trước:** bật toggle "Xem trước khớp 100%" + DỪNG → ảnh chuẩn (server)
87
+ có cỡ chữ ĐÚNG bằng burn không? Nếu ĐÚNG → đây chỉ là CSS gần đúng vẽ to hơn libass,
88
+ không phải bug burn; chỉ cần user dùng bản chuẩn. Nếu ảnh chuẩn CŨNG khác burn → bug thật.
89
+ - Preview CSS: `px = st.fontSize * scale()`, `scale = video.clientHeight / videoHeight`
90
+ (SubtitleEditor.jsx ~L120, L276). Burn: ASS `Fontsize = st.fontSize` tại
91
+ `PlayResY = probe_size(video)[1]` (subtitle_render.py `_style_line`, `build_ass_str`).
92
+ Về lý thuyết cùng tỉ lệ `fontSize/videoHeight`. Giả thuyết chênh: (a) CSS font-size
93
+ (em box) vs libass Fontsize khác metric ~10-20%; (b) `scale()` đọc clientHeight sai
94
+ thời điểm; (c) 2 ảnh user gửi là 2 ĐOẠN khác nhau nên không so trực tiếp được.
95
+ - **Đề xuất hướng:** nếu muốn CSS preview (lúc phát/kéo) gần cỡ burn hơn, đo hệ số hiệu
96
+ chỉnh CSS↔libass 1 lần rồi nhân vào `px`. Nhưng ưu tiên: để user xem bằng bản CHUẨN
97
+ (ảnh server) — đó mới là chân lý.
98
+
99
+ ### 6.2. "Mất chữ" (1 từ không hiện) — cần xác nhận lại
100
+ - Trước đây user thấy mất 1 từ. Rất có thể do libass-wasm cũ (bug #460) → **đã bỏ
101
+ libass-wasm** nên có thể HẾT. Cần user xác nhận trên ảnh chuẩn (server ffmpeg).
102
+ - Nếu ảnh chuẩn (server) VẪN mất chữ → là bug dựng ASS thật (ảnh hưởng cả burn). Khi
103
+ đó cần **text chính xác của đoạn** để tái hiện; soi `build_ass_str`/`_esc`/`joinw`/
104
+ `words_for`/`breaks` xem chữ bị nuốt ở đâu.
105
+
106
+ ## 7. CÁCH TEST / CHỖ DỄ SAI
107
+ - Chạy BE local: env conda `tnt` (`D:\miniconda3\envs\tnt\python.exe`); ffmpeg ở
108
+ `D:\Tuyen\outsource\video_cook\ffmpeg-8.1.1-essentials_build\bin`. Build FE: `npx vite build`.
109
+ - Editor cần 1 session dub thật (video + srt) → khó e2e ngoài app. Nhưng có thể test
110
+ riêng `build_ass_str`/`render_frame` bằng python + ffmpeg (xem cách trong git history
111
+ của các commit trên, hoặc tự dựng video lavfi).
112
+ - **Dòng trạng thái dưới nút play** (mới thêm) cho biết: ✅ đang hiện ảnh chuẩn /
113
+ ⏳ đang dựng / ⚠ lỗi server / ▶ đang phát. Dùng để chẩn đoán nhanh khi user báo lỗi.
114
+ - KHÔNG test kết luận trên Windows (có sẵn font MS che lỗi). Dùng WSL Debian.
115
+
116
+ ## 8. TRẠNG THÁI
117
+ - Đã push tới `1b53507` trên `origin/main`. Deploy HF tự chạy khi push.
118
+ - Preview khớp burn (khi bật toggle + dừng) về mặt kiến trúc là ĐÚNG (cùng ffmpeg/
119
+ ASS/font). Việc còn lại chủ yếu là XÁC NHẬN với user trên bản chuẩn + xử 2 bug mục 6.
backend/app/services/downloader/ydl.py CHANGED
@@ -38,15 +38,35 @@ def clean_error(s) -> str:
38
  s = s.replace("ERROR:", "").replace("\n", " ").strip()
39
  return (s[:240] + "…") if len(s) > 240 else s
40
 
 
 
41
 
42
- def normalize_url(url: str) -> str:
43
- """Chuẩn hóa một số dạng link để yt-dlp tải được.
44
-
45
- - Douyin 'jingxuan?modal_id=ID' / '?modal_id=ID' -> '/video/ID'
46
- - Bỏ các tham số rác phía sau cho douyin video.
47
  """
48
  u = (url or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  m = re.search(r"douyin\.com/.*?[?&]modal_id=(\d+)", u)
 
50
  if m:
51
  return f"https://www.douyin.com/video/{m.group(1)}"
52
  m = re.search(r"douyin\.com/video/(\d+)", u)
@@ -179,7 +199,7 @@ def _build_opts(out_dir: str, cookies_file: str | None = None,
179
  # TikTok/Douyin nay BẮT BUỘC giả lập trình duyệt (impersonation) mới qua được
180
  # JS challenge + lấy luồng video; thiếu -> chỉ bóc được metadata/audio, coi như
181
  # "không phải video". Cần curl_cffi (trong requirements); không có -> bỏ qua êm.
182
- if source in ("tiktok", "douyin"):
183
  try:
184
  from yt_dlp.networking.impersonate import ImpersonateTarget
185
  opts["impersonate"] = ImpersonateTarget.from_str("chrome")
@@ -256,9 +276,9 @@ def _attempt(url: str, out_dir: str, opts: dict) -> DownloadResult:
256
  )
257
 
258
 
259
- _UA_MOBILE = ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
260
- "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 "
261
- "Mobile/15E148 Safari/604.1")
262
 
263
 
264
  def _douyin_direct(url: str, out_dir: str) -> DownloadResult:
@@ -452,7 +472,9 @@ def _tiktok_via_tikwm(url: str, out_dir: str) -> DownloadResult:
452
  raise RuntimeError(f"tikwm báo lỗi: {j.get('msg')}")
453
  d = j.get("data") or {}
454
  title = (d.get("title") or "").strip()
455
- m = re.search(r"/(?:video|photo)/(\d+)", url) or re.search(r"(\d{6,})", url)
 
 
456
  vid_id = str(d.get("id") or (m.group(1) if m else "tiktok"))
457
  safe = re.sub(r'[\\/:*?"<>|\n\r\t]+', "_", title)[:60].strip() or vid_id
458
 
@@ -556,14 +578,22 @@ def download_one(url: str, out_dir: str, cookies_file: str | None = None) -> Dow
556
  except Exception as e2:
557
  return DownloadResult(url=url, ok=False,
558
  error=clean_error(f"{source}: {e2}"))
559
- # TikTok: yt-dlp hay chỉ bóc audio -> dự phòng qua tikwm (giống SnapTik):
560
- # lấy video no-watermark, hoặc ghép slideshow ảnh+nhạc thành mp4.
561
- if source == "tiktok":
562
  try:
563
  return _tiktok_via_tikwm(url, out_dir)
564
  except Exception as e_tk:
565
- return DownloadResult(url=url, ok=False,
566
- error=clean_error(f"{first_err} | tikwm: {e_tk}"))
 
 
 
 
 
 
 
 
567
  # YouTube bị chặn (IP datacenter) -> lưới an toàn: thử Invidious/Piped.
568
  if source == "youtube":
569
  try:
 
38
  s = s.replace("ERROR:", "").replace("\n", " ").strip()
39
  return (s[:240] + "…") if len(s) > 240 else s
40
 
41
+ def _resolve_tiktok_url(url: str) -> str:
42
+ """Follow redirect cho link rút gọn TikTok (vt.tiktok.com / vm.tiktok.com / share ads link).
43
 
44
+ Link quảng cáo (Spark Ads) hầu hết ở dạng rút gọn -> không có sẵn '/video/<id>/'
45
+ nên các bước tách ID phía sau sẽ fail nếu không resolve ra link thật trước.
 
 
 
46
  """
47
  u = (url or "").strip()
48
+ if not re.search(r"(vt|vm)\.tiktok\.com/", u):
49
+ return u
50
+ try:
51
+ import requests # type: ignore
52
+ r = requests.head(u, allow_redirects=True, timeout=15,
53
+ headers={"User-Agent": _UA_MOBILE})
54
+ final = r.url or u
55
+ # Một số server chặn HEAD -> thử GET nếu HEAD không redirect được.
56
+ if final == u:
57
+ r = requests.get(u, allow_redirects=True, timeout=15,
58
+ headers={"User-Agent": _UA_MOBILE}, stream=True)
59
+ final = r.url or u
60
+ r.close()
61
+ return final
62
+ except Exception:
63
+ return u # resolve fail -> trả lại url gốc, để bước sau tự lo
64
+
65
+ def normalize_url(url: str) -> str:
66
+ u = (url or "").strip()
67
+ u = _resolve_tiktok_url(u) # <-- MỚI: giải link rút gọn tiktok ads trước
68
  m = re.search(r"douyin\.com/.*?[?&]modal_id=(\d+)", u)
69
+
70
  if m:
71
  return f"https://www.douyin.com/video/{m.group(1)}"
72
  m = re.search(r"douyin\.com/video/(\d+)", u)
 
199
  # TikTok/Douyin nay BẮT BUỘC giả lập trình duyệt (impersonation) mới qua được
200
  # JS challenge + lấy luồng video; thiếu -> chỉ bóc được metadata/audio, coi như
201
  # "không phải video". Cần curl_cffi (trong requirements); không có -> bỏ qua êm.
202
+ if source in ("tiktok", "tiktok_photo", "douyin"):
203
  try:
204
  from yt_dlp.networking.impersonate import ImpersonateTarget
205
  opts["impersonate"] = ImpersonateTarget.from_str("chrome")
 
276
  )
277
 
278
 
279
+ # _UA_MOBILE = ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
280
+ # "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 "
281
+ # "Mobile/15E148 Safari/604.1")
282
 
283
 
284
  def _douyin_direct(url: str, out_dir: str) -> DownloadResult:
 
472
  raise RuntimeError(f"tikwm báo lỗi: {j.get('msg')}")
473
  d = j.get("data") or {}
474
  title = (d.get("title") or "").strip()
475
+ m = (re.search(r"/(?:video|photo)/(\d{6,})", url)
476
+ or re.search(r"item_id=(\d{6,})", url)
477
+ or re.search(r"(\d{15,})", url)) # aweme id thường rất dài, ưu tiên số dài nhất
478
  vid_id = str(d.get("id") or (m.group(1) if m else "tiktok"))
479
  safe = re.sub(r'[\\/:*?"<>|\n\r\t]+', "_", title)[:60].strip() or vid_id
480
 
 
578
  except Exception as e2:
579
  return DownloadResult(url=url, ok=False,
580
  error=clean_error(f"{source}: {e2}"))
581
+ # TikTok (video thường lẫn ảnh/slideshow lẫn ads): yt-dlp hay chỉ bóc audio
582
+ # hoặc fail thẳng với nội dung ads -> dự phòng qua tikwm (giống SnapTik).
583
+ if source in ("tiktok", "tiktok_photo"):
584
  try:
585
  return _tiktok_via_tikwm(url, out_dir)
586
  except Exception as e_tk:
587
+ msg = f"{first_err} | tikwm: {e_tk}"
588
+ # Ads nhắm mục tiêu (targeted ads) đôi khi chỉ hiện trong app cho
589
+ # đúng tài khoản được target -> KHÔNG server nào (kể cả tikwm) có
590
+ # quyền truy cập nội dung này qua API công khai. Báo rõ để khỏi
591
+ # nhầm tưởng là bug.
592
+ if "báo lỗi" in str(e_tk) or "không trả link" in str(e_tk):
593
+ msg += (" | Có thể đây là video quảng cáo (Spark Ads) chỉ hiển "
594
+ "thị trong app cho tài khoản được nhắm mục tiêu -> "
595
+ "không tải được qua API công khai.")
596
+ return DownloadResult(url=url, ok=False, error=clean_error(msg))
597
  # YouTube bị chặn (IP datacenter) -> lưới an toàn: thử Invidious/Piped.
598
  if source == "youtube":
599
  try: