STBack23 commited on
Commit
2245a9f
·
verified ·
1 Parent(s): c92431f

Fix llama.cpp pin: clone tag b9553 instead of short commit ref

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +69 -0
  2. .gitignore +6 -0
  3. .pytest_cache/.gitignore +2 -0
  4. .pytest_cache/CACHEDIR.TAG +4 -0
  5. .pytest_cache/README.md +8 -0
  6. .pytest_cache/v/cache/nodeids +19 -0
  7. COLAB.md +56 -0
  8. GemmaSRT.bat +5 -0
  9. GemmaSRT.vbs +4 -0
  10. MTP/README.md +58 -0
  11. MTP/gemma-4-12B-it-BF16-MTP.gguf +3 -0
  12. MTP/gemma-4-12B-it-F16-MTP.gguf +3 -0
  13. MTP/gemma-4-12B-it-Q4_0-MTP.gguf +3 -0
  14. MTP/gemma-4-12B-it-Q8_0-MTP.gguf +3 -0
  15. README.md +531 -92
  16. Tiểu Ái Test.mp4 +3 -0
  17. Tiểu Ái Test.srt +855 -0
  18. __pycache__/diarize_audio.cpython-310.pyc +0 -0
  19. __pycache__/translate_srt.cpython-310.pyc +0 -0
  20. hf-upload/README.md +139 -0
  21. hf-upload/colab/GemmaSRT_Colab.ipynb +633 -0
  22. hf-upload/config.yaml +40 -0
  23. hf-upload/diarize_audio.py +1130 -0
  24. hf-upload/notebook.ipynb +473 -0
  25. hf-upload/requirements-diarize.txt +24 -0
  26. hf-upload/scripts/build_llama_server.sh +38 -0
  27. hf-upload/scripts/download_models.py +72 -0
  28. hf-upload/scripts/ensure_llama_colab.py +441 -0
  29. hf-upload/translate_srt.py +2279 -0
  30. huggingface/README.md +139 -0
  31. huggingface/UPLOAD.md +98 -0
  32. huggingface/config.yaml +40 -0
  33. huggingface/scripts/build_llama_server.sh +38 -0
  34. huggingface/scripts/download_models.py +72 -0
  35. huggingface/scripts/ensure_llama_colab.py +441 -0
  36. huggingface/scripts/prepare_drive.ps1 +52 -0
  37. huggingface/scripts/prepare_upload.ps1 +84 -0
  38. huggingface/scripts/sanitize_nb_token.py +27 -0
  39. launch_ui.ps1 +10 -0
  40. run-gpu.ps1 +16 -0
  41. run-translate.ps1 +30 -0
  42. tools/__pycache__/gemma_srt_app.cpython-310.pyc +0 -0
  43. tools/gemma_srt_app.py +672 -0
  44. tools/gui_data/__init__.py +0 -0
  45. tools/gui_data/__pycache__/__init__.cpython-310.pyc +0 -0
  46. tools/gui_data/__pycache__/app_size_values.cpython-310.pyc +0 -0
  47. tools/gui_data/__pycache__/constants.cpython-310.pyc +0 -0
  48. tools/gui_data/__pycache__/make_assets.cpython-310.pyc +0 -0
  49. tools/gui_data/app_size_values.py +50 -0
  50. tools/gui_data/constants.py +73 -0
.gitattributes CHANGED
@@ -33,3 +33,72 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ MTP/gemma-4-12B-it-BF16-MTP.gguf filter=lfs diff=lfs merge=lfs -text
37
+ MTP/gemma-4-12B-it-F16-MTP.gguf filter=lfs diff=lfs merge=lfs -text
38
+ MTP/gemma-4-12B-it-Q4_0-MTP.gguf filter=lfs diff=lfs merge=lfs -text
39
+ MTP/gemma-4-12B-it-Q8_0-MTP.gguf filter=lfs diff=lfs merge=lfs -text
40
+ Tiểu[[:space:]]Ái[[:space:]]Test.mp4 filter=lfs diff=lfs merge=lfs -text
41
+ tools/llama-cuda/cublas64_12.dll filter=lfs diff=lfs merge=lfs -text
42
+ tools/llama-cuda/cublasLt64_12.dll filter=lfs diff=lfs merge=lfs -text
43
+ tools/llama-cuda/cudart64_12.dll filter=lfs diff=lfs merge=lfs -text
44
+ tools/llama-cuda/ggml-base.dll filter=lfs diff=lfs merge=lfs -text
45
+ tools/llama-cuda/ggml-cpu-alderlake.dll filter=lfs diff=lfs merge=lfs -text
46
+ tools/llama-cuda/ggml-cpu-cannonlake.dll filter=lfs diff=lfs merge=lfs -text
47
+ tools/llama-cuda/ggml-cpu-cascadelake.dll filter=lfs diff=lfs merge=lfs -text
48
+ tools/llama-cuda/ggml-cpu-cooperlake.dll filter=lfs diff=lfs merge=lfs -text
49
+ tools/llama-cuda/ggml-cpu-haswell.dll filter=lfs diff=lfs merge=lfs -text
50
+ tools/llama-cuda/ggml-cpu-icelake.dll filter=lfs diff=lfs merge=lfs -text
51
+ tools/llama-cuda/ggml-cpu-ivybridge.dll filter=lfs diff=lfs merge=lfs -text
52
+ tools/llama-cuda/ggml-cpu-piledriver.dll filter=lfs diff=lfs merge=lfs -text
53
+ tools/llama-cuda/ggml-cpu-sandybridge.dll filter=lfs diff=lfs merge=lfs -text
54
+ tools/llama-cuda/ggml-cpu-sapphirerapids.dll filter=lfs diff=lfs merge=lfs -text
55
+ tools/llama-cuda/ggml-cpu-skylakex.dll filter=lfs diff=lfs merge=lfs -text
56
+ tools/llama-cuda/ggml-cpu-sse42.dll filter=lfs diff=lfs merge=lfs -text
57
+ tools/llama-cuda/ggml-cpu-x64.dll filter=lfs diff=lfs merge=lfs -text
58
+ tools/llama-cuda/ggml-cpu-zen4.dll filter=lfs diff=lfs merge=lfs -text
59
+ tools/llama-cuda/ggml-cuda.dll filter=lfs diff=lfs merge=lfs -text
60
+ tools/llama-cuda/ggml-rpc.dll filter=lfs diff=lfs merge=lfs -text
61
+ tools/llama-cuda/libomp140.x86_64.dll filter=lfs diff=lfs merge=lfs -text
62
+ tools/llama-cuda/llama-bench-impl.dll filter=lfs diff=lfs merge=lfs -text
63
+ tools/llama-cuda/llama-cli-impl.dll filter=lfs diff=lfs merge=lfs -text
64
+ tools/llama-cuda/llama-common.dll filter=lfs diff=lfs merge=lfs -text
65
+ tools/llama-cuda/llama-completion-impl.dll filter=lfs diff=lfs merge=lfs -text
66
+ tools/llama-cuda/llama-imatrix.exe filter=lfs diff=lfs merge=lfs -text
67
+ tools/llama-cuda/llama-perplexity-impl.dll filter=lfs diff=lfs merge=lfs -text
68
+ tools/llama-cuda/llama-quantize-impl.dll filter=lfs diff=lfs merge=lfs -text
69
+ tools/llama-cuda/llama-server-impl.dll filter=lfs diff=lfs merge=lfs -text
70
+ tools/llama-cuda/llama-template-analysis.exe filter=lfs diff=lfs merge=lfs -text
71
+ tools/llama-cuda/llama-tts.exe filter=lfs diff=lfs merge=lfs -text
72
+ tools/llama-cuda/llama.dll filter=lfs diff=lfs merge=lfs -text
73
+ tools/llama-cuda/mtmd.dll filter=lfs diff=lfs merge=lfs -text
74
+ tools/llama-cuda/rpc-server.exe filter=lfs diff=lfs merge=lfs -text
75
+ tools/llama.cpp/ggml-base.dll filter=lfs diff=lfs merge=lfs -text
76
+ tools/llama.cpp/ggml-cpu-alderlake.dll filter=lfs diff=lfs merge=lfs -text
77
+ tools/llama.cpp/ggml-cpu-cannonlake.dll filter=lfs diff=lfs merge=lfs -text
78
+ tools/llama.cpp/ggml-cpu-cascadelake.dll filter=lfs diff=lfs merge=lfs -text
79
+ tools/llama.cpp/ggml-cpu-cooperlake.dll filter=lfs diff=lfs merge=lfs -text
80
+ tools/llama.cpp/ggml-cpu-haswell.dll filter=lfs diff=lfs merge=lfs -text
81
+ tools/llama.cpp/ggml-cpu-icelake.dll filter=lfs diff=lfs merge=lfs -text
82
+ tools/llama.cpp/ggml-cpu-ivybridge.dll filter=lfs diff=lfs merge=lfs -text
83
+ tools/llama.cpp/ggml-cpu-piledriver.dll filter=lfs diff=lfs merge=lfs -text
84
+ tools/llama.cpp/ggml-cpu-sandybridge.dll filter=lfs diff=lfs merge=lfs -text
85
+ tools/llama.cpp/ggml-cpu-sapphirerapids.dll filter=lfs diff=lfs merge=lfs -text
86
+ tools/llama.cpp/ggml-cpu-skylakex.dll filter=lfs diff=lfs merge=lfs -text
87
+ tools/llama.cpp/ggml-cpu-sse42.dll filter=lfs diff=lfs merge=lfs -text
88
+ tools/llama.cpp/ggml-cpu-x64.dll filter=lfs diff=lfs merge=lfs -text
89
+ tools/llama.cpp/ggml-cpu-zen4.dll filter=lfs diff=lfs merge=lfs -text
90
+ tools/llama.cpp/ggml-rpc.dll filter=lfs diff=lfs merge=lfs -text
91
+ tools/llama.cpp/libomp140.x86_64.dll filter=lfs diff=lfs merge=lfs -text
92
+ tools/llama.cpp/llama-bench-impl.dll filter=lfs diff=lfs merge=lfs -text
93
+ tools/llama.cpp/llama-cli-impl.dll filter=lfs diff=lfs merge=lfs -text
94
+ tools/llama.cpp/llama-common.dll filter=lfs diff=lfs merge=lfs -text
95
+ tools/llama.cpp/llama-completion-impl.dll filter=lfs diff=lfs merge=lfs -text
96
+ tools/llama.cpp/llama-imatrix.exe filter=lfs diff=lfs merge=lfs -text
97
+ tools/llama.cpp/llama-perplexity-impl.dll filter=lfs diff=lfs merge=lfs -text
98
+ tools/llama.cpp/llama-quantize-impl.dll filter=lfs diff=lfs merge=lfs -text
99
+ tools/llama.cpp/llama-server-impl.dll filter=lfs diff=lfs merge=lfs -text
100
+ tools/llama.cpp/llama-template-analysis.exe filter=lfs diff=lfs merge=lfs -text
101
+ tools/llama.cpp/llama-tts.exe filter=lfs diff=lfs merge=lfs -text
102
+ tools/llama.cpp/llama.dll filter=lfs diff=lfs merge=lfs -text
103
+ tools/llama.cpp/mtmd.dll filter=lfs diff=lfs merge=lfs -text
104
+ tools/llama.cpp/rpc-server.exe filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Local-only Hugging Face token (never commit/upload)
2
+ hf_token.local
3
+ hf_token.txt
4
+
5
+ # GGUF weights (tải vào models/ — xem huggingface/scripts/download_models.py)
6
+ models/*.gguf
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_diarize_gender.py::test_age_group_empty_when_gender_unknown",
3
+ "tests/test_diarize_gender.py::test_age_group_unreliable_when_high_std",
4
+ "tests/test_diarize_gender.py::test_age_group_unreliable_when_too_few_windows",
5
+ "tests/test_diarize_gender.py::test_age_group_when_stable",
6
+ "tests/test_diarize_gender.py::test_reconcile_agreement_returns_gender",
7
+ "tests/test_diarize_gender.py::test_reconcile_ambiguous_pitch_needs_higher_conf",
8
+ "tests/test_diarize_gender.py::test_reconcile_child_passthrough",
9
+ "tests/test_diarize_gender.py::test_reconcile_f0_contradicts_female",
10
+ "tests/test_diarize_gender.py::test_reconcile_low_model_conf_returns_unknown",
11
+ "tests/test_diarize_gender.py::test_reconcile_model_pitch_conflict_returns_unknown",
12
+ "tests/test_relationship_lock.py::test_dominant_pair_picks_most_dialogue",
13
+ "tests/test_relationship_lock.py::test_ff_direct_anh_em_corrected_to_chi",
14
+ "tests/test_relationship_lock.py::test_kinship_source_beats_romance_lock",
15
+ "tests/test_relationship_lock.py::test_no_anh_em_lock_for_two_females",
16
+ "tests/test_relationship_lock.py::test_romance_lock_not_overwritten_by_translation_mom_child",
17
+ "tests/test_relationship_lock.py::test_same_age_locks_anh_em_despite_middle_aged_female",
18
+ "tests/test_relationship_lock.py::test_vision_lock_rejected_for_same_gender_pair"
19
+ ]
COLAB.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Colab + Hugging Face
2
+
3
+ Hướng dẫn chạy trên **Google Colab (L4)**.
4
+
5
+ ## Repo HF
6
+
7
+ **https://huggingface.co/STBack23/gemma-srt-translate**
8
+
9
+ ## Cấu trúc Google Drive
10
+
11
+ ```
12
+ Drive của tôi/
13
+ └── Gemma/ ← folder chính
14
+ ├── Cache/ ← model GGUF + llama-server (tự cache)
15
+ └── Phim/ ← đặt video + SRT vào đây
16
+ ├── lam-chanh-anh.mp4
17
+ ├── lam-chanh-anh.srt
18
+ └── lam-chanh-anh.vi.srt ← file ra (sau khi dịch)
19
+ ```
20
+
21
+ ## Mở Colab
22
+
23
+ | Cách | Link |
24
+ |------|------|
25
+ | **Open in Colab** | [colab/GemmaSRT_Colab.ipynb](https://huggingface.co/STBack23/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb) |
26
+ | **Shortcut `/colab`** | [huggingface.co/.../colab](https://huggingface.co/STBack23/gemma-srt-translate/colab) |
27
+ | **Bản mới nhất (nếu HF chưa kịp cập nhật)** | Colab → File → Upload notebook → `colab/GemmaSRT_Colab.ipynb` trên máy |
28
+
29
+ > HF repo đôi khi chậm hơn file local. Nếu vẫn thấy `lam-chanh-anh` / `JOBS` cũ → upload file local hoặc chạy `hf auth login` rồi push lại (xem `huggingface/UPLOAD.md`).
30
+
31
+ ## Chạy
32
+
33
+ 1. Upload phim + SRT vào **`Gemma/Phim`**
34
+ 2. Sửa **`JOBS`** — chỉ cần tên file (không đuôi):
35
+
36
+ ```python
37
+ JOBS = [
38
+ job("lam-chanh-anh"),
39
+ job("phim2"),
40
+ ]
41
+ ```
42
+
43
+ 3. Runtime → GPU **L4** → **Run all**
44
+
45
+ ### Tự ngắt sau khi xong (1 hoặc nhiều phim)
46
+
47
+ ```python
48
+ DOWNLOAD_RESULTS = False # file đã trên Drive → không cần tải về
49
+ AUTO_UNMOUNT_DRIVE = True
50
+ AUTO_DISCONNECT_RUNTIME = True
51
+ ```
52
+
53
+ ## Local + Colab song song
54
+
55
+ - **Local**: `GemmaSRT.bat` → phim A
56
+ - **Colab**: notebook → phim trong `Gemma/Phim`
GemmaSRT.bat ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ @echo off
2
+ rem Khoi chay Gemma SRT Translate (khong hien console)
3
+ set "ROOT=%~dp0"
4
+ cd /d "%ROOT%"
5
+ start "" pythonw "%ROOT%tools\gemma_srt_app.py"
GemmaSRT.vbs ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ' Chay GemmaSRT.bat o che do an (khong nhay cua so console)
2
+ Set sh = CreateObject("WScript.Shell")
3
+ dir = Left(WScript.ScriptFullName, InStrRev(WScript.ScriptFullName, "\"))
4
+ sh.Run """" & dir & "GemmaSRT.bat""", 0, False
MTP/README.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gemma 4 12B QAT MTP drafter
2
+
3
+ Multi-Token Prediction (MTP) drafter for `unsloth/gemma-4-12B-it-qat-GGUF`. It runs as a speculative draft model that shares the target's KV cache and speeds up text generation. The drafter is the Gemma 4 12B QAT assistant and pairs with the QAT model unchanged.
4
+
5
+ Verified on a single B200 against the `gemma-4-12B-it-qat-UD-Q4_K_XL.gguf` target with `-hf` auto-discovery: draft acceptance 0.51.
6
+
7
+ MTP was merged into llama.cpp on 2026-06-07 (PR ggml-org/llama.cpp#23398). You need a llama.cpp build **>= b9549** (`llama-server --version`). Older builds cannot load these (arch `gemma4-assistant`).
8
+
9
+ ## Files
10
+
11
+ The recommended drafter is a **smart Q4_0**: the native 4-bit QAT drafter (about 97% of its weights are byte-exact on the int4 grid), near-lossless versus higher precision while roughly half the size. It sits at the repo root as `mtp-gemma-4-12B-it.gguf` so `-hf` finds it automatically, and the same file plus higher-precision drafters are in `MTP/`:
12
+
13
+ - `mtp-gemma-4-12B-it.gguf` (repo root, smart Q4_0, recommended; used by `-hf`)
14
+ - `MTP/gemma-4-12B-it-Q4_0-MTP.gguf` (same smart Q4_0)
15
+ - `MTP/gemma-4-12B-it-Q8_0-MTP.gguf`
16
+ - `MTP/gemma-4-12B-it-BF16-MTP.gguf`
17
+ - `MTP/gemma-4-12B-it-F16-MTP.gguf`
18
+
19
+ ## Build llama.cpp
20
+
21
+ ```bash
22
+ git clone https://github.com/ggml-org/llama.cpp
23
+ cd llama.cpp
24
+
25
+ # CUDA build. Set the arch for your GPU: 89 (RTX 4090), 90 (H100), 100 (B200).
26
+ cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=90
27
+ cmake --build build --config Release -j --target llama-server
28
+ ```
29
+
30
+ ## Run, the easy way
31
+
32
+ A recent llama.cpp finds the drafter automatically from the root `mtp-` file, so `-hf` is all you need. No `--model-draft`.
33
+
34
+ ```bash
35
+ ./build/bin/llama-server \
36
+ -hf unsloth/gemma-4-12B-it-qat-GGUF:UD-Q4_K_XL \
37
+ --spec-type draft-mtp --spec-draft-n-max 4 \
38
+ -ngl 999 -fa on
39
+ ```
40
+
41
+ If your build is too old to auto-discover the sibling, use the explicit form below.
42
+
43
+ ## Run with an explicit drafter
44
+
45
+ Use this to choose a precision or point at a local file.
46
+
47
+ ```bash
48
+ hf download unsloth/gemma-4-12B-it-qat-GGUF gemma-4-12B-it-qat-UD-Q4_K_XL.gguf --local-dir .
49
+ hf download unsloth/gemma-4-12B-it-qat-GGUF MTP/gemma-4-12B-it-Q8_0-MTP.gguf --local-dir .
50
+
51
+ ./build/bin/llama-server \
52
+ -m gemma-4-12B-it-qat-UD-Q4_K_XL.gguf \
53
+ --model-draft MTP/gemma-4-12B-it-Q8_0-MTP.gguf \
54
+ --spec-type draft-mtp --spec-draft-n-max 4 \
55
+ -ngl 999 -fa on
56
+ ```
57
+
58
+ Multi GPU: add `--spec-draft-device CUDA0 -sm layer`. The drafter pairs with any quant of the 12B QAT model. Quantized KV cache works.
MTP/gemma-4-12B-it-BF16-MTP.gguf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:320b1dfe0dc0af8380c1a74b4992177841a940da92015b9c672e2590b20c17de
3
+ size 861537344
MTP/gemma-4-12B-it-F16-MTP.gguf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bb7a268dc6518c37b71095601140ef930630f16fa3b9be91311a625013235601
3
+ size 861537344
MTP/gemma-4-12B-it-Q4_0-MTP.gguf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c50c91c35f04903815b2e8930cbb8c8c5bee0e1aa00748c30a7b8ff05d2310b4
3
+ size 253707328
MTP/gemma-4-12B-it-Q8_0-MTP.gguf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3c9460bc9f6143f232c9447bc772bf6f6db974b57129298381cf0cb702bd586b
3
+ size 465126464
README.md CHANGED
@@ -1,139 +1,578 @@
1
  ---
 
2
  license: apache-2.0
3
- language:
4
- - vi
5
- - en
6
  tags:
7
- - subtitles
8
- - srt
9
- - translation
10
- - gemma4
11
- - vision
12
- - llama.cpp
13
- pipeline_tag: translation
14
- library_name: gemma-srt-translate
15
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- # Gemma SRT Translate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- Dịch sửa phụ đề SRT bằng **Gemma 4 12B vision** (llama-server + MTP).
20
- Repo này chứa **script + notebook Colab**; model GGUF tải từ [`unsloth/gemma-4-12B-it-qat-GGUF`](https://huggingface.co/unsloth/gemma-4-12B-it-qat-GGUF).
21
 
22
- ## Cấu trúc repo
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  ```
25
- YOUR_USERNAME/gemma-srt-translate/
26
- ├── README.md ← file này
27
- ├── config.yaml ← cấu hình mặc định
28
- ├── translate_srt.py ← pipeline chính
29
- ├── diarize_audio.py ← Pass 0: phân tích giọng nói (tùy chọn)
30
- ├── requirements-diarize.txt ← dependency cho Pass 0
31
- ├── colab/
32
- │ └── GemmaSRT_Colab.ipynb ← chạy trên Google Colab (L4 24GB)
33
- └── scripts/
34
- ├── download_models.py ← tải GGUF từ Unsloth
35
- └── build_llama_server.sh ← build llama-server Linux (Colab)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  ```
37
 
38
- ## Chạy trên Google Colab (khuyến nghị L4 24GB)
39
 
40
- Repo **Private** **không** mở được bằng nút Open in Colab trên HF (lỗi 401).
41
 
42
- **Cách mở:** tải [colab/GemmaSRT_Colab.ipynb](https://huggingface.co/STBack23/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb) → Colab **File → Upload notebook** → dán **`HF_TOKEN`** vào cell cấu hình → lưu copy lên Drive `Gemma/` để dùng lại.
 
43
 
44
- Repo này nằm trên **Hugging Face**, không phải GitHub — link `colab.research.google.com/github/...` sẽ lỗi 404.
45
 
46
- **Cách mở (chọn một):**
47
 
48
- 1. **Nút Open in Colab trên HF** (khuyến nghị):
49
- [colab/GemmaSRT_Colab.ipynb](https://huggingface.co/STBack23/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb) → bấm **Open in Colab**
50
 
51
- 2. **Shortcut HF `/colab`**:
52
- [https://huggingface.co/STBack23/gemma-srt-translate/colab](https://huggingface.co/STBack23/gemma-srt-translate/colab)
53
 
54
- 3. **Colab → File → Upload notebook** → tải file `.ipynb` từ HF về rồi upload
55
 
56
- Sau khi mở notebook:
 
 
 
 
 
 
 
57
 
58
- 1. Runtime **Change runtime type** GPU **L4** (hoặc T4/A100)
59
- 2. Chạy tuần tự các cell (lần đầu ~15–20 phút: build llama-server + tải model)
60
- 3. Upload video + SRT hoặc trỏ vào Google Drive
61
- 4. Tải file `*.vi.srt` về máy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://huggingface.co/STBack23/gemma-srt-translate/colab)
64
 
65
- ## Chạy trên máy local (Windows)
 
66
 
67
- Repo HF **không** chứa `llama-server.exe` dùng bản Windows trong project gốc:
68
 
69
- ```powershell
70
- .\run-translate.ps1 -Video "phim.mp4" -InputSrt "phim.srt" -OutputSrt "phim.vi.srt"
71
- ```
72
 
73
- Hoặc mở GUI: `GemmaSRT.bat`
74
 
75
- ## Tải model thủ công
76
 
77
- ```bash
78
- pip install huggingface_hub
79
- python scripts/download_models.py --dest ./models
 
 
 
 
 
 
 
 
 
80
  ```
81
 
82
- File tải về (~12–15 GB):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- | File | Mục đích |
85
- |------|----------|
86
- | `gemma-4-12B-it-qat-UD-Q4_K_XL.gguf` | Model chính |
87
- | `mmproj-F16.gguf` | Vision projector |
88
- | `mtp-gemma-4-12B-it.gguf` | MTP draft (tăng tốc) |
89
 
90
- ## Pipeline
91
 
92
- 1. Parse SRT → gom scene
93
- 2. **Pass 0** *(tùy chọn)*: phân tích giọng nói (pyannote) → "ai nói câu nào" + giới tính/độ tuổi → gán vào từng cue để xưng hô nhất quán
94
- 3. **Pass 1**: cắt frame (ffmpeg) → OCR/sửa SRT gốc (vision)
95
- 4. **Pass 2**: dịch theo ngữ cảnh + xưng hô
96
- 5. Ghi SRT dịch, SRT đã sửa, báo cáo JSON
97
 
98
- ### Pass 0 phân tích giọng nói (diarization)
99
 
100
- Bật bằng `--diarize` (CLI) hoặc `ENABLE_DIARIZE = True` (Colab). Cần thêm:
101
 
102
- ```bash
103
- pip install -r requirements-diarize.txt
 
 
 
 
 
 
 
 
 
 
 
 
104
  ```
105
 
106
- Token HF phải bấm **Agree** điều kiện các model gated:
107
- [community-1](https://hf.co/pyannote/speaker-diarization-community-1) ·
108
- [3.1](https://hf.co/pyannote/speaker-diarization-3.1) ·
109
- [segmentation-3.0](https://hf.co/pyannote/segmentation-3.0).
110
- `--gender-method model` dùng [audeering wav2vec2 age/gender](https://hf.co/audeering/wav2vec2-large-robust-24-ft-age-gender) (~1GB).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
- ## VRAM
113
 
114
- | GPU | 1 phim | 2 phim song song |
115
- |-----|--------|------------------|
116
- | RTX 4060 Ti 16GB | ✅ | ❌ |
117
- | Colab L4 24GB | ✅ | ❌ (dùng local + Colab = 2 phim độc lập) |
118
 
119
- ## Cấu hình
120
 
121
- Chỉnh `config.yaml` hoặc tham số CLI:
122
 
123
- ```bash
124
- python translate_srt.py \
125
- --video phim.mp4 \
126
- --input-srt phim.srt \
127
- --output-srt phim.vi.srt \
128
- --target-lang Vietnamese \
129
- --skip-correction # bỏ pass OCR (nhanh hơn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  ```
131
 
132
- ## Model gốc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
- - [unsloth/gemma-4-12B-it-qat-GGUF](https://huggingface.co/unsloth/gemma-4-12B-it-qat-GGUF)
135
- - [Gemma 4 license](https://ai.google.dev/gemma/docs/gemma_4_license)
 
 
136
 
137
- ## Upload repo lên Hugging Face
138
 
139
- Xem [UPLOAD.md](./UPLOAD.md) (trong project gốc: `huggingface/UPLOAD.md`).
 
1
  ---
2
+ library_name: transformers
3
  license: apache-2.0
4
+ license_link: https://ai.google.dev/gemma/docs/gemma_4_license
5
+ pipeline_tag: any-to-any
6
+ base_model: google/gemma-4-12B-it-qat-q4_0-unquantized
7
  tags:
8
+ - gemma4
9
+ - unsloth
10
+ - gemma
11
+ - google
 
 
 
 
12
  ---
13
+ # Read our How to [Run Gemma 4 QAT Guide!](https://unsloth.ai/docs/models/gemma-4/qat)
14
+ <div>
15
+ <p style="margin: 0 0 0px 0; margin-top: 0px;">
16
+ <em>See <a href="https://unsloth.ai/docs/basics/unsloth-dynamic-v2.0-gguf">Unsloth Dynamic 2.0 GGUFs</a> for our quantization benchmarks.</em>
17
+ </p>
18
+ <div style="display: flex; gap: 5px; align-items: center; margin-bottom: 0px;">
19
+ <a href="https://github.com/unslothai/unsloth/">
20
+ <img src="https://github.com/unslothai/unsloth/raw/main/images/unsloth%20new%20logo.png" width="133">
21
+ </a>
22
+ <a href="https://discord.gg/unsloth">
23
+ <img src="https://github.com/unslothai/unsloth/raw/main/images/Discord%20button.png" width="173">
24
+ </a>
25
+ <a href="https://unsloth.ai/docs/models/gemma-4">
26
+ <img src="https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/documentation%20green%20button.png" width="143">
27
+ </a>
28
+ </div>
29
+
30
+ <ul style="margin: 0;">
31
+ <li><b>Jun 9 Update:</b> Added MTP support. See our <a href="https://unsloth.ai/docs/models/mtp">MTP Guide</a>.</li>
32
+ <li>Gemma 4 can now be run and fine-tuned in <a href="https://unsloth.ai/docs/new/studio">Unsloth Studio</a>. <a href="https://unsloth.ai/docs/models/gemma-4">Read our guide</a>.</li>
33
+ <li>See all versions of Gemma 4 QAT (GGUF, 16-bit etc.) <a href="https://huggingface.co/collections/unsloth/gemma-4-qat">in our collection</a>.</li>
34
+ <li>Example of Gemma 4 E4B (4-bit GGUF) running in Unsloth Studio with tool-calling:</li>
35
+ </ul>
36
+ </div>
37
+ <img width="600" alt="gemma 4 in unsloth studio" src="https://cdn-uploads.huggingface.co/production/uploads/62ecdc18b72a69615d6bd857/BTmv3pr-QQ8ZMxxX3Ofnf.gif" />
38
+
39
+
40
+ ![image](https://cdn-uploads.huggingface.co/production/uploads/62ecdc18b72a69615d6bd857/Ni3-kGKIbStvDqTTFCYFU.png)
41
+
42
+ ## Run with MTP (speculative decoding)
43
+
44
+ This model ships a Multi-Token Prediction drafter at the repo root (`mtp-gemma-4-12B-it.gguf`, a near-lossless smart Q4_0). A recent llama.cpp auto-discovers it from `-hf`, so you do not pass `--model-draft`:
45
 
46
+ ```bash
47
+ ./build/bin/llama-server \
48
+ -hf unsloth/gemma-4-12B-it-qat-GGUF:UD-Q4_K_XL \
49
+ --spec-type draft-mtp --spec-draft-n-max 4 \
50
+ -ngl 999 -fa on
51
+ ```
52
+
53
+ The drafter shares the target's KV cache and does not change the output (the target verifies every drafted token). See the `MTP/` folder for the other precisions and explicit usage.
54
+
55
+
56
+ <div align="center">
57
+ <img src=https://ai.google.dev/gemma/images/gemma4_banner.png>
58
+ </div>
59
+
60
+
61
+ <p align="center">
62
+ <a href="https://huggingface.co/collections/google/gemma-4" target="_blank">Hugging Face</a> |
63
+ <a href="https://github.com/google-gemma" target="_blank">GitHub</a> |
64
+ <a href="https://blog.google/innovation-and-ai/technology/developers-tools/introducing-gemma-4-12B/" target="_blank">Launch Blog</a> |
65
+ <a href="https://ai.google.dev/gemma/docs/core" target="_blank">Documentation</a>
66
+ <br>
67
+ <b>License</b>: <a href="https://ai.google.dev/gemma/docs/gemma_4_license" target="_blank">Apache 2.0</a> | <b>Authors</b>: <a href="https://deepmind.google/models/gemma/" target="_blank">Google DeepMind</a>
68
+ </p>
69
+
70
+ > [!Note]
71
+ > This model card is for the new versions of the Gemma 4 family optimized with Quantization-Aware Training (QAT), which allows preserving similar quality to bfloat16 while dramatically reducing the memory requirements to load the model.
72
+ > Four versions of the QAT checkpoints are available:
73
+ > * **Unquantized QAT checkpoints** (Q4_0): Half-precision weights extracted from the QAT pipeline, ideal for custom downstream compilation and research. Available for Gemma 4 E2B, E4B, 12B, 26B A4B, and 31B, and their drafter models.
74
+ > * **GGUF** (Q4_0): Ready-to-deploy formats for broad ecosystem compatibility. Available for Gemma 4 E2B, E4B, 12B, 26B A4B, and 31B.
75
+ > * **Mobile-optimized** (wNa8o8): A custom schema engineered explicitly for mobile hardware efficiency. It features targeted 2-bit decoding layers, optimized KV caches, and static activations to maximize VRAM savings. Available for Gemma 4 E2B and E4B.
76
+ > * **Compressed Tensors** (w4a16): QAT checkpoints serialized in the compressed-tensors format for native, optimized inference with vLLM. Available for Gemma 4 E2B, E4B, 12B, and 31B.
77
+
78
+ Gemma is a family of open models built by Google DeepMind. Gemma 4 models are multimodal, handling text and image input (with audio supported on E2B, E4B, and 12B) and generating text output. This release includes open-weights models in both pre-trained and instruction-tuned variants. Gemma 4 features a context window of up to 256K tokens and maintains multilingual support in over 140 languages.
79
+
80
+ Featuring both Dense and Mixture-of-Experts (MoE) architectures, Gemma 4 is well-suited for tasks like text generation, coding, and reasoning. The models are available in five distinct sizes: **E2B**, **E4B**, **12B**, **26B A4B**, and **31B**. Their diverse sizes make them deployable in environments ranging from high-end phones to laptops and servers, democratizing access to state-of-the-art AI.
81
+
82
+ Gemma 4 introduces key **capability and architectural advancements**:
83
+
84
+ * **Reasoning** – All models in the family are designed as highly capable reasoners, with configurable thinking modes.
85
+
86
+ * **Extended Multimodalities** – Processes Text, Image with variable aspect ratio and resolution support (all models), Video, and Audio (featured natively on the E2B, E4B, and 12B models).
87
+
88
+ * **Diverse & Efficient Architectures** – Offers Dense and Mixture-of-Experts (MoE) variants of different sizes for scalable deployment.
89
+
90
+ * **Optimized for On-Device** – Smaller models are specifically designed for efficient local execution on laptops and mobile devices.
91
+
92
+ * **Increased Context Window** – The small models feature a 128K context window, while the medium models support 256K.
93
+
94
+ * **Enhanced Coding & Agentic Capabilities** – Achieves notable improvements in coding benchmarks alongside native function-calling support, powering highly capable autonomous agents.
95
+
96
+ * **Native System Prompt Support** – Gemma 4 introduces native support for the `system` role, enabling more structured and controllable conversations.
97
+
98
+ ## **Models Overview**
99
+
100
+ Gemma 4 models are designed to deliver frontier-level performance at each size, targeting deployment scenarios from mobile and edge devices (E2B, E4B) to consumer GPUs and workstations (12B, 26B A4B, 31B). They are well-suited for reasoning, agentic workflows, coding, and multimodal understanding.
101
+
102
+ The models employ a hybrid attention mechanism that interleaves local sliding window attention with full global attention, ensuring the final layer is always global. This hybrid design delivers the processing speed and low memory footprint of a lightweight model without sacrificing the deep awareness required for complex, long-context tasks. To optimize memory for long contexts, global layers feature unified Keys and Values, and apply Proportional RoPE (p-RoPE).
103
+
104
+ ### Dense Models
105
+
106
+ | Property | E2B | E4B | 12B Unified | 31B Dense |
107
+ | :---- | :---- | :---- | :---- | :---- |
108
+ | **Total Parameters** | 2.3B effective <br> (5.1B with embeddings) | 4.5B effective <br> (8B with embeddings) | 11.95B | 30.7B |
109
+ | **Layers** | 35 | 42 | 48 | 60 |
110
+ | **Sliding Window** | 512 tokens | 512 tokens | 1024 tokens | 1024 tokens |
111
+ | **Context Length** | 128K tokens | 128K tokens | 256K tokens | 256K tokens |
112
+ | **Vocabulary Size** | 262K | 262K | 262K | 262K |
113
+ | **Supported Modalities** | Text, Image, Audio | Text, Image, Audio | Text, Image, Audio | Text, Image |
114
+ | **Vision Encoder Parameters** | *~150M* | *~150M* | - | *~550M* |
115
+ | **Audio Encoder Parameters** | *~300M* | *~300M* | - | No Audio |
116
+
117
+ The "E" in E2B and E4B stands for "effective" parameters. The smaller models incorporate Per-Layer Embeddings (PLE) to maximize parameter efficiency in on-device deployments. Rather than adding more layers or parameters to the model, PLE gives each decoder layer its own small embedding for every token. These embedding tables are large but are only used for quick lookups, which is why the effective parameter count is much smaller than the total.
118
+
119
+ The "Unified" in Gemma 4 12B Unified refers to its encoder-free architecture. Other Gemma 4 models use dedicated encoders to process multimodal data before passing it to the LLM. Gemma 4 12B eliminates these encoders entirely, projecting raw image patches and audio waveforms directly into the LLM's embedding space through lightweight linear layers. This unified approach means all modalities flow straight into a single decoder-only transformer, reducing multimodal latency and allowing the entire model to be fine-tuned in one pass.
120
+
121
+ ### Mixture-of-Experts (MoE) Model
122
+
123
+ | Property | 26B A4B MoE |
124
+ | :---- | :---- |
125
+ | **Total Parameters** | 25.2B |
126
+ | **Active Parameters** | 3.8B |
127
+ | **Layers** | 30 |
128
+ | **Sliding Window** | 1024 tokens |
129
+ | **Context Length** | 256K tokens |
130
+ | **Vocabulary Size** | 262K |
131
+ | **Expert Count** | 8 active / 128 total and 1 shared |
132
+ | **Supported Modalities** | Text, Image |
133
+ | **Vision Encoder Parameters** | *~550M* |
134
 
135
+ The "A" in 26B A4B stands for "active parameters" in contrast to the total number of parameters the model contains. By only activating a 4B subset of parameters during inference, the Mixture-of-Experts model runs much faster than its 26B total might suggest. This makes it an excellent choice for fast inference compared to the dense 31B model since it runs almost as fast as a 4B-parameter model.
 
136
 
137
+ ## **Benchmark Results**
138
 
139
+ These models were evaluated against a large collection of different datasets and metrics to cover different aspects of text generation. Evaluation results marked in the table are for instruction-tuned models.
140
+
141
+ | | Gemma 4 31B | Gemma 4 26B A4B | Gemma 4 12B Unified | Gemma 4 E4B | Gemma 4 E2B | Gemma 3 27B (no think) |
142
+ | :---- | :---- | :---- | :---- | :---- | :---- | :---- |
143
+ | MMLU Pro | 85.2% | 82.6% | 77.2% | 69.4% | 60.0% | 67.6% |
144
+ | AIME 2026 no tools | 89.2% | 88.3% | 77.5% | 42.5% | 37.5% | 20.8% |
145
+ | LiveCodeBench v6 | 80.0% | 77.1% | 72.0% | 52.0% | 44.0% | 29.1% |
146
+ | Codeforces ELO | 2150 | 1718 | 1659 | 940 | 633 | 110 |
147
+ | GPQA Diamond | 84.3% | 82.3% | 78.8% | 58.6% | 43.4% | 42.4% |
148
+ | Tau2 (average over 3) | 76.9% | 68.2% | 69.0% | 42.2% | 24.5% | 16.2% |
149
+ | HLE no tools | 19.5% | 8.7% | 5.2% | - | - | - |
150
+ | HLE with search | 26.5% | 17.2% | - | - | - | - |
151
+ | BigBench Extra Hard | 74.4% | 64.8% | 53.0% | 33.1% | 21.9% | 19.3% |
152
+ | MMMLU | 88.4% | 86.3% | 83.4% | 76.6% | 67.4% | 70.7% |
153
+ | **Vision** | | | | | | |
154
+ | MMMU Pro | 76.9% | 73.8% | 69.1% | 52.6% | 44.2% | 49.7% |
155
+ | OmniDocBench 1.5 (average edit distance, lower is better) | 0.131 | 0.149 | 0.164 | 0.181 | 0.290 | 0.365 |
156
+ | MATH-Vision | 85.6% | 82.4% | 79.7% | 59.5% | 52.4% | 46.0% |
157
+ | MedXPertQA MM | 61.3% | 58.1% | 48.7% | 28.7% | 23.5% | - |
158
+ | **Audio** | | | | | | |
159
+ | CoVoST | - | - | 38.5<sup>*</sup> | 35.54 | 33.47 | - |
160
+ | FLEURS (lower is better) | - | - | 0.069<sup>*</sup> | 0.08 | 0.09 | - |
161
+ | **Long Context** | | | | | | |
162
+ | MRCR v2 8 needle 128k (average) | 66.4% | 44.1% | 43.4% | 25.4% | 19.1% | 13.5% |
163
+
164
+ <sup>*</sup>Excluding Chinese language.
165
+
166
+ ## **Core Capabilities**
167
+
168
+ Gemma 4 models handle a broad range of tasks across text, vision, and audio. Key capabilities include:
169
+
170
+ * **Thinking** – Built-in reasoning mode that lets the model think step-by-step before answering.
171
+ * **Long Context** – Context windows of up to 128K tokens (E2B/E4B) and 256K tokens (12B, 26B A4B/31B).
172
+ * **Image Understanding** – Object detection, Document/PDF parsing, screen and UI understanding, chart comprehension, OCR (including multilingual), handwriting recognition, and pointing. Images can be processed at variable aspect ratios and resolutions.
173
+ * **Video Understanding** – Analyze video by processing sequences of frames.
174
+ * **Interleaved Multimodal Input** – Freely mix text and images in any order within a single prompt.
175
+ * **Function Calling** – Native support for structured tool use, enabling agentic workflows.
176
+ * **Coding** – Code generation, completion, and correction.
177
+ * **Multilingual** – Out-of-the-box support for 35+ languages, pre-trained on 140+ languages.
178
+ * **Audio** (E2B, E4B, and 12B only) – Automatic speech recognition (ASR) and speech-to-translated-text translation across multiple languages.
179
+
180
+
181
+ ## Getting Started
182
+
183
+ You can use all Gemma 4 models with the latest version of Transformers. To get started, install the necessary dependencies in your environment:
184
+
185
+ `pip install -U transformers torch accelerate`
186
+
187
+ Once you have everything installed, you can proceed to load the model with the code below:
188
+
189
+ ```python
190
+ from transformers import AutoProcessor, AutoModelForMultimodalLM
191
+
192
+ MODEL_ID = "google/gemma-4-12B-it"
193
+
194
+ # Load model
195
+ processor = AutoProcessor.from_pretrained(MODEL_ID)
196
+ model = AutoModelForMultimodalLM.from_pretrained(
197
+ MODEL_ID,
198
+ dtype="auto",
199
+ device_map="auto"
200
+ )
201
  ```
202
+
203
+ Once the model is loaded, you can start generating output:
204
+
205
+ ```python
206
+ # Prompt
207
+ messages = [
208
+ {"role": "system", "content": "You are a helpful assistant."},
209
+ {"role": "user", "content": "Write a short joke about saving RAM."},
210
+ ]
211
+
212
+ # Process input
213
+ inputs = processor.apply_chat_template(
214
+ messages,
215
+ tokenize=True,
216
+ return_dict=True,
217
+ return_tensors="pt",
218
+ add_generation_prompt=True,
219
+ enable_thinking=False
220
+ ).to(model.device)
221
+ input_len = inputs["input_ids"].shape[-1]
222
+
223
+ # Generate output
224
+ outputs = model.generate(**inputs, max_new_tokens=1024)
225
+ response = processor.decode(outputs[0][input_len:], skip_special_tokens=False)
226
+
227
+ # Parse output
228
+ processor.parse_response(response)
229
  ```
230
 
231
+ To enable reasoning, set `enable_thinking=True` and the `parse_response` function will take care of parsing the thinking output.
232
 
233
+ Below, you will also find snippets for processing audio (E2B, E4B, 12B only), images, and video alongside text:
234
 
235
+ <details>
236
+ <summary>Code for processing Audio</summary>
237
 
238
+ Make sure to install the following packages:
239
 
240
+ `pip install -U transformers torch torchvision librosa accelerate`
241
 
242
+ You can then load the model with the code below:
 
243
 
244
+ ```python
245
+ from transformers import AutoProcessor, AutoModelForMultimodalLM
246
 
247
+ MODEL_ID = "google/gemma-4-12B-it"
248
 
249
+ # Load model
250
+ processor = AutoProcessor.from_pretrained(MODEL_ID)
251
+ model = AutoModelForMultimodalLM.from_pretrained(
252
+ MODEL_ID,
253
+ dtype="auto",
254
+ device_map="auto"
255
+ )
256
+ ```
257
 
258
+ Once the model is loaded, you can start generating output by directly referencing the audio URL in the prompt:
259
+
260
+
261
+ ```python
262
+ # Prompt - add audio after text
263
+ messages = [
264
+ {
265
+ "role": "user",
266
+ "content": [
267
+ {"type": "text", "text": "Transcribe the following speech segment in its original language. Follow these specific instructions for formatting the answer:\n* Only output the transcription, with no newlines.\n* When transcribing numbers, write the digits, i.e. write 1.7 and not one point seven, and write 3 instead of three."},
268
+ {"type": "audio", "audio": "https://raw.githubusercontent.com/google-gemma/cookbook/refs/heads/main/apps/sample-data/journal1.wav"},
269
+ ]
270
+ }
271
+ ]
272
+
273
+ # Process input
274
+ inputs = processor.apply_chat_template(
275
+ messages,
276
+ tokenize=True,
277
+ return_dict=True,
278
+ return_tensors="pt",
279
+ add_generation_prompt=True,
280
+ ).to(model.device)
281
+ input_len = inputs["input_ids"].shape[-1]
282
+
283
+ # Generate output
284
+ outputs = model.generate(**inputs, max_new_tokens=512)
285
+ response = processor.decode(outputs[0][input_len:], skip_special_tokens=False)
286
+
287
+ # Parse output
288
+ processor.parse_response(response)
289
+ ```
290
 
291
+ </details>
292
 
293
+ <details>
294
+ <summary>Code for processing Images</summary>
295
 
296
+ Make sure to install the following packages:
297
 
 
 
 
298
 
299
+ `pip install -U transformers torch torchvision accelerate`
300
 
301
+ You can then load the model with the code below:
302
 
303
+ ```python
304
+ from transformers import AutoProcessor, AutoModelForMultimodalLM
305
+
306
+ MODEL_ID = "google/gemma-4-12B-it"
307
+
308
+ # Load model
309
+ processor = AutoProcessor.from_pretrained(MODEL_ID)
310
+ model = AutoModelForMultimodalLM.from_pretrained(
311
+ MODEL_ID,
312
+ dtype="auto",
313
+ device_map="auto"
314
+ )
315
  ```
316
 
317
+ Once the model is loaded, you can start generating output by directly referencing the image URL in the prompt:
318
+
319
+
320
+ ```python
321
+ # Prompt - add image before text
322
+ messages = [
323
+ {
324
+ "role": "user", "content": [
325
+ {"type": "image", "url": "https://raw.githubusercontent.com/google-gemma/cookbook/refs/heads/main/apps/sample-data/GoldenGate.png"},
326
+ {"type": "text", "text": "What is shown in this image?"}
327
+ ]
328
+ }
329
+ ]
330
+
331
+ # Process input
332
+ inputs = processor.apply_chat_template(
333
+ messages,
334
+ tokenize=True,
335
+ return_dict=True,
336
+ return_tensors="pt",
337
+ add_generation_prompt=True,
338
+ ).to(model.device)
339
+ input_len = inputs["input_ids"].shape[-1]
340
+
341
+ # Generate output
342
+ outputs = model.generate(**inputs, max_new_tokens=512)
343
+ response = processor.decode(outputs[0][input_len:], skip_special_tokens=False)
344
+
345
+ # Parse output
346
+ processor.parse_response(response)
347
+ ```
348
 
349
+ </details>
 
 
 
 
350
 
 
351
 
352
+ <details>
353
+ <summary>Code for processing Videos</summary>
 
 
 
354
 
355
+ Make sure to install the following packages:
356
 
357
+ `pip install -U transformers torch torchvision librosa accelerate`
358
 
359
+ You can then load the model with the code below:
360
+
361
+ ```python
362
+ from transformers import AutoProcessor, AutoModelForMultimodalLM
363
+
364
+ MODEL_ID = "google/gemma-4-12B-it"
365
+
366
+ # Load model
367
+ processor = AutoProcessor.from_pretrained(MODEL_ID)
368
+ model = AutoModelForMultimodalLM.from_pretrained(
369
+ MODEL_ID,
370
+ dtype="auto",
371
+ device_map="auto"
372
+ )
373
  ```
374
 
375
+ Once the model is loaded, you can start generating output by directly referencing the video URL in the prompt:
376
+
377
+
378
+ ```python
379
+ # Prompt - add video before text
380
+ messages = [
381
+ {
382
+ 'role': 'user',
383
+ 'content': [
384
+ {"type": "video", "video": "https://github.com/bebechien/gemma/raw/refs/heads/main/videos/ForBiggerBlazes.mp4"},
385
+ {'type': 'text', 'text': 'Describe this video.'}
386
+ ]
387
+ }
388
+ ]
389
+
390
+ # Process input
391
+ inputs = processor.apply_chat_template(
392
+ messages,
393
+ tokenize=True,
394
+ return_dict=True,
395
+ return_tensors="pt",
396
+ add_generation_prompt=True,
397
+ ).to(model.device)
398
+ input_len = inputs["input_ids"].shape[-1]
399
+
400
+ # Generate output
401
+ outputs = model.generate(**inputs, max_new_tokens=512)
402
+ response = processor.decode(outputs[0][input_len:], skip_special_tokens=False)
403
+
404
+ # Parse output
405
+ processor.parse_response(response)
406
+ ```
407
 
408
+ </details>
409
 
 
 
 
 
410
 
 
411
 
412
+ ## **Best Practices**
413
 
414
+ For the best performance, use these configurations and best practices:
415
+
416
+ ### 1. Sampling Parameters
417
+
418
+ Use the following standardized sampling configuration across all use cases:
419
+
420
+ * `temperature=1.0`
421
+ * `top_p=0.95`
422
+ * `top_k=64`
423
+
424
+ ### 2. Thinking Mode Configuration
425
+
426
+ Compared to Gemma 3, the models use standard `system`, `assistant`, and `user` roles. To properly manage the thinking process, use the following control tokens:
427
+
428
+ * **Trigger Thinking:** Thinking is enabled by including the `<|think|>` token at the start of the system prompt. To disable thinking, remove the token.
429
+ * **Standard Generation:** When thinking is enabled, the model will output its internal reasoning followed by the final answer using this structure:
430
+ `<|channel>thought\n`**[Internal reasoning]**`<channel|>`
431
+ * **Disabled Thinking Behavior:** For all models except for the E2B and E4B variants, if thinking is disabled, the model will still generate the tags but with an empty thought block:
432
+ `<|channel>thought\n<channel|>`**[Final answer]**
433
+
434
+ > [!Note]
435
+ > Note that many libraries like Transformers and llama.cpp handle the complexities of the chat template for you.
436
+
437
+ ### 3. Multi-Turn Conversations
438
+
439
+ * **No Thinking Content in History**: In multi-turn conversations, the historical model output should only include the final response. Thoughts from previous model turns must *not be added* before the next user turn begins.
440
+
441
+ ### 4. Modality order
442
+
443
+ For optimal performance with multimodal inputs, place:
444
+
445
+ * Image content **before** the text in your prompt.
446
+ * Audio content **after** the text in your prompt.
447
+
448
+ ### 5. Variable Image Resolution
449
+
450
+ Aside from variable aspect ratios, Gemma 4 supports variable image resolution through a configurable visual token budget, which controls how many tokens are used to represent an image. A higher token budget preserves more visual detail at the cost of additional compute, while a lower budget enables faster inference for tasks that don't require fine-grained understanding.
451
+
452
+ * The supported token budgets are: **70**, **140**, **280**, **560**, and **1120**.
453
+ * Use *lower budgets* for classification, captioning, or video understanding, where faster inference and processing many frames outweigh fine-grained detail.
454
+ * Use *higher budgets* for tasks like OCR, document parsing, or reading small text.
455
+
456
+ ### 6. Audio
457
+
458
+ Use the following prompt structures for audio processing:
459
+
460
+ * **Audio Speech Recognition (ASR)**
461
+
462
+ ```text
463
+ Transcribe the following speech segment in {LANGUAGE} into {LANGUAGE} text.
464
+
465
+ Follow these specific instructions for formatting the answer:
466
+ * Only output the transcription, with no newlines.
467
+ * When transcribing numbers, write the digits, i.e. write 1.7 and not one point seven, and write 3 instead of three.
468
+ ```
469
+
470
+ * **Automatic Speech Translation (AST)**
471
+
472
+ ```text
473
+ Transcribe the following speech segment in {SOURCE_LANGUAGE}, then translate it into {TARGET_LANGUAGE}.
474
+ When formatting the answer, first output the transcription in {SOURCE_LANGUAGE}, then one newline, then output the string '{TARGET_LANGUAGE}: ', then the translation in {TARGET_LANGUAGE}.
475
  ```
476
 
477
+ ### 7. Audio and Video Length
478
+
479
+ All models support image inputs and can process videos as frames whereas the E2B, E4B, and 12B models also support audio inputs. Audio supports a maximum length of 30 seconds. Video supports a maximum of 60 seconds assuming the images are processed at one frame per second.
480
+
481
+ ## **Model Data**
482
+
483
+ Data used for model training and how the data was processed.
484
+
485
+ ### **Training Dataset**
486
+
487
+ Our pre-training dataset is a large-scale, diverse collection of data encompassing a wide range of domains and modalities, which includes web documents, code, images, audio, with a cutoff date of January 2025. Here are the key components:
488
+
489
+ * **Web Documents**: A diverse collection of web text ensures the model is exposed to a broad range of linguistic styles, topics, and vocabulary. The training dataset includes content in over 140 languages.
490
+ * **Code**: Exposing the model to code helps it to learn the syntax and patterns of programming languages, which improves its ability to generate code and understand code-related questions.
491
+ * **Mathematics**: Training on mathematical text helps the model learn logical reasoning, symbolic representation, and to address mathematical queries.
492
+ * **Images**: A wide range of images enables the model to perform image analysis and visual data extraction tasks.
493
+
494
+ The combination of these diverse data sources is crucial for training a powerful multimodal model that can handle a wide variety of different tasks and data formats.
495
+
496
+ ### **Data Preprocessing**
497
+
498
+ Here are the key data cleaning and filtering methods applied to the training data:
499
+
500
+ * **CSAM Filtering**: Rigorous CSAM (Child Sexual Abuse Material) filtering was applied at multiple stages in the data preparation process to ensure the exclusion of harmful and illegal content.
501
+ * **Sensitive Data Filtering**: As part of making Gemma pre-trained models safe and reliable, automated techniques were used to filter out certain personal information and other sensitive data from training sets.
502
+ * **Additional methods**: Filtering based on content quality and safety in line with [our policies](https://ai.google/static/documents/ai-responsibility-update-published-february-2025.pdf).
503
+
504
+ ## **Ethics and Safety**
505
+
506
+ As open models become central to enterprise infrastructure, provenance and security are paramount. Developed by Google DeepMind, Gemma 4 undergoes the same rigorous safety evaluations as our proprietary Gemini models.
507
+
508
+ ### **Evaluation Approach**
509
+
510
+ Gemma 4 models were developed in partnership with internal safety and responsible AI teams. A range of automated as well as human evaluations were conducted to help improve model safety. These evaluations align with [Google’s AI principles](https://ai.google/principles/), as well as safety policies, which aim to prevent our generative AI models from generating harmful content, including:
511
+
512
+ * Content related to child sexual abuse material and exploitation
513
+ * Dangerous content (e.g., promoting suicide, or instructing in activities that could cause real-world harm)
514
+ * Sexually explicit content
515
+ * Hate speech (e.g., dehumanizing members of protected groups)
516
+ * Harassment (e.g., encouraging violence against people)
517
+
518
+ ### **Evaluation Results**
519
+
520
+ For all areas of safety testing, we saw major improvements in all categories of content safety relative to previous Gemma models. Overall, Gemma 4 models significantly outperform Gemma 3 and 3n models in improving safety, while keeping unjustified refusals low. All testing was conducted without safety filters to evaluate the model capabilities and behaviors. For both text-to-text and image-to-text, and across all model sizes, the model produced minimal policy violations, and showed significant improvements over previous Gemma models' performance.
521
+
522
+ ## **Usage and Limitations**
523
+
524
+ These models have certain limitations that users should be aware of.
525
+
526
+ ### **Intended Usage**
527
+
528
+ Multimodal models (capable of processing vision, language, and/or audio) have a wide range of applications across various industries and domains. The following list of potential uses is not comprehensive. The purpose of this list is to provide contextual information about the possible use-cases that the model creators considered as part of model training and development.
529
+
530
+ * **Content Creation and Communication**
531
+ * **Text Generation**: These models can be used to generate creative text formats such as poems, scripts, code, marketing copy, and email drafts.
532
+ * **Chatbots and Conversational AI**: Power conversational interfaces for customer service, virtual assistants, or interactive applications.
533
+ * **Text Summarization**: Generate concise summaries of a text corpus, research papers, or reports.
534
+ * **Image Data Extraction**: These models can be used to extract, interpret, and summarize visual data for text communications.
535
+ * **Audio Processing and Interaction**: The E2B, E4B, and 12B models can analyze and interpret audio inputs, enabling voice-driven interactions and transcriptions.
536
+ * **Research and Education**
537
+ * **Natural Language Processing (NLP) and VLM Research**: These models can serve as a foundation for researchers to experiment with VLM and NLP techniques, develop algorithms, and contribute to the advancement of the field.
538
+ * **Language Learning Tools**: Support interactive language learning experiences, aiding in grammar correction or providing writing practice.
539
+ * **Knowledge Exploration**: Assist researchers in exploring large bodies of text by generating summaries or answering questions about specific topics.
540
+
541
+ ### **Limitations**
542
+
543
+ * **Training Data**
544
+ * The quality and diversity of the training data significantly influence the model's capabilities. Biases or gaps in the training data can lead to limitations in the model's responses.
545
+ * The scope of the training dataset determines the subject areas the model can handle effectively.
546
+ * **Context and Task Complexity**
547
+ * Models perform well on tasks that can be framed with clear prompts and instructions. Open-ended or highly complex tasks might be challenging.
548
+ * A model's performance can be influenced by the amount of context provided (longer context generally leads to better outputs, up to a certain point).
549
+ * **Language Ambiguity and Nuance**
550
+ * Natural language is inherently complex. Models might struggle to grasp subtle nuances, sarcasm, or figurative language.
551
+ * **Factual Accuracy**
552
+ * Models generate responses based on information they learned from their training datasets, but they are not knowledge bases. They may generate incorrect or outdated factual statements.
553
+ * **Common Sense**
554
+ * Models rely on statistical patterns in language. They might lack the ability to apply common sense reasoning in certain situations.
555
+
556
+ ### **Ethical Considerations and Risks**
557
+
558
+ The development of vision-language models (VLMs) raises several ethical concerns. In creating an open model, we have carefully considered the following:
559
+
560
+ * **Bias and Fairness**
561
+ * VLMs trained on large-scale, real-world text and image data can reflect socio-cultural biases embedded in the training material. Gemma 4 models underwent careful scrutiny, input data pre-processing, and post-training evaluations as reported in this card to help mitigate the risk of these biases.
562
+ * **Misinformation and Misuse**
563
+ * VLMs can be misused to generate text that is false, misleading, or harmful.
564
+ * Guidelines are provided for responsible use with the model, see the [Responsible Generative AI Toolkit](https://ai.google.dev/responsible).
565
+ * **Transparency and Accountability**
566
+ * This model card summarizes details on the models' architecture, capabilities, limitations, and evaluation processes.
567
+ * A responsibly developed open model offers the opportunity to share innovation by making VLM technology accessible to developers and researchers across the AI ecosystem.
568
+
569
+ **Risks identified and mitigations**:
570
 
571
+ * **Generation of harmful content**: Mechanisms and guidelines for content safety are essential. Developers are encouraged to exercise caution and implement appropriate content safety safeguards based on their specific product policies and application use cases.
572
+ * **Misuse for malicious purposes**: Technical limitations and developer and end-user education can help mitigate against malicious applications of VLMs. Educational resources and reporting mechanisms for users to flag misuse are provided.
573
+ * **Privacy violations**: Models were trained on data filtered for removal of certain personal information and other sensitive data. Developers are encouraged to adhere to privacy regulations with privacy-preserving techniques.
574
+ * **Perpetuation of biases**: It's encouraged to perform continuous monitoring (using evaluation metrics, human review) and the exploration of de-biasing techniques during model training, fine-tuning, and other use cases.
575
 
576
+ ### **Benefits**
577
 
578
+ At the time of release, this family of models provides high-performance open vision-language model implementations designed from the ground up for responsible AI development compared to similarly sized models.
Tiểu Ái Test.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:af7d511664225d358167af3bb5163731de3838010f65e0814d10277ad5bc3efa
3
+ size 659294678
Tiểu Ái Test.srt ADDED
@@ -0,0 +1,855 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 1
2
+ 00:00:10,261 --> 00:00:11,861
3
+ 不愧是小提琴女王
4
+
5
+ 2
6
+ 00:00:12,021 --> 00:00:13,301
7
+ 从相貌到家世
8
+
9
+ 3
10
+ 00:00:13,301 --> 00:00:14,581
11
+ 哪样都很出色
12
+
13
+ 4
14
+ 00:00:15,061 --> 00:00:16,821
15
+ 听说她是天赋型选手
16
+
17
+ 5
18
+ 00:00:17,141 --> 00:00:18,741
19
+ 天生对音乐很敏锐
20
+
21
+ 6
22
+ 00:00:18,901 --> 00:00:20,341
23
+ 学了很多乐器
24
+
25
+ 7
26
+ 00:00:20,341 --> 00:00:22,101
27
+ 却独独钟爱小提琴
28
+
29
+ 8
30
+ 00:00:48,501 --> 00:00:49,621
31
+ 02
32
+
33
+ 9
34
+ 00:00:56,821 --> 00:00:57,461
35
+ 柒柒
36
+
37
+ 10
38
+ 00:00:57,941 --> 00:00:58,741
39
+ 在忙吗
40
+
41
+ 11
42
+ 00:01:00,021 --> 00:01:00,661
43
+ 顾总
44
+
45
+ 12
46
+ 00:01:00,661 --> 00:01:03,061
47
+ 时老师在接受采访呢
48
+
49
+ 13
50
+ 00:01:03,061 --> 00:01:04,501
51
+ 需要我把手机给她吗
52
+
53
+ 14
54
+ 00:01:06,421 --> 00:01:07,221
55
+ 不用了
56
+
57
+ 15
58
+ 00:01:39,061 --> 00:01:39,701
59
+ 谢谢大家
60
+
61
+ 16
62
+ 00:01:39,861 --> 00:01:40,661
63
+ 那我们今天的采访
64
+
65
+ 17
66
+ 00:01:40,661 --> 00:01:41,461
67
+ 就到此结束了
68
+
69
+ 18
70
+ 00:01:41,461 --> 00:01:41,961
71
+ 谢谢
72
+
73
+ 19
74
+ 00:01:42,901 --> 00:01:44,181
75
+ 时小姐等一下
76
+
77
+ 20
78
+ 00:01:44,181 --> 00:01:45,781
79
+ 麻烦再回答一个问题好吗
80
+
81
+ 21
82
+ 00:01:45,781 --> 00:01:46,581
83
+ 不好意思
84
+
85
+ 22
86
+ 00:01:57,141 --> 00:01:58,741
87
+ 时老师
88
+
89
+ 23
90
+ 00:01:58,741 --> 00:01:59,381
91
+ 你真不愧是
92
+
93
+ 24
94
+ 00:01:59,381 --> 00:02:00,981
95
+ 名誉世界的小提琴女王
96
+
97
+ 25
98
+ 00:02:00,981 --> 00:02:02,581
99
+ 简直是太完美了
100
+
101
+ 26
102
+ 00:02:04,821 --> 00:02:05,461
103
+ 呆甜
104
+
105
+ 27
106
+ 00:02:06,101 --> 00:02:07,381
107
+ 擦擦你的口水
108
+
109
+ 28
110
+ 00:02:12,821 --> 00:02:13,941
111
+ 时老师
112
+
113
+ 29
114
+ 00:02:13,941 --> 00:02:15,381
115
+ 刚才顾总来电话了
116
+
117
+ 30
118
+ 00:02:17,781 --> 00:02:18,421
119
+ 知道了
120
+
121
+ 31
122
+ 00:02:18,901 --> 00:02:20,661
123
+ 顾家和时家门当户对
124
+
125
+ 32
126
+ 00:02:20,661 --> 00:02:21,461
127
+ 当年
128
+
129
+ 33
130
+ 00:02:21,461 --> 00:02:23,221
131
+ 时家融资出现问题了
132
+
133
+ 34
134
+ 00:02:23,221 --> 00:02:23,861
135
+ 是顾总出资
136
+
137
+ 35
138
+ 00:02:23,861 --> 00:02:25,461
139
+ 帮时家渡过难关的
140
+
141
+ 36
142
+ 00:02:25,621 --> 00:02:26,421
143
+ 婚后
144
+
145
+ 37
146
+ 00:02:26,421 --> 00:02:27,701
147
+ 两个人有孩子了
148
+
149
+ 38
150
+ 00:02:27,701 --> 00:02:28,981
151
+ 顾总对时老师
152
+
153
+ 39
154
+ 00:02:28,981 --> 00:02:29,941
155
+ 事无巨细
156
+
157
+ 40
158
+ 00:02:29,941 --> 00:02:30,901
159
+ 宠爱有加
160
+
161
+ 41
162
+ 00:02:35,701 --> 00:02:36,501
163
+ 时老师
164
+
165
+ 42
166
+ 00:02:36,821 --> 00:02:38,741
167
+ 那个小少爷生日快到了
168
+
169
+ 43
170
+ 00:02:38,741 --> 00:02:41,301
171
+ 需不需要准备点礼物寄回国
172
+
173
+ 44
174
+ 00:02:46,101 --> 00:02:46,741
175
+ 不需要
176
+
177
+ 45
178
+ 00:02:50,901 --> 00:02:51,701
179
+
180
+
181
+ 46
182
+ 00:02:55,145 --> 00:02:57,225
183
+ 就知道会是这样的答案
184
+
185
+ 47
186
+ 00:02:57,545 --> 00:02:58,665
187
+ 因为小少爷身主
188
+
189
+ 48
190
+ 00:02:58,665 --> 00:02:59,945
191
+ 流着顾总的血
192
+
193
+ 49
194
+ 00:03:00,425 --> 00:03:01,545
195
+ 所以时老师
196
+
197
+ 50
198
+ 00:03:01,545 --> 00:03:02,985
199
+ 连带小少爷
200
+
201
+ 51
202
+ 00:03:03,465 --> 00:03:04,585
203
+ 也很冷淡
204
+
205
+ 52
206
+ 00:03:06,185 --> 00:03:06,985
207
+ 等下个月回国了
208
+
209
+ 53
210
+ 00:03:06,985 --> 00:03:08,105
211
+ 咱们好好庆祝一下
212
+
213
+ 54
214
+ 00:03:12,265 --> 00:03:12,905
215
+
216
+
217
+ 55
218
+ 00:03:13,065 --> 00:03:13,865
219
+ 怎么了
220
+
221
+ 56
222
+ 00:03:15,145 --> 00:03:16,425
223
+ 脸色这么难看
224
+
225
+ 57
226
+ 00:03:18,185 --> 00:03:18,985
227
+ 这么烫
228
+
229
+ 58
230
+ 00:03:19,305 --> 00:03:20,425
231
+ 你先带柒柒去医院
232
+
233
+ 59
234
+ 00:03:20,425 --> 00:03:21,225
235
+ 这边我来收尾
236
+
237
+ 60
238
+ 00:03:21,225 --> 00:03:21,725
239
+
240
+
241
+ 61
242
+ 00:03:24,425 --> 00:03:25,865
243
+ 时老师你没事吧
244
+
245
+ 62
246
+ 00:03:28,745 --> 00:03:29,705
247
+ 这位患者
248
+
249
+ 63
250
+ 00:03:29,705 --> 00:03:31,305
251
+ 是急性肠胃炎导致的高烧
252
+
253
+ 64
254
+ 00:03:32,265 --> 00:03:32,765
255
+ 注意观察
256
+
257
+ 65
258
+ 00:03:36,425 --> 00:03:37,225
259
+ 小宝乖
260
+
261
+ 66
262
+ 00:03:38,665 --> 00:03:39,625
263
+ 明天做完手术
264
+
265
+ 67
266
+ 00:03:39,625 --> 00:03:40,905
267
+ 我们就可以回家了
268
+
269
+ 68
270
+ 00:03:41,385 --> 00:03:43,305
271
+ 到时候妈妈再带你去游乐场
272
+
273
+ 69
274
+ 00:03:43,305 --> 00:03:44,585
275
+ 补过生日好不好
276
+
277
+ 70
278
+ 00:03:44,905 --> 00:03:47,305
279
+ 妈妈那我们说好了
280
+
281
+ 71
282
+ 00:03:47,465 --> 00:03:49,225
283
+ 明天做完手术后
284
+
285
+ 72
286
+ 00:03:49,225 --> 00:03:51,305
287
+ 就带我去过生日
288
+
289
+ 73
290
+ 00:03:52,745 --> 00:03:53,225
291
+
292
+
293
+ 74
294
+ 00:03:53,225 --> 00:03:54,185
295
+ 妈妈答应你
296
+
297
+ 75
298
+ 00:03:59,785 --> 00:04:00,265
299
+
300
+
301
+ 76
302
+ 00:04:00,265 --> 00:04:01,065
303
+ 真可怜啊
304
+
305
+ 77
306
+ 00:04:01,545 --> 00:04:02,985
307
+ 手术费还差十万元呢
308
+
309
+ 78
310
+ 00:04:03,945 --> 00:04:05,545
311
+ 明天就算筹齐善款
312
+
313
+ 79
314
+ 00:04:05,545 --> 00:04:06,345
315
+ 可以做手术
316
+
317
+ 80
318
+ 00:04:06,665 --> 00:04:08,425
319
+ 手术成功的可能也挺小
320
+
321
+ 81
322
+ 00:04:09,705 --> 00:04:11,785
323
+ 孩子才四岁就患白血病了
324
+
325
+ 82
326
+ 00:04:11,785 --> 00:04:12,745
327
+ 真是可怜
328
+
329
+ 83
330
+ 00:04:13,225 --> 00:04:14,665
331
+ 但他妈妈真的爱他儿子
332
+
333
+ 84
334
+ 00:04:14,665 --> 00:04:16,745
335
+ 每天给他陪床做饭
336
+
337
+ 85
338
+ 00:04:17,225 --> 00:04:17,705
339
+ 妈妈
340
+
341
+ 86
342
+ 00:04:17,705 --> 00:04:18,205
343
+ 别哭了
344
+
345
+ 87
346
+ 00:04:24,905 --> 00:04:25,545
347
+ 小姐
348
+
349
+ 88
350
+ 00:04:25,705 --> 00:04:27,305
351
+ 你真的愿意帮那对母子
352
+
353
+ 89
354
+ 00:04:27,305 --> 00:04:28,105
355
+ 补交费用
356
+
357
+ 90
358
+ 00:04:30,025 --> 00:04:30,665
359
+ 不过
360
+
361
+ 91
362
+ 00:04:30,985 --> 00:04:33,065
363
+ 你不用告诉他们是谁给的
364
+
365
+ 92
366
+ 00:04:34,185 --> 00:04:34,985
367
+ 就说
368
+
369
+ 93
370
+ 00:04:35,465 --> 00:04:36,905
371
+ 是小宝的生日礼物
372
+
373
+ 94
374
+ 00:04:39,465 --> 00:04:41,545
375
+ 真是有钱有颜还善良
376
+
377
+ 95
378
+ 00:04:45,225 --> 00:04:46,505
379
+ 订最近的一个航班
380
+
381
+ 96
382
+ 00:04:47,145 --> 00:04:47,645
383
+ 回国
384
+
385
+ 97
386
+ 00:04:47,785 --> 00:04:48,285
387
+
388
+
389
+ 98
390
+ 00:04:48,585 --> 00:04:49,545
391
+ 时老师
392
+
393
+ 99
394
+ 00:04:49,705 --> 00:04:51,145
395
+ 你这身体还吃得消吗
396
+
397
+ 100
398
+ 00:04:51,625 --> 00:04:52,425
399
+ 要不然
400
+
401
+ 101
402
+ 00:04:52,425 --> 00:04:54,345
403
+ 在医院住两天再走吧
404
+
405
+ 102
406
+ 00:04:55,465 --> 00:04:56,105
407
+ 妈妈
408
+
409
+ 103
410
+ 00:04:56,105 --> 00:04:57,545
411
+ 别哭了
412
+
413
+ 104
414
+ 00:04:59,145 --> 00:04:59,645
415
+ 不用
416
+
417
+ 105
418
+ 00:05:00,425 --> 00:05:01,225
419
+ 订机票吧
420
+
421
+ 106
422
+ 00:05:07,465 --> 00:05:08,105
423
+ 师傅
424
+
425
+ 107
426
+ 00:05:08,585 --> 00:05:09,705
427
+ 去星河别墅
428
+
429
+ 108
430
+ 00:05:10,185 --> 00:05:10,685
431
+ 好的
432
+
433
+ 109
434
+ 00:05:13,225 --> 00:05:14,505
435
+ 这好像
436
+
437
+ 110
438
+ 00:05:14,505 --> 00:05:16,905
439
+ 是我跟顾寒深结婚五年来
440
+
441
+ 111
442
+ 00:05:17,385 --> 00:05:18,505
443
+ 第一次
444
+
445
+ 112
446
+ 00:05:18,505 --> 00:05:20,425
447
+ 演出一结束就回家
448
+
449
+ 113
450
+ 00:05:21,225 --> 00:05:22,665
451
+ 你不爱我没关系
452
+
453
+ 114
454
+ 00:05:23,145 --> 00:05:24,905
455
+ 哪怕恨我都没关系
456
+
457
+ 115
458
+ 00:05:27,625 --> 00:05:28,585
459
+ 五年前
460
+
461
+ 116
462
+ 00:05:28,905 --> 00:05:30,985
463
+ 时家资金链断裂
464
+
465
+ 117
466
+ 00:05:30,985 --> 00:05:32,105
467
+ 面临破产
468
+
469
+ 118
470
+ 00:05:33,225 --> 00:05:35,145
471
+ 顾寒深伸出援手
472
+
473
+ 119
474
+ 00:05:36,265 --> 00:05:37,545
475
+ 条件却是
476
+
477
+ 120
478
+ 00:05:38,985 --> 00:05:40,745
479
+ 只要肯做我太太就好
480
+
481
+ 121
482
+ 00:05:43,145 --> 00:05:44,585
483
+ 要我跟他结婚
484
+
485
+ 122
486
+ 00:05:51,354 --> 00:05:52,794
487
+ 你不爱我没关系
488
+
489
+ 123
490
+ 00:05:54,234 --> 00:05:55,834
491
+ 哪怕恨我都没关系
492
+
493
+ 124
494
+ 00:05:57,274 --> 00:05:59,034
495
+ 只要肯做我太太就好
496
+
497
+ 125
498
+ 00:06:07,514 --> 00:06:08,634
499
+ 只要你点头
500
+
501
+ 126
502
+ 00:06:09,594 --> 00:06:11,194
503
+ 我立刻让人把五个亿
504
+
505
+ 127
506
+ 00:06:11,994 --> 00:06:13,434
507
+ 打入你们时氏的账户
508
+
509
+ 128
510
+ 00:06:16,474 --> 00:06:17,754
511
+ 我憎恨他做局
512
+
513
+ 129
514
+ 00:06:18,394 --> 00:06:19,834
515
+ 害我当时的男朋友
516
+
517
+ 130
518
+ 00:06:19,834 --> 00:06:21,114
519
+ 沈景泽入狱
520
+
521
+ 131
522
+ 00:06:24,154 --> 00:06:25,594
523
+ 以至于结婚后
524
+
525
+ 132
526
+ 00:06:31,194 --> 00:06:32,634
527
+ 对他恨之入骨
528
+
529
+ 133
530
+ 00:06:39,674 --> 00:06:40,314
531
+ 柒柒
532
+
533
+ 134
534
+ 00:06:40,794 --> 00:06:41,594
535
+ 我求你
536
+
537
+ 135
538
+ 00:06:41,594 --> 00:06:42,394
539
+ 你别走
540
+
541
+ 136
542
+ 00:06:43,354 --> 00:06:44,154
543
+ 别碰我
544
+
545
+ 137
546
+ 00:06:44,634 --> 00:06:45,434
547
+
548
+
549
+ 138
550
+ 00:07:20,474 --> 00:07:22,394
551
+ 只要你肯生下这个孩子
552
+
553
+ 139
554
+ 00:07:29,594 --> 00:07:31,034
555
+ 我就放你自由
556
+
557
+ 140
558
+ 00:07:35,994 --> 00:07:37,594
559
+ 直到上个月我才知道
560
+
561
+ 141
562
+ 00:07:38,234 --> 00:07:39,674
563
+ 当初转移股份
564
+
565
+ 142
566
+ 00:07:40,474 --> 00:07:41,754
567
+ 截断资金链
568
+
569
+ 143
570
+ 00:07:41,754 --> 00:07:43,514
571
+ 让公司陷入危机的人
572
+
573
+ 144
574
+ 00:07:47,034 --> 00:07:48,634
575
+ 是我引狼入室
576
+
577
+ 145
578
+ 00:07:48,954 --> 00:07:50,234
579
+ 看错了人
580
+
581
+ 146
582
+ 00:07:50,874 --> 00:07:51,674
583
+ 柒柒
584
+
585
+ 147
586
+ 00:07:51,994 --> 00:07:54,234
587
+ 你让我调查的事情有结果了
588
+
589
+ 148
590
+ 00:07:55,194 --> 00:07:55,834
591
+ 当年
592
+
593
+ 149
594
+ 00:07:55,834 --> 00:07:57,274
595
+ 不是顾寒深设的局
596
+
597
+ 150
598
+ 00:07:58,074 --> 00:07:59,034
599
+ 你误会他了
600
+
601
+ 151
602
+ 00:07:59,514 --> 00:08:01,274
603
+ 当年如果没有他出资相助
604
+
605
+ 152
606
+ 00:08:01,594 --> 00:08:02,714
607
+ 时家现在
608
+
609
+ 153
610
+ 00:08:02,714 --> 00:08:03,834
611
+ 恐怕早就没了
612
+
613
+ 154
614
+ 00:08:06,554 --> 00:08:07,674
615
+ 而我这么多年
616
+
617
+ 155
618
+ 00:08:08,314 --> 00:08:10,074
619
+ 一直都在错怪顾寒深
620
+
621
+ 156
622
+ 00:08:10,874 --> 00:08:11,674
623
+ 可他
624
+
625
+ 157
626
+ 00:08:12,314 --> 00:08:13,594
627
+ 却无一怨言
628
+
629
+ 158
630
+ 00:08:24,314 --> 00:08:26,074
631
+ 小姐星河别墅到了
632
+
633
+ 159
634
+ 00:08:30,874 --> 00:08:31,674
635
+ 谢谢
636
+
637
+ 160
638
+ 00:08:36,954 --> 00:08:38,714
639
+ 因为我喜欢玫瑰
640
+
641
+ 161
642
+ 00:08:39,354 --> 00:08:40,954
643
+ 所以顾寒深
644
+
645
+ 162
646
+ 00:08:41,274 --> 00:08:42,554
647
+ 持巨资
648
+
649
+ 163
650
+ 00:08:43,194 --> 00:08:45,114
651
+ 为我打造了这片玫瑰花海
652
+
653
+ 164
654
+ 00:08:51,097 --> 00:08:53,177
655
+ 是棠棠告诉我真相的原因吗
656
+
657
+ 165
658
+ 00:08:54,457 --> 00:08:55,577
659
+ 这次回来
660
+
661
+ 166
662
+ 00:08:56,057 --> 00:08:57,017
663
+ 竟然对这个
664
+
665
+ 167
666
+ 00:08:57,017 --> 00:08:58,777
667
+ 曾经厌恶至极的家
668
+
669
+ 168
670
+ 00:08:59,257 --> 00:09:01,337
671
+ 产生了一丝丝的思念
672
+
673
+ 169
674
+ 00:09:03,417 --> 00:09:04,217
675
+ 太太
676
+
677
+ 170
678
+ 00:09:05,337 --> 00:09:06,137
679
+ 太太
680
+
681
+ 171
682
+ 00:09:06,297 --> 00:09:06,937
683
+ 您回来了
684
+
685
+ 172
686
+ 00:09:06,937 --> 00:09:08,697
687
+ 怎么没有提前跟我说一声
688
+
689
+ 173
690
+ 00:09:08,697 --> 00:09:10,457
691
+ 我好派车去接你啊
692
+
693
+ 174
694
+ 00:09:11,257 --> 00:09:12,857
695
+ 先生他知道了吗
696
+
697
+ 175
698
+ 00:09:15,897 --> 00:09:16,697
699
+ 他还不知道
700
+
701
+ 176
702
+ 00:09:17,657 --> 00:09:18,617
703
+ 那快进屋吧
704
+
705
+ 177
706
+ 00:09:25,497 --> 00:09:26,297
707
+ 暮暮
708
+
709
+ 178
710
+ 00:09:26,937 --> 00:09:28,057
711
+ 已经十点了
712
+
713
+ 179
714
+ 00:09:28,377 --> 00:09:29,657
715
+ 明天还要上学
716
+
717
+ 180
718
+ 00:09:29,817 --> 00:09:30,777
719
+ 快去睡觉
720
+
721
+ 181
722
+ 00:09:31,257 --> 00:09:32,217
723
+ 爸爸
724
+
725
+ 182
726
+ 00:09:32,537 --> 00:09:35,097
727
+ 妈妈怎么还没有回来
728
+
729
+ 183
730
+ 00:09:35,577 --> 00:09:39,417
731
+ 她走之前答应给我过生日的
732
+
733
+ 184
734
+ 00:09:40,217 --> 00:09:43,097
735
+ 你不是也说她会回来的吗
736
+
737
+ 185
738
+ 00:09:43,097 --> 00:09:44,377
739
+ 妈妈是
740
+
741
+ 186
742
+ 00:09:45,017 --> 00:09:47,897
743
+ 是不是不喜欢我
744
+
745
+ 187
746
+ 00:09:53,817 --> 00:09:54,937
747
+ 怎么会呢
748
+
749
+ 188
750
+ 00:09:55,577 --> 00:09:57,177
751
+ 妈妈很喜欢暮暮
752
+
753
+ 189
754
+ 00:09:58,617 --> 00:10:00,217
755
+ 妈妈工作很忙
756
+
757
+ 190
758
+ 00:10:00,857 --> 00:10:02,617
759
+ 没有办法能回来陪你
760
+
761
+ 191
762
+ 00:10:03,097 --> 00:10:05,177
763
+ 明天爸爸送暮暮去上幼儿园
764
+
765
+ 192
766
+ 00:10:05,177 --> 00:10:05,977
767
+ 好不好
768
+
769
+ 193
770
+ 00:10:06,617 --> 00:10:08,057
771
+ 你骗我
772
+
773
+ 194
774
+ 00:10:08,217 --> 00:10:10,617
775
+ 妈妈就是不喜欢我
776
+
777
+ 195
778
+ 00:10:10,777 --> 00:10:12,857
779
+ 别人家的小朋友
780
+
781
+ 196
782
+ 00:10:12,857 --> 00:10:15,897
783
+ 妈妈都会给他们过生日
784
+
785
+ 197
786
+ 00:10:16,057 --> 00:10:17,817
787
+ 送他们上幼儿园
788
+
789
+ 198
790
+ 00:10:17,977 --> 00:10:19,897
791
+ 给他们讲故事
792
+
793
+ 199
794
+ 00:10:20,057 --> 00:10:24,537
795
+ 我妈妈从来不理我
796
+
797
+ 200
798
+ 00:10:33,177 --> 00:10:34,137
799
+ 是啊
800
+
801
+ 201
802
+ 00:10:34,777 --> 00:10:36,697
803
+ 暮暮都看得清的事实
804
+
805
+ 202
806
+ 00:10:37,497 --> 00:10:39,577
807
+ 我却一直在自欺欺人
808
+
809
+ 203
810
+ 00:10:41,017 --> 00:10:42,137
811
+ 她不喜欢我
812
+
813
+ 204
814
+ 00:10:42,617 --> 00:10:44,377
815
+ 也不喜欢我们的孩子
816
+
817
+ 205
818
+ 00:10:47,417 --> 00:10:49,817
819
+ 太太本就不喜欢小少爷
820
+
821
+ 206
822
+ 00:10:49,817 --> 00:10:51,577
823
+ 小少爷这么哭下去
824
+
825
+ 207
826
+ 00:10:51,577 --> 00:10:53,657
827
+ 会不会哭恼了太太
828
+
829
+ 208
830
+ 00:10:55,097 --> 00:10:55,897
831
+ 太太
832
+
833
+ 209
834
+ 00:10:55,897 --> 00:10:57,497
835
+ 您怎么不进去啊
836
+
837
+ 210
838
+ 00:11:12,697 --> 00:11:13,977
839
+ 柒柒
840
+
841
+ 211
842
+ 00:11:44,057 --> 00:11:45,177
843
+ 你抱够了没有
844
+
845
+ 212
846
+ 00:11:46,937 --> 00:11:49,177
847
+ 朝思暮时
848
+
849
+ 213
850
+ 00:11:58,674 --> 00:11:59,634
851
+ 这些年
852
+
853
+ 214
854
+ 00:11:59,634 --> 00:12:00,754
855
+ 我每次回家
__pycache__/diarize_audio.cpython-310.pyc ADDED
Binary file (34.7 kB). View file
 
__pycache__/translate_srt.cpython-310.pyc ADDED
Binary file (80.7 kB). View file
 
hf-upload/README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - vi
5
+ - en
6
+ tags:
7
+ - subtitles
8
+ - srt
9
+ - translation
10
+ - gemma4
11
+ - vision
12
+ - llama.cpp
13
+ pipeline_tag: translation
14
+ library_name: gemma-srt-translate
15
+ ---
16
+
17
+ # Gemma SRT Translate
18
+
19
+ Dịch và sửa phụ đề SRT bằng **Gemma 4 12B vision** (llama-server + MTP).
20
+ Repo này chứa **script + notebook Colab**; model GGUF tải từ [`unsloth/gemma-4-12B-it-qat-GGUF`](https://huggingface.co/unsloth/gemma-4-12B-it-qat-GGUF).
21
+
22
+ ## Cấu trúc repo
23
+
24
+ ```
25
+ YOUR_USERNAME/gemma-srt-translate/
26
+ ├── README.md ← file này
27
+ ├── config.yaml ← cấu hình mặc định
28
+ ├── translate_srt.py ← pipeline chính
29
+ ├── diarize_audio.py ← Pass 0: phân tích giọng nói (tùy chọn)
30
+ ├── requirements-diarize.txt ← dependency cho Pass 0
31
+ ├── colab/
32
+ │ └── GemmaSRT_Colab.ipynb ← chạy trên Google Colab (L4 24GB)
33
+ └── scripts/
34
+ ├── download_models.py ← tải GGUF từ Unsloth
35
+ └── build_llama_server.sh ← build llama-server Linux (Colab)
36
+ ```
37
+
38
+ ## Chạy trên Google Colab (khuyến nghị L4 24GB)
39
+
40
+ Repo **Private** → **không** mở được bằng nút Open in Colab trên HF (lỗi 401).
41
+
42
+ **Cách mở:** tải [colab/GemmaSRT_Colab.ipynb](https://huggingface.co/STBack23/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb) → Colab **File → Upload notebook** → dán **`HF_TOKEN`** vào cell cấu hình → lưu copy lên Drive `Gemma/` để dùng lại.
43
+
44
+ Repo này nằm trên **Hugging Face**, không phải GitHub — link `colab.research.google.com/github/...` sẽ lỗi 404.
45
+
46
+ **Cách mở (chọn một):**
47
+
48
+ 1. **Nút Open in Colab trên HF** (khuyến nghị):
49
+ [colab/GemmaSRT_Colab.ipynb](https://huggingface.co/STBack23/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb) → bấm **Open in Colab**
50
+
51
+ 2. **Shortcut HF `/colab`**:
52
+ [https://huggingface.co/STBack23/gemma-srt-translate/colab](https://huggingface.co/STBack23/gemma-srt-translate/colab)
53
+
54
+ 3. **Colab → File → Upload notebook** → tải file `.ipynb` từ HF về rồi upload
55
+
56
+ Sau khi mở notebook:
57
+
58
+ 1. Runtime → **Change runtime type** → GPU **L4** (hoặc T4/A100)
59
+ 2. Chạy tuần tự các cell (lần đầu ~15–20 phút: build llama-server + tải model)
60
+ 3. Upload video + SRT hoặc trỏ vào Google Drive
61
+ 4. Tải file `*.vi.srt` về máy
62
+
63
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://huggingface.co/STBack23/gemma-srt-translate/colab)
64
+
65
+ ## Chạy trên máy local (Windows)
66
+
67
+ Repo HF **không** chứa `llama-server.exe` — dùng bản Windows trong project gốc:
68
+
69
+ ```powershell
70
+ .\run-translate.ps1 -Video "phim.mp4" -InputSrt "phim.srt" -OutputSrt "phim.vi.srt"
71
+ ```
72
+
73
+ Hoặc mở GUI: `GemmaSRT.bat`
74
+
75
+ ## Tải model thủ công
76
+
77
+ ```bash
78
+ pip install huggingface_hub
79
+ python scripts/download_models.py --dest ./models
80
+ ```
81
+
82
+ File tải về (~12–15 GB):
83
+
84
+ | File | Mục đích |
85
+ |------|----------|
86
+ | `gemma-4-12B-it-qat-UD-Q4_K_XL.gguf` | Model chính |
87
+ | `mmproj-F16.gguf` | Vision projector |
88
+ | `mtp-gemma-4-12B-it.gguf` | MTP draft (tăng tốc) |
89
+
90
+ ## Pipeline
91
+
92
+ 1. Parse SRT → gom scene
93
+ 2. **Pass 0** *(tùy chọn)*: phân tích giọng nói (pyannote) → "ai nói câu nào" + giới tính/độ tuổi → gán vào từng cue để xưng hô nhất quán
94
+ 3. **Pass 1**: cắt frame (ffmpeg) → OCR/sửa SRT gốc (vision)
95
+ 4. **Pass 2**: dịch theo ngữ cảnh + xưng hô
96
+ 5. Ghi SRT dịch, SRT đã sửa, báo cáo JSON
97
+
98
+ ### Pass 0 — phân tích giọng nói (diarization)
99
+
100
+ Bật bằng `--diarize` (CLI) hoặc `ENABLE_DIARIZE = True` (Colab). Cần thêm:
101
+
102
+ ```bash
103
+ pip install -r requirements-diarize.txt
104
+ ```
105
+
106
+ Token HF phải bấm **Agree** điều kiện các model gated:
107
+ [community-1](https://hf.co/pyannote/speaker-diarization-community-1) ·
108
+ [3.1](https://hf.co/pyannote/speaker-diarization-3.1) ·
109
+ [segmentation-3.0](https://hf.co/pyannote/segmentation-3.0).
110
+ `--gender-method model` dùng [audeering wav2vec2 age/gender](https://hf.co/audeering/wav2vec2-large-robust-24-ft-age-gender) (~1GB).
111
+
112
+ ## VRAM
113
+
114
+ | GPU | 1 phim | 2 phim song song |
115
+ |-----|--------|------------------|
116
+ | RTX 4060 Ti 16GB | ✅ | ❌ |
117
+ | Colab L4 24GB | ✅ | ❌ (dùng local + Colab = 2 phim độc lập) |
118
+
119
+ ## Cấu hình
120
+
121
+ Chỉnh `config.yaml` hoặc tham số CLI:
122
+
123
+ ```bash
124
+ python translate_srt.py \
125
+ --video phim.mp4 \
126
+ --input-srt phim.srt \
127
+ --output-srt phim.vi.srt \
128
+ --target-lang Vietnamese \
129
+ --skip-correction # bỏ pass OCR (nhanh hơn)
130
+ ```
131
+
132
+ ## Model gốc
133
+
134
+ - [unsloth/gemma-4-12B-it-qat-GGUF](https://huggingface.co/unsloth/gemma-4-12B-it-qat-GGUF)
135
+ - [Gemma 4 license](https://ai.google.dev/gemma/docs/gemma_4_license)
136
+
137
+ ## Upload repo lên Hugging Face
138
+
139
+ Xem [UPLOAD.md](./UPLOAD.md) (trong project gốc: `huggingface/UPLOAD.md`).
hf-upload/colab/GemmaSRT_Colab.ipynb ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Gemma SRT Translate — Google Colab\n",
8
+ "\n",
9
+ "Dịch phụ đề `.srt` sang tiếng Việt bằng **Gemma 4 12B vision**.\n",
10
+ "\n",
11
+ "**GPU:** T4/L4 — Runtime → Change runtime type → GPU. \n",
12
+ "\n",
13
+ "## Chạy\n",
14
+ "\n",
15
+ "Lần đầu ~20–50 phút (tải model ~12GB + build llama-server; cache lưu `Gemma/Cache`).\n",
16
+ "\n",
17
+ "## Tuỳ chọn (cell CẤU HÌNH)\n",
18
+ "\n",
19
+ "- `ENABLE_DIARIZE = True` — phân tích giọng nói để xưng hô nhất quán (cần video)\n",
20
+ "- `SKIP_CORRECTION = True` — bỏ OCR sửa SRT. Đặt `False` nếu sub gốc hay sai chữ\n",
21
+ "- `FORCE_LLAMA_REBUILD = True` — build lại llama-server b9553 (MTP); đặt `False` sau lần build OK\n",
22
+ "- `CTX_SIZE = 4096` — context nhỏ hơn giúp MTP chạy trên L4\n",
23
+ "- `NO_SCENE_VISION` / `SCENE_FRAMES` — tắt/giảm vision để dịch nhanh hơn (phim dài)\n",
24
+ "- `DOWNLOAD_RESULTS` — tải `.vi.srt` về trình duyệt sau khi xong"
25
+ ]
26
+ },
27
+ {
28
+ "cell_type": "code",
29
+ "metadata": {},
30
+ "source": [
31
+ "# ═══ CẤU HÌNH — sửa trước khi chạy ═══\n",
32
+ "from pathlib import Path\n",
33
+ "import os\n",
34
+ "\n",
35
+ "from google.colab import userdata\n",
36
+ "\n",
37
+ "HF_TOKEN_SECRET = \"HF_TOKEN\" # tên khóa trong Colab Secrets (🔑)\n",
38
+ "\n",
39
+ "def _load_hf_token() -> str:\n",
40
+ " try:\n",
41
+ " return userdata.get(HF_TOKEN_SECRET).strip()\n",
42
+ " except userdata.SecretNotFoundError:\n",
43
+ " return \"\"\n",
44
+ "\n",
45
+ "HF_TOKEN = _load_hf_token()\n",
46
+ "if HF_TOKEN:\n",
47
+ " os.environ[\"HF_TOKEN\"] = HF_TOKEN\n",
48
+ " os.environ[\"HUGGING_FACE_HUB_TOKEN\"] = HF_TOKEN\n",
49
+ " print(f\"HF token OK (Secret: {HF_TOKEN_SECRET})\")\n",
50
+ "else:\n",
51
+ " print(f\"Chưa có Secret '{HF_TOKEN_SECRET}' — thêm tại 🔑 Secrets (Read token HF)\")\n",
52
+ "\n",
53
+ "HF_REPO = \"STBack23/gemma-srt-translate\" # repo chứa mã nguồn pipeline\n",
54
+ "\n",
55
+ "MODELS_REPO = \"unsloth/gemma-4-12B-it-qat-GGUF\" # ~12GB — T4/L4\n",
56
+ "MODEL_FILES = [\n",
57
+ " \"gemma-4-12B-it-qat-UD-Q4_K_XL.gguf\",\n",
58
+ " \"mmproj-F16.gguf\",\n",
59
+ " \"mtp-gemma-4-12B-it.gguf\",\n",
60
+ "]\n",
61
+ "\n",
62
+ "# ── Google Drive ─────────────────────────────────────────────\n",
63
+ "USE_DRIVE_CACHE = True # True = cache model + llama-server trên Drive (khuyến nghị)\n",
64
+ "\n",
65
+ "# Thư mục trên Drive: Gemma/Cache (model) + Gemma/Phim (video + SRT vào/ra)\n",
66
+ "DRIVE_ROOT = \"/content/drive/MyDrive/Gemma\"\n",
67
+ "DRIVE_CACHE = f\"{DRIVE_ROOT}/Cache\"\n",
68
+ "DRIVE_PHIM_DIR = f\"{DRIVE_ROOT}/Phim\"\n",
69
+ "\n",
70
+ "# Mã nguồn pipeline luôn tải từ Hugging Face (HF_REPO).\n",
71
+ "VIDEO_EXTS = (\".mp4\", \".mkv\", \".avi\", \".mov\")\n",
72
+ "\n",
73
+ "def job(stem, ext_video=\".mp4\"):\n",
74
+ " \"\"\"stem = tên file trên Drive (KHÔNG đuôi), khớp y hệt — kể cả dấu, khoảng trắng.\"\"\"\n",
75
+ " base = f\"{DRIVE_PHIM_DIR}/{stem}\"\n",
76
+ " return {\"stem\": stem, \"video\": f\"{base}{ext_video}\", \"srt\": f\"{base}.srt\"}\n",
77
+ "\n",
78
+ "# ── Danh sách phim cần dịch ──────────────────────────────────\n",
79
+ "PHIM_STEMS = [\n",
80
+ " \"Thư Gửi Mùa Hè\", # → Gemma/Phim/Thư Gửi Mùa Hè.mp4 + Thư Gửi Mùa Hè.srt\n",
81
+ " # \"ten-phim-khac\",\n",
82
+ "]\n",
83
+ "JOBS = [job(stem) for stem in PHIM_STEMS]\n",
84
+ "# Cách khác — đường dẫn đầy đủ:\n",
85
+ "# JOBS = [{\"video\": f\"{DRIVE_PHIM_DIR}/a.mp4\", \"srt\": f\"{DRIVE_PHIM_DIR}/a.srt\"}]\n",
86
+ "# Upload 1 phim qua nút Colab: PHIM_STEMS = [], JOBS = [], UPLOAD_WIDGET = True\n",
87
+ "UPLOAD_WIDGET = False\n",
88
+ "\n",
89
+ "# ── Tùy chọn dịch ───────────────────────────────────────────\n",
90
+ "SOURCE_LANG = \"auto\" # ngôn ngữ SRT gốc (\"auto\" = model tự nhận)\n",
91
+ "TARGET_LANG = \"Vietnamese\" # ngôn ngữ đích\n",
92
+ "\n",
93
+ "SKIP_CORRECTION = True # False = OCR sửa SRT trước khi dịch\n",
94
+ "\n",
95
+ "LIMIT_CUES = 0 # 0 = dịch hết file; đặt 20 để thử nhanh vài cue đầu\n",
96
+ "\n",
97
+ "ENABLE_DIARIZE = True # False = bỏ phân tích giọng nói\n",
98
+ "\n",
99
+ "GENDER_METHOD = \"model\" # \"model\" | \"pitch\" | \"auto\"\n",
100
+ "\n",
101
+ "NUM_SPEAKERS = 0 # 0 = tự dò; đặt số nếu biết trước số nhân vật chính\n",
102
+ "DETECT_GENDER = True # False = chỉ gán nhãn người nói, bỏ giới tính/tuổi\n",
103
+ "DIARIZE_DEVICE = \"auto\" # \"auto\" | \"cuda\" | \"cpu\"\n",
104
+ "\n",
105
+ "# ── llama-server + MTP (Gemma 4 draft-mtp) ───────────────────\n",
106
+ "# Pin llama.cpp tag b9553 — verified Gemma 4 draft-mtp on L4.\n",
107
+ "FORCE_LLAMA_REBUILD = True # True = xóa cache Drive + build lại (~20–50 phút)\n",
108
+ " # Sau khi log có \"(with MTP)\", đặt False cho lần sau\n",
109
+ "NO_MTP = False # True = tắt MTP (chậm hơn ~20–40%)\n",
110
+ "CTX_SIZE = 4096 # 4096 ổn L4+MTP; 8192 nếu đủ VRAM / không MTP\n",
111
+ "\n",
112
+ "# ── Tốc độ dịch (phim dài) ───────────────────────────────────\n",
113
+ "NO_SCENE_VISION = False # True = bỏ bước mô tả cảnh (~40% nhanh hơn)\n",
114
+ "NO_SCENE_IMAGE = False # True = dịch không ảnh (nhanh nhất, kém context)\n",
115
+ "SCENE_FRAMES = 3 # 1 = ít ảnh/scene, nhanh hơn\n",
116
+ "SCENE_MAX_CUES = 4 # 5–6 = ít lần gọi model hơn (phim dài)\n",
117
+ "SCENE_MAX_GAP = 1.5 # giây — tách scene nếu khoảng cách lớn hơn\n",
118
+ "SCENE_MAX_DUR = 20.0 # giây — tách scene nếu đoạn quá dài\n",
119
+ "\n",
120
+ "# ── Sau khi dịch xong ─────────────────────────────────────────\n",
121
+ "DOWNLOAD_RESULTS = True # True = tải *.vi.srt về trình duyệt\n",
122
+ "AUTO_UNMOUNT_DRIVE = True # True = gỡ mount Google Drive\n",
123
+ "AUTO_DISCONNECT_RUNTIME = True # True = ngắt runtime Colab (tiết kiệm GPU quota)\n",
124
+ "DISCONNECT_DELAY_SEC = 15 # giây chờ trước khi ngắt (để download kịp)\n",
125
+ "\n",
126
+ "# ── Đường dẫn nội bộ Colab (thường không cần sửa) ────────────\n",
127
+ "ROOT = \"/content/gemma-srt\"\n",
128
+ "MODELS_DIR = f\"{ROOT}/models\"\n",
129
+ "LLAMA_DIR = \"/content/llama.cpp\"\n",
130
+ "LLAMA_SERVER = f\"{LLAMA_DIR}/build/bin/llama-server\""
131
+ ],
132
+ "execution_count": null,
133
+ "outputs": []
134
+ },
135
+ {
136
+ "cell_type": "code",
137
+ "metadata": {},
138
+ "source": [
139
+ "# Kiểm tra GPU\n",
140
+ "!nvidia-smi --query-gpu=name,memory.total --format=csv,noheader\n",
141
+ "\n",
142
+ "import torch\n",
143
+ "if not torch.cuda.is_available():\n",
144
+ " raise RuntimeError(\"Chưa có GPU. Runtime → Change runtime type → GPU (L4).\")"
145
+ ],
146
+ "execution_count": null,
147
+ "outputs": []
148
+ },
149
+ {
150
+ "cell_type": "code",
151
+ "metadata": {},
152
+ "source": [
153
+ "# Mount Google Drive — cache model + thư mục phim\n",
154
+ "if USE_DRIVE_CACHE:\n",
155
+ " from google.colab import drive\n",
156
+ " from pathlib import Path\n",
157
+ " import os\n",
158
+ " drive.mount(\"/content/drive\")\n",
159
+ " os.makedirs(DRIVE_CACHE, exist_ok=True)\n",
160
+ " os.makedirs(DRIVE_PHIM_DIR, exist_ok=True)\n",
161
+ " print(f\"Cache model: {DRIVE_CACHE}\")\n",
162
+ " print(f\"Thư mục phim: {DRIVE_PHIM_DIR}\")\n",
163
+ "\n",
164
+ " cache_tgz = Path(f\"{DRIVE_CACHE}/llama-server-bin.tgz\")\n",
165
+ " if cache_tgz.is_file():\n",
166
+ " mb = cache_tgz.stat().st_size / 1_048_576\n",
167
+ " print(f\" llama-server cache: {mb:.1f} MB\" + (\" OK\" if mb > 5 else \" HỎNG\"))\n",
168
+ " else:\n",
169
+ " print(\" llama-server cache: chưa có (lần đầu build ~20–50 phút, pin b9553 cho MTP)\")\n",
170
+ "\n",
171
+ " files = sorted(p.name for p in Path(DRIVE_PHIM_DIR).iterdir() if p.is_file())\n",
172
+ " if files:\n",
173
+ " print(f\" ({len(files)} file trong Phim)\")\n",
174
+ " for name in files[:12]:\n",
175
+ " print(f\" - {name}\")\n",
176
+ " if len(files) > 12:\n",
177
+ " print(f\" ... và {len(files) - 12} file khác\")\n",
178
+ " else:\n",
179
+ " print(\" (chưa có file — upload video + .srt vào Gemma/Phim)\")"
180
+ ],
181
+ "execution_count": null,
182
+ "outputs": []
183
+ },
184
+ {
185
+ "cell_type": "code",
186
+ "metadata": {},
187
+ "source": [
188
+ "# Chuẩn bị code pipeline — tải từ Hugging Face\n",
189
+ "!pip install -q huggingface_hub pyyaml\n",
190
+ "\n",
191
+ "from pathlib import Path\n",
192
+ "import os\n",
193
+ "from huggingface_hub import snapshot_download\n",
194
+ "\n",
195
+ "Path(ROOT).mkdir(parents=True, exist_ok=True)\n",
196
+ "\n",
197
+ "_hf_tok = HF_TOKEN.strip()\n",
198
+ "if not _hf_tok or HF_REPO.startswith(\"YOUR_\"):\n",
199
+ " raise ValueError(\n",
200
+ " f\"Tải code từ HF cần HF_REPO hợp lệ + Secret '{HF_TOKEN_SECRET}' (Read token) — thêm tại 🔑 Secrets.\"\n",
201
+ " )\n",
202
+ "os.environ[\"HF_TOKEN\"] = _hf_tok\n",
203
+ "os.environ[\"HUGGING_FACE_HUB_TOKEN\"] = _hf_tok\n",
204
+ "\n",
205
+ "print(f\"[code] Downloading {HF_REPO} ...\")\n",
206
+ "snapshot_download(\n",
207
+ " repo_id=HF_REPO,\n",
208
+ " repo_type=\"model\",\n",
209
+ " local_dir=ROOT,\n",
210
+ " token=_hf_tok,\n",
211
+ " local_dir_use_symlinks=False,\n",
212
+ ")\n",
213
+ "print(f\"[code] OK (HF): {ROOT}\")\n",
214
+ "\n",
215
+ "assert Path(f\"{ROOT}/translate_srt.py\").is_file(), \"translate_srt.py not found\"\n",
216
+ "assert Path(f\"{ROOT}/scripts/ensure_llama_colab.py\").is_file(), \"scripts/ensure_llama_colab.py not found\""
217
+ ],
218
+ "execution_count": null,
219
+ "outputs": []
220
+ },
221
+ {
222
+ "cell_type": "code",
223
+ "metadata": {},
224
+ "source": [
225
+ "# ═══ Pass 0 — cài dependency phân tích giọng nói (chỉ khi ENABLE_DIARIZE) ═══\n",
226
+ "import importlib\n",
227
+ "import os\n",
228
+ "\n",
229
+ "if ENABLE_DIARIZE:\n",
230
+ " pip = get_ipython().run_line_magic\n",
231
+ " print(\"[diarize] Cài pyannote (Colab: giữ numpy<2.1 cho numba/scipy)...\")\n",
232
+ " # pyannote hay nâng numpy 2.4 → lỗi: cannot import name '_center' from numpy._core.umath\n",
233
+ " pip(\"pip\", \"install -q 'numpy>=1.26,<2.1'\")\n",
234
+ " pip(\"pip\", \"install -q 'lightning-utilities>=0.10' 'lightning>=2.2,<2.5'\")\n",
235
+ " pip(\"pip\", \"install -q pyannote.audio>=4.0 soundfile>=0.11 --upgrade-strategy only-if-needed\")\n",
236
+ " pip(\"pip\", \"install -q 'numpy>=1.26,<2.1' --force-reinstall --no-deps\")\n",
237
+ " try:\n",
238
+ " import transformers # noqa: F401\n",
239
+ " except Exception:\n",
240
+ " pip(\"pip\", \"install -q transformers>=4.40\")\n",
241
+ " importlib.invalidate_caches()\n",
242
+ " if HF_TOKEN.strip():\n",
243
+ " os.environ[\"HF_TOKEN\"] = HF_TOKEN.strip()\n",
244
+ " assert os.path.isfile(f\"{ROOT}/diarize_audio.py\"), \\\n",
245
+ " \"Thiếu diarize_audio.py — chạy lại cell tải code (Drive/HF) phía trên.\"\n",
246
+ " try:\n",
247
+ " import numpy as np\n",
248
+ " from pyannote.audio import Pipeline # noqa: F401\n",
249
+ " print(f\"[diarize] OK — numpy {np.__version__}, pyannote import OK.\")\n",
250
+ " print(\"[diarize] Trước khi chạy: tài khoản HF (Secret HF_TOKEN) phải bấm Agree tại:\")\n",
251
+ " for _u in (\n",
252
+ " \"https://hf.co/pyannote/speaker-diarization-community-1\",\n",
253
+ " \"https://hf.co/pyannote/speaker-diarization-3.1\",\n",
254
+ " \"https://hf.co/pyannote/segmentation-3.0\",\n",
255
+ " ):\n",
256
+ " print(f\" {_u}\")\n",
257
+ " except ImportError as e:\n",
258
+ " raise RuntimeError(\n",
259
+ " f\"pyannote import lỗi: {e}\\n\"\n",
260
+ " \"→ Runtime → Restart session → Run all lại (cell này chạy lại sau restart).\"\n",
261
+ " ) from e\n",
262
+ " if GENDER_METHOD == \"model\":\n",
263
+ " print(\"[diarize] Preflight model age/gender (xác nhận load + predict trước khi chạy)...\")\n",
264
+ " try:\n",
265
+ " import sys as _sys\n",
266
+ " if ROOT not in _sys.path:\n",
267
+ " _sys.path.insert(0, ROOT)\n",
268
+ " import numpy as _np\n",
269
+ " import torch as _torch\n",
270
+ " from diarize_audio import _load_age_gender_model, DEFAULT_AGE_GENDER_MODEL\n",
271
+ " _dev = \"cuda\" if _torch.cuda.is_available() else \"cpu\"\n",
272
+ " _m, _proc, _ = _load_age_gender_model(DEFAULT_AGE_GENDER_MODEL, _dev, print)\n",
273
+ " _sig = _np.zeros(16000, dtype=\"float32\")\n",
274
+ " _in = _proc(_sig, sampling_rate=16000)\n",
275
+ " _vals = _torch.from_numpy(_in[\"input_values\"][0].reshape(1, -1)).to(_torch.device(_dev))\n",
276
+ " with _torch.no_grad():\n",
277
+ " _h, _age, _gen = _m(_vals)\n",
278
+ " print(f\"[diarize] Preflight OK — model age/gender chạy được trên {_dev} (age head demo={float(_age[0].item())*100:.0f}y).\")\n",
279
+ " except Exception as _e: # noqa: BLE001\n",
280
+ " print(f\"[diarize] CANH BAO - Preflight age/gender LOI: {_e}\")\n",
281
+ " print(\"[diarize] -> Pass 0 van chay nhung FALLBACK pitch (chi gioi tinh, KHONG co tuoi).\")\n",
282
+ " print(\"[diarize] -> Tai lai diarize_audio.py moi nhat tu Drive/HF roi Runtime -> Restart session.\")\n",
283
+ "else:\n",
284
+ " print(\"Bỏ qua cài đặt diarization (ENABLE_DIARIZE = False).\")"
285
+ ],
286
+ "execution_count": null,
287
+ "outputs": []
288
+ },
289
+ {
290
+ "cell_type": "code",
291
+ "metadata": {},
292
+ "source": [
293
+ "# Tải model GGUF — dùng MODELS_REPO + MODEL_FILES từ cell CẤU HÌNH\n",
294
+ "from huggingface_hub import hf_hub_download\n",
295
+ "from pathlib import Path\n",
296
+ "import os\n",
297
+ "import shutil\n",
298
+ "\n",
299
+ "print(f\"[model] repo: {MODELS_REPO}\")\n",
300
+ "print(f\"[model] files: {MODEL_FILES}\")\n",
301
+ "\n",
302
+ "models_dest = MODELS_DIR\n",
303
+ "if USE_DRIVE_CACHE:\n",
304
+ " models_dest = f\"{DRIVE_CACHE}/models\"\n",
305
+ "Path(models_dest).mkdir(parents=True, exist_ok=True)\n",
306
+ "\n",
307
+ "paths = {}\n",
308
+ "for name in MODEL_FILES:\n",
309
+ " dest_file = Path(models_dest) / name\n",
310
+ " if dest_file.is_file():\n",
311
+ " print(f\"[model] cached: {name}\")\n",
312
+ " paths[name] = dest_file\n",
313
+ " continue\n",
314
+ " print(f\"[model] downloading {MODELS_REPO}/{name} ...\")\n",
315
+ " p = hf_hub_download(\n",
316
+ " repo_id=MODELS_REPO,\n",
317
+ " filename=name,\n",
318
+ " local_dir=models_dest,\n",
319
+ " local_dir_use_symlinks=False,\n",
320
+ " )\n",
321
+ " paths[name] = Path(p)\n",
322
+ " print(f\" OK: {p}\")\n",
323
+ "\n",
324
+ "# Symlink/copy vào ROOT/models cho translate_srt.py (luôn refresh link hỏng)\n",
325
+ "Path(MODELS_DIR).mkdir(parents=True, exist_ok=True)\n",
326
+ "for name, src in paths.items():\n",
327
+ " src = Path(src).resolve()\n",
328
+ " dst = Path(MODELS_DIR) / name\n",
329
+ " if not src.is_file():\n",
330
+ " raise FileNotFoundError(f\"Model thiếu trên Drive/disk: {src}\")\n",
331
+ " if dst.is_symlink() or dst.exists():\n",
332
+ " dst.unlink(missing_ok=True)\n",
333
+ " try:\n",
334
+ " os.symlink(src, dst)\n",
335
+ " except OSError:\n",
336
+ " if dst.exists():\n",
337
+ " dst.unlink(missing_ok=True)\n",
338
+ " shutil.copy2(src, dst)\n",
339
+ " print(f\"[model] linked {name} -> {dst} ({dst.stat().st_size / 1e9:.1f} GB)\")\n",
340
+ "\n",
341
+ "MODEL_PATH = Path(MODELS_DIR) / MODEL_FILES[0]\n",
342
+ "MMPROJ_PATH = Path(MODELS_DIR) / MODEL_FILES[1]\n",
343
+ "DRAFT_PATH = Path(MODELS_DIR) / MODEL_FILES[2]\n",
344
+ "for label, p in [(\"main\", MODEL_PATH), (\"mmproj\", MMPROJ_PATH), (\"draft\", DRAFT_PATH)]:\n",
345
+ " rp = p.resolve() if p.exists() else p\n",
346
+ " if not rp.is_file():\n",
347
+ " raise FileNotFoundError(f\"{label} chưa sẵn sàng: {p}\")\n",
348
+ "print(f\"\\nModels OK — chạy pipeline được.\")"
349
+ ],
350
+ "execution_count": null,
351
+ "outputs": []
352
+ },
353
+ {
354
+ "cell_type": "code",
355
+ "metadata": {},
356
+ "source": [
357
+ "# llama-server — restore/build (pin b9553 cho MTP Gemma 4)\n",
358
+ "import sys\n",
359
+ "\n",
360
+ "sys.path.insert(0, ROOT)\n",
361
+ "from scripts.ensure_llama_colab import ensure_llama_server\n",
362
+ "\n",
363
+ "LLAMA_SERVER = str(ensure_llama_server(\n",
364
+ " llama_dir=LLAMA_DIR,\n",
365
+ " drive_cache=DRIVE_CACHE if USE_DRIVE_CACHE else None,\n",
366
+ " allow_build=True,\n",
367
+ " require_mtp=True,\n",
368
+ " force_rebuild=FORCE_LLAMA_REBUILD,\n",
369
+ "))\n",
370
+ "print(f\"LLAMA_SERVER = {LLAMA_SERVER}\")\n"
371
+ ],
372
+ "execution_count": null,
373
+ "outputs": []
374
+ },
375
+ {
376
+ "cell_type": "code",
377
+ "metadata": {},
378
+ "source": [
379
+ "# Kiểm tra ffmpeg\n",
380
+ "!ffmpeg -version | head -1"
381
+ ],
382
+ "execution_count": null,
383
+ "outputs": []
384
+ },
385
+ {
386
+ "cell_type": "code",
387
+ "metadata": {},
388
+ "source": [
389
+ "# Kiểm tra danh sách JOBS (Drive) hoặc upload 1 phim qua widget\n",
390
+ "from pathlib import Path\n",
391
+ "from google.colab import files\n",
392
+ "import os\n",
393
+ "\n",
394
+ "UPLOAD_DIR = \"/content/uploads\"\n",
395
+ "os.makedirs(UPLOAD_DIR, exist_ok=True)\n",
396
+ "resolved_jobs = []\n",
397
+ "\n",
398
+ "def _list_phim_hint() -> str:\n",
399
+ " phim = Path(DRIVE_PHIM_DIR)\n",
400
+ " if not phim.is_dir():\n",
401
+ " return f\"Chưa có thư mục: {DRIVE_PHIM_DIR}\"\n",
402
+ " lines = [\"File hiện có trong Gemma/Phim:\"]\n",
403
+ " for p in sorted(phim.iterdir()):\n",
404
+ " if p.is_file():\n",
405
+ " lines.append(f\" - {p.name}\")\n",
406
+ " if len(lines) == 1:\n",
407
+ " lines.append(\" (trống — upload video + .srt vào đây)\")\n",
408
+ " return \"\\n\".join(lines)\n",
409
+ "\n",
410
+ "def _find_video(stem: str, preferred: str | None = None) -> Path | None:\n",
411
+ " if preferred:\n",
412
+ " p = Path(preferred)\n",
413
+ " if p.is_file():\n",
414
+ " return p\n",
415
+ " for ext in VIDEO_EXTS:\n",
416
+ " p = Path(DRIVE_PHIM_DIR) / f\"{stem}{ext}\"\n",
417
+ " if p.is_file():\n",
418
+ " return p\n",
419
+ " return None\n",
420
+ "\n",
421
+ "if JOBS:\n",
422
+ " for i, entry in enumerate(JOBS, 1):\n",
423
+ " stem = entry.get(\"stem\") or Path(entry[\"video\"]).stem\n",
424
+ " v = _find_video(stem, entry.get(\"video\"))\n",
425
+ " s = Path(entry[\"srt\"])\n",
426
+ " if v is None:\n",
427
+ " raise FileNotFoundError(\n",
428
+ " f\"Job {i}: không thấy video cho '{stem}'\\n\"\n",
429
+ " f\"Đã thử: {', '.join(stem + ext for ext in VIDEO_EXTS)}\\n\\n\"\n",
430
+ " f\"Tên trong PHIM_STEMS phải KHỚP Y HỆT tên file (không đuôi).\\n\\n\"\n",
431
+ " f\"{_list_phim_hint()}\"\n",
432
+ " )\n",
433
+ " if not s.is_file():\n",
434
+ " s = Path(DRIVE_PHIM_DIR) / f\"{stem}.srt\"\n",
435
+ " if not s.is_file():\n",
436
+ " raise FileNotFoundError(\n",
437
+ " f\"Job {i}: không thấy SRT: {s}\\n\\n\"\n",
438
+ " f\"Cần file: {stem}.srt cùng thư mục Phim.\\n\\n\"\n",
439
+ " f\"{_list_phim_hint()}\"\n",
440
+ " )\n",
441
+ " out = Path(entry[\"output\"]) if entry.get(\"output\") else v.with_name(v.stem + \".vi.srt\")\n",
442
+ " resolved_jobs.append({\"video\": v, \"srt\": s, \"output\": out})\n",
443
+ " print(f\"Job {i}/{len(JOBS)}: {v.name} + {s.name} -> {out.name}\")\n",
444
+ "elif UPLOAD_WIDGET:\n",
445
+ " print(\"Upload 1 video (.mp4/.mkv) và 1 SRT (.srt):\")\n",
446
+ " uploaded = files.upload()\n",
447
+ " video = srt = None\n",
448
+ " for name in uploaded:\n",
449
+ " p = Path(UPLOAD_DIR) / name\n",
450
+ " p.write_bytes(uploaded[name])\n",
451
+ " low = name.lower()\n",
452
+ " if low.endswith((\".mp4\", \".mkv\", \".avi\", \".mov\")):\n",
453
+ " video = p\n",
454
+ " elif low.endswith(\".srt\"):\n",
455
+ " srt = p\n",
456
+ " if not video or not srt:\n",
457
+ " raise ValueError(\"Cần 1 file video và 1 file .srt\")\n",
458
+ " out = video.with_name(video.stem + \".vi.srt\")\n",
459
+ " resolved_jobs.append({\"video\": video, \"srt\": srt, \"output\": out})\n",
460
+ " print(f\"Job 1/1: {video.name} -> {out.name}\")\n",
461
+ "else:\n",
462
+ " raise ValueError(\"Thêm phim vào JOBS hoặc đặt UPLOAD_WIDGET = True\")\n",
463
+ "\n",
464
+ "print(f\"\\nTổng: {len(resolved_jobs)} phim (chạy tuần tự)\")"
465
+ ],
466
+ "execution_count": null,
467
+ "outputs": []
468
+ },
469
+ {
470
+ "cell_type": "code",
471
+ "metadata": {},
472
+ "source": [
473
+ "# ═══ CHẠY DỊCH SRT (tuần tự từng phim) ═══\n",
474
+ "import sys\n",
475
+ "import os\n",
476
+ "from pathlib import Path\n",
477
+ "\n",
478
+ "sys.path.insert(0, ROOT)\n",
479
+ "from scripts.ensure_llama_colab import ensure_llama_server, llama_bin_valid\n",
480
+ "\n",
481
+ "if not llama_bin_valid(Path(LLAMA_SERVER)):\n",
482
+ " LLAMA_SERVER = str(ensure_llama_server(\n",
483
+ " llama_dir=LLAMA_DIR,\n",
484
+ " drive_cache=DRIVE_CACHE if USE_DRIVE_CACHE else None,\n",
485
+ " allow_build=True,\n",
486
+ " require_mtp=True,\n",
487
+ " force_rebuild=FORCE_LLAMA_REBUILD,\n",
488
+ " ))\n",
489
+ "\n",
490
+ "from translate_srt import build_parser, run_pipeline\n",
491
+ "\n",
492
+ "def _require_file(path, hint: str) -> Path:\n",
493
+ " p = Path(path)\n",
494
+ " if p.is_symlink():\n",
495
+ " p = p.resolve()\n",
496
+ " if not p.is_file():\n",
497
+ " raise FileNotFoundError(f\"{hint}\\n Path: {path}\")\n",
498
+ " return p\n",
499
+ "\n",
500
+ "# Preflight — tránh chạy 2911 cue rồi mới lỗi thiếu model\n",
501
+ "_model = _require_file(MODEL_PATH, \"Thiếu model GGUF — chạy cell 'Tải model GGUF' (~12GB).\")\n",
502
+ "_mmproj = _require_file(MMPROJ_PATH, \"Thiếu mmproj — chạy cell 'Tải model GGUF'.\")\n",
503
+ "_draft = _require_file(DRAFT_PATH, \"Thiếu MTP draft — chạy cell 'Tải model GGUF'.\")\n",
504
+ "print(f\"[ok] model {_model.name} ({_model.stat().st_size / 1e9:.1f} GB)\")\n",
505
+ "\n",
506
+ "if ENABLE_DIARIZE:\n",
507
+ " try:\n",
508
+ " from pyannote.audio import Pipeline # noqa: F401\n",
509
+ " except ImportError as e:\n",
510
+ " raise RuntimeError(\n",
511
+ " f\"ENABLE_DIARIZE=True nhưng pyannote import lỗi: {e}\\n\"\n",
512
+ " \"Chạy lại cell cài Pass 0 hoặc Runtime → Restart session.\"\n",
513
+ " ) from e\n",
514
+ "\n",
515
+ "completed = []\n",
516
+ "\n",
517
+ "for i, job in enumerate(resolved_jobs, 1):\n",
518
+ " v, s, out = job[\"video\"], job[\"srt\"], job[\"output\"]\n",
519
+ " print(\"\\n\" + \"=\" * 50)\n",
520
+ " print(f\"=== Phim {i}/{len(resolved_jobs)}: {v.name} ===\")\n",
521
+ " print(\"=\" * 50)\n",
522
+ "\n",
523
+ " argv = [\n",
524
+ " \"--video\", str(v),\n",
525
+ " \"--input-srt\", str(s),\n",
526
+ " \"--output-srt\", str(out),\n",
527
+ " \"--source-lang\", SOURCE_LANG,\n",
528
+ " \"--target-lang\", TARGET_LANG,\n",
529
+ " \"--llama-server\", LLAMA_SERVER,\n",
530
+ " \"--model\", str(_model.resolve()),\n",
531
+ " \"--mmproj\", str(_mmproj.resolve()),\n",
532
+ " \"--model-draft\", str(_draft.resolve()),\n",
533
+ " \"--limit\", str(LIMIT_CUES),\n",
534
+ " \"--ngl\", \"999\",\n",
535
+ " \"--ctx\", str(CTX_SIZE),\n",
536
+ " \"--scene-frames\", str(SCENE_FRAMES),\n",
537
+ " \"--scene-max-cues\", str(SCENE_MAX_CUES),\n",
538
+ " \"--scene-max-gap\", str(SCENE_MAX_GAP),\n",
539
+ " \"--scene-max-dur\", str(SCENE_MAX_DUR),\n",
540
+ " ]\n",
541
+ " if SKIP_CORRECTION:\n",
542
+ " argv.append(\"--skip-correction\")\n",
543
+ " if NO_MTP:\n",
544
+ " argv.append(\"--no-mtp\")\n",
545
+ " if NO_SCENE_VISION:\n",
546
+ " argv.append(\"--no-scene-vision\")\n",
547
+ " if NO_SCENE_IMAGE:\n",
548
+ " argv.append(\"--no-scene-image\")\n",
549
+ " if ENABLE_DIARIZE:\n",
550
+ " argv += [\n",
551
+ " \"--diarize\",\n",
552
+ " \"--gender-method\", GENDER_METHOD,\n",
553
+ " \"--diarize-device\", DIARIZE_DEVICE,\n",
554
+ " ]\n",
555
+ " if NUM_SPEAKERS and int(NUM_SPEAKERS) > 0:\n",
556
+ " argv += [\"--num-speakers\", str(int(NUM_SPEAKERS))]\n",
557
+ " if HF_TOKEN.strip():\n",
558
+ " argv += [\"--hf-token\", HF_TOKEN.strip()]\n",
559
+ " if not DETECT_GENDER:\n",
560
+ " argv.append(\"--no-gender\")\n",
561
+ "\n",
562
+ " args = build_parser().parse_args(argv)\n",
563
+ " args.log_fn = lambda msg, _i=i: print(f\"[{_i}] {msg}\", flush=True)\n",
564
+ " run_pipeline(args)\n",
565
+ " completed.append(out)\n",
566
+ " print(f\"\\nXong phim {i}: {out}\")\n",
567
+ "\n",
568
+ "print(\"\\n\" + \"=\" * 50)\n",
569
+ "print(f\"HOÀN TẤT {len(completed)} phim:\")\n",
570
+ "for p in completed:\n",
571
+ " print(f\" - {p}\")"
572
+ ],
573
+ "execution_count": null,
574
+ "outputs": []
575
+ },
576
+ {
577
+ "cell_type": "code",
578
+ "metadata": {},
579
+ "source": [
580
+ "# Tải kết quả về máy (tuỳ chọn) + ngắt kết nối\n",
581
+ "from pathlib import Path\n",
582
+ "import time\n",
583
+ "\n",
584
+ "if DOWNLOAD_RESULTS:\n",
585
+ " from google.colab import files\n",
586
+ " for out in completed:\n",
587
+ " out = Path(out)\n",
588
+ " for p in [\n",
589
+ " out,\n",
590
+ " out.with_suffix(\".corrected\" + out.suffix),\n",
591
+ " out.with_suffix(out.suffix + \".report.json\"),\n",
592
+ " ]:\n",
593
+ " if p.is_file():\n",
594
+ " print(f\"Download: {p.name}\")\n",
595
+ " files.download(str(p))\n",
596
+ "else:\n",
597
+ " print(\"Bỏ qua download — file SRT đã nằm trên Drive (xem đường dẫn output trong JOBS).\")\n",
598
+ "\n",
599
+ "if AUTO_UNMOUNT_DRIVE and USE_DRIVE_CACHE:\n",
600
+ " from google.colab import drive\n",
601
+ " drive.flush_and_unmount()\n",
602
+ " print(\"Đã gỡ mount Google Drive.\")\n",
603
+ "\n",
604
+ "if AUTO_DISCONNECT_RUNTIME:\n",
605
+ " delay = max(0, int(DISCONNECT_DELAY_SEC))\n",
606
+ " if delay:\n",
607
+ " print(f\"Ngắt Colab runtime sau {delay}s...\")\n",
608
+ " time.sleep(delay)\n",
609
+ " from google.colab import runtime\n",
610
+ " runtime.unassign()"
611
+ ],
612
+ "execution_count": null,
613
+ "outputs": []
614
+ }
615
+ ],
616
+ "metadata": {
617
+ "accelerator": "GPU",
618
+ "colab": {
619
+ "gpuType": "L4",
620
+ "name": "GemmaSRT_Colab.ipynb",
621
+ "provenance": []
622
+ },
623
+ "kernelspec": {
624
+ "display_name": "Python 3",
625
+ "name": "python3"
626
+ },
627
+ "language_info": {
628
+ "name": "python"
629
+ }
630
+ },
631
+ "nbformat": 4,
632
+ "nbformat_minor": 0
633
+ }
hf-upload/config.yaml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cấu hình mặc định cho Gemma SRT Translate (Colab + Hugging Face)
2
+ # Thay YOUR_USERNAME trước khi upload repo.
3
+
4
+ repo:
5
+ id: "STBack23/gemma-srt-translate"
6
+ type: "model" # hoặc "space" nếu deploy Space sau này
7
+
8
+ models:
9
+ source_repo: "unsloth/gemma-4-12B-it-qat-GGUF"
10
+ files:
11
+ - "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf"
12
+ - "mmproj-F16.gguf"
13
+ - "mtp-gemma-4-12B-it.gguf"
14
+
15
+ llama_cpp:
16
+ git_url: "https://github.com/ggml-org/llama.cpp.git"
17
+ cuda_arch: "89" # L4, RTX 4090 (Ada)
18
+ build_target: "llama-server"
19
+
20
+ translate:
21
+ source_lang: "auto"
22
+ target_lang: "Vietnamese"
23
+ chars_per_sec: 22.0
24
+ max_line_chars: 52
25
+ max_lines: 2
26
+ shorten: false
27
+ scene_max_gap: 1.5
28
+ scene_max_cues: 4
29
+ scene_max_dur: 20.0
30
+ ctx: 8192
31
+ ngl: 999
32
+ temp: 0.7
33
+ use_mtp: true
34
+
35
+ paths:
36
+ local_models: "models"
37
+ colab_root: "/content/gemma-srt"
38
+ drive_root: "/content/drive/MyDrive/Gemma"
39
+ drive_cache: "/content/drive/MyDrive/Gemma/Cache"
40
+ drive_phim: "/content/drive/MyDrive/Gemma/Phim"
hf-upload/diarize_audio.py ADDED
@@ -0,0 +1,1130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speaker diarization + gender estimation for subtitle cues.
2
+
3
+ This module answers "who spoke when" using pyannote.audio and attaches a stable
4
+ speaker label (and a coarse male/female/unknown gender guess) to every SRT cue.
5
+ Gemma then uses those labels to keep Vietnamese forms of address / pronouns
6
+ (ta, hắn, y, muội muội, tỷ tỷ, phu quân, ...) consistent for each character.
7
+
8
+ Pipeline:
9
+ extract_audio (ffmpeg -> 16k mono wav) ->
10
+ run_diarization (pyannote community-1) -> list[SpeakerTurn] ->
11
+ estimate_speaker_genders (median F0 per speaker, optional) ->
12
+ assign_speakers_to_cues (max temporal overlap)
13
+
14
+ Heavy dependencies (pyannote.audio, torch, torchaudio) are imported lazily so
15
+ that the rest of the project keeps working even when they are not installed.
16
+ The Hugging Face access token is required by pyannote and is read, in order,
17
+ from the explicit argument, then the ``HF_TOKEN`` / ``HUGGINGFACE_TOKEN`` env
18
+ vars.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any, Callable
31
+
32
+ if TYPE_CHECKING: # pragma: no cover - typing only
33
+ from translate_srt import Cue
34
+
35
+ # Preferred open-source pipeline (pyannote.audio 4.x). Falls back to the legacy
36
+ # 3.1 pipeline if the newer one is unavailable for the installed version.
37
+ DEFAULT_PIPELINE = "pyannote/speaker-diarization-community-1"
38
+ FALLBACK_PIPELINE = "pyannote/speaker-diarization-3.1"
39
+
40
+ def _load_default_token() -> str:
41
+ """Local convenience token, read from a sibling file that is NOT uploaded.
42
+
43
+ Put your token in ``hf_token.local`` (or ``hf_token.txt``) next to this file
44
+ to have local runs pick it up automatically. The file is git-ignored and is
45
+ never copied by ``prepare_upload.ps1`` so the public code stays secret-free.
46
+ """
47
+ for name in ("hf_token.local", "hf_token.txt"):
48
+ p = Path(__file__).with_name(name)
49
+ try:
50
+ if p.is_file():
51
+ tok = p.read_text(encoding="utf-8").strip()
52
+ if tok:
53
+ return tok
54
+ except OSError:
55
+ pass
56
+ return ""
57
+
58
+
59
+ # Built-in fallback Hugging Face token (used when no arg/env token is supplied).
60
+ # Loaded from a local-only file so the public source never embeds a secret.
61
+ DEFAULT_HF_TOKEN = _load_default_token()
62
+
63
+ _SUBPROCESS_FLAGS = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
64
+
65
+ # Coarse pitch thresholds (Hz) for median fundamental frequency per speaker.
66
+ # A deliberate "unknown" band avoids confidently mislabelling ambiguous voices.
67
+ # Male speech F0 is typically ~85-180 Hz and female ~165-255 Hz, so the bands
68
+ # overlap. We label only confident cases and leave the overlap as "unknown" so
69
+ # the translator infers gender from dialogue/context instead of guessing wrong.
70
+ _MALE_F0_MAX = 150.0
71
+ _FEMALE_F0_MIN = 195.0
72
+ _F0_MIN_HZ = 65.0
73
+ _F0_MAX_HZ = 400.0
74
+
75
+ # Dedicated speech model that predicts age (0..100) and gender (female/male/child).
76
+ DEFAULT_AGE_GENDER_MODEL = "audeering/wav2vec2-large-robust-24-ft-age-gender"
77
+ # The model's gender head order is [female, male, child].
78
+ _AGE_GENDER_LABELS = ("female", "male", "child")
79
+ # Minimum softmax confidence to commit to a gender label; below this we report
80
+ # "unknown" so the translator infers gender from dialogue instead of trusting a
81
+ # coin-flip. Mirrors the deliberate "unknown" band used by the pitch heuristic.
82
+ _MODEL_GENDER_MIN_CONF = 0.55
83
+ # The model often mislabels high-pitched adult women as "child"; only keep the
84
+ # "child" gender when the predicted age is genuinely childlike.
85
+ _CHILD_MAX_AGE = 16.0
86
+
87
+ # Speaker-embedding model used to merge speakers that diarization over-split.
88
+ # speechbrain ECAPA is not gated (no extra HF accept needed) and ships as a
89
+ # pyannote.audio dependency.
90
+ DEFAULT_EMBEDDING_MODEL = "speechbrain/spkrec-ecapa-voxceleb"
91
+ # Conservative cosine-similarity threshold: only merge two labels when we are
92
+ # confident they are the SAME voice that pyannote wrongly split apart.
93
+ _MERGE_SIM_THRESHOLD = 0.70
94
+
95
+ # Cached (model, processor, device) so we only load the ~1 GB model once.
96
+ _AGE_GENDER_CACHE: dict[str, Any] = {}
97
+
98
+
99
+ def age_to_group(age_years: float | None, gender: str = "") -> str:
100
+ """Bucket an age (years) into a coarse life-stage label.
101
+
102
+ Returns one of "child", "teen", "young_adult", "middle_aged", "elderly" or
103
+ "" when age is unknown.
104
+ """
105
+ if gender == "child":
106
+ return "child"
107
+ if age_years is None:
108
+ return ""
109
+ if age_years < 13:
110
+ return "child"
111
+ if age_years < 20:
112
+ return "teen"
113
+ if age_years < 40:
114
+ return "young_adult"
115
+ if age_years < 60:
116
+ return "middle_aged"
117
+ return "elderly"
118
+
119
+
120
+ LogFn = Callable[[str], None]
121
+
122
+
123
+ def _noop(_msg: str) -> None:
124
+ pass
125
+
126
+
127
+ def _fmt_dur(seconds: float) -> str:
128
+ """Human-readable duration, e.g. '1h 23m' or '4m 12s'."""
129
+ if seconds < 0:
130
+ seconds = 0.0
131
+ s = int(round(seconds))
132
+ if s >= 3600:
133
+ h, rem = divmod(s, 3600)
134
+ m, sec = divmod(rem, 60)
135
+ return f"{h}h {m}m" if sec < 30 else f"{h}h {m}m {sec}s"
136
+ if s >= 60:
137
+ m, sec = divmod(s, 60)
138
+ return f"{m}m {sec}s"
139
+ return f"{s}s"
140
+
141
+
142
+ def _log_step(log: LogFn, step: int, total: int, msg: str) -> None:
143
+ log(f"[diarize] [{step}/{total}] {msg}")
144
+
145
+
146
+ def _audio_duration_sec(waveform, sample_rate: int) -> float:
147
+ try:
148
+ n = int(waveform.shape[-1])
149
+ return n / float(sample_rate) if sample_rate else 0.0
150
+ except Exception: # noqa: BLE001
151
+ return 0.0
152
+
153
+
154
+ @dataclass
155
+ class SpeakerTurn:
156
+ start: float
157
+ end: float
158
+ speaker: str
159
+
160
+ @property
161
+ def duration(self) -> float:
162
+ return max(0.0, self.end - self.start)
163
+
164
+
165
+ # --------------------------------------------------------------------------- #
166
+ # Token handling
167
+ # --------------------------------------------------------------------------- #
168
+ def resolve_hf_token(token: str | None) -> str | None:
169
+ if token and token.strip():
170
+ return token.strip()
171
+ for env in ("HF_TOKEN", "HUGGINGFACE_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
172
+ val = os.environ.get(env)
173
+ if val and val.strip():
174
+ return val.strip()
175
+ if DEFAULT_HF_TOKEN and DEFAULT_HF_TOKEN.strip():
176
+ return DEFAULT_HF_TOKEN.strip()
177
+ return None
178
+
179
+
180
+ # --------------------------------------------------------------------------- #
181
+ # Audio extraction
182
+ # --------------------------------------------------------------------------- #
183
+ def extract_audio(
184
+ video: Path,
185
+ out_wav: Path,
186
+ sample_rate: int = 16000,
187
+ max_seconds: float | None = None,
188
+ ) -> Path:
189
+ """Extract a 16 kHz mono PCM wav from *video* (what pyannote expects).
190
+
191
+ When *max_seconds* is given, only that many seconds from the start are
192
+ extracted (so diarization scales with the number of cues being processed).
193
+ """
194
+ cmd = [
195
+ "ffmpeg",
196
+ "-hide_banner",
197
+ "-loglevel",
198
+ "error",
199
+ "-i",
200
+ str(video),
201
+ "-vn",
202
+ "-ac",
203
+ "1",
204
+ "-ar",
205
+ str(sample_rate),
206
+ ]
207
+ if max_seconds and max_seconds > 0:
208
+ cmd += ["-t", f"{max_seconds:.3f}"]
209
+ cmd += [
210
+ "-c:a",
211
+ "pcm_s16le",
212
+ "-y",
213
+ str(out_wav),
214
+ ]
215
+ proc = subprocess.run(
216
+ cmd, capture_output=True, timeout=600, creationflags=_SUBPROCESS_FLAGS
217
+ )
218
+ if proc.returncode != 0:
219
+ raise RuntimeError(
220
+ "ffmpeg audio extraction failed: "
221
+ + proc.stderr.decode("utf-8", "replace").strip()
222
+ )
223
+ if not out_wav.exists():
224
+ raise RuntimeError(f"Audio extraction produced no file at {out_wav}")
225
+ return out_wav
226
+
227
+
228
+ # --------------------------------------------------------------------------- #
229
+ # Diarization
230
+ # --------------------------------------------------------------------------- #
231
+ def _pick_device(preferred: str = "auto") -> str:
232
+ if preferred and preferred != "auto":
233
+ return preferred
234
+ try:
235
+ import torch # noqa: PLC0415
236
+
237
+ return "cuda" if torch.cuda.is_available() else "cpu"
238
+ except Exception: # noqa: BLE001
239
+ return "cpu"
240
+
241
+
242
+ def _patch_speechbrain_lazy(log: LogFn = _noop) -> None:
243
+ """Work around a speechbrain LazyModule bug that breaks pyannote on Windows.
244
+
245
+ ``speechbrain.utils.importutils.LazyModule.ensure_module`` tries to skip the
246
+ lazy import when the caller is ``inspect.py`` (e.g. ``inspect.getmodule``
247
+ probing ``__file__``), but it only matches the POSIX ``/inspect.py`` path. On
248
+ Windows the path uses ``\\`` so the guard misses, the lazy module (e.g.
249
+ ``speechbrain.integrations.k2_fsa``) is force-imported, and it raises
250
+ ``ImportError`` — which ``hasattr`` propagates, crashing pipeline loading.
251
+
252
+ We re-implement ``ensure_module`` with an OS-agnostic ``inspect.py`` check.
253
+ """
254
+ try:
255
+ import importlib as _importlib # noqa: PLC0415
256
+ import inspect as _inspect # noqa: PLC0415
257
+ import os as _os # noqa: PLC0415
258
+ import sys as _sys # noqa: PLC0415
259
+
260
+ from speechbrain.utils import importutils as iu # noqa: PLC0415
261
+ except Exception: # noqa: BLE001 - speechbrain not used by this pipeline
262
+ return
263
+
264
+ if getattr(iu.LazyModule, "_gemma_patched", False):
265
+ return
266
+
267
+ def ensure_module(self, stacklevel: int):
268
+ importer_frame = None
269
+ try:
270
+ importer_frame = _inspect.getframeinfo(_sys._getframe(stacklevel + 1))
271
+ except (AttributeError, ValueError):
272
+ importer_frame = None
273
+ if (
274
+ importer_frame is not None
275
+ and _os.path.basename(importer_frame.filename) == "inspect.py"
276
+ ):
277
+ raise AttributeError()
278
+ if self.lazy_module is None:
279
+ try:
280
+ if self.package is None:
281
+ self.lazy_module = _importlib.import_module(self.target)
282
+ else:
283
+ self.lazy_module = _importlib.import_module(
284
+ f".{self.target}", self.package
285
+ )
286
+ except Exception as e: # noqa: BLE001
287
+ raise ImportError(f"Lazy import of {self!r} failed") from e
288
+ return self.lazy_module
289
+
290
+ iu.LazyModule.ensure_module = ensure_module
291
+ iu.LazyModule._gemma_patched = True
292
+ log("[diarize] applied speechbrain LazyModule Windows compatibility patch.")
293
+
294
+
295
+ def _load_pipeline(hf_token: str, device: str, log: LogFn):
296
+ try:
297
+ from pyannote.audio import Pipeline # noqa: PLC0415
298
+ except ImportError as exc: # pragma: no cover - env dependent
299
+ raise RuntimeError(
300
+ "pyannote.audio is not installed. Install diarization extras:\n"
301
+ " pip install -r requirements-diarize.txt"
302
+ ) from exc
303
+
304
+ import torch # noqa: PLC0415
305
+
306
+ _patch_speechbrain_lazy(log)
307
+
308
+ last_err: Exception | None = None
309
+ for model_id in (DEFAULT_PIPELINE, FALLBACK_PIPELINE):
310
+ try:
311
+ log(f"[diarize] loading pipeline {model_id} ...")
312
+ pipeline = Pipeline.from_pretrained(model_id, token=hf_token)
313
+ if pipeline is None:
314
+ raise RuntimeError(
315
+ "Pipeline.from_pretrained returned None — the HF token is "
316
+ "likely invalid or the model conditions were not accepted at "
317
+ f"https://hf.co/{model_id}"
318
+ )
319
+ try:
320
+ pipeline.to(torch.device(device))
321
+ except Exception: # noqa: BLE001 - keep CPU pipeline if .to fails
322
+ pass
323
+ log(f"[diarize] pipeline ready: {model_id} on {device}.")
324
+ return pipeline
325
+ except Exception as exc: # noqa: BLE001
326
+ last_err = exc
327
+ log(f"[diarize] could not load {model_id}: {exc}")
328
+ raise RuntimeError(
329
+ "Failed to load any diarization pipeline. Most common causes:\n"
330
+ " 1) HF token's account has not accepted ALL gated model conditions (Agree):\n"
331
+ f" https://hf.co/{DEFAULT_PIPELINE}\n"
332
+ f" https://hf.co/{FALLBACK_PIPELINE}\n"
333
+ " https://hf.co/pyannote/segmentation-3.0\n"
334
+ " 2) On Colab: pytorch-lightning version mismatch (community-1).\n"
335
+ f"Last error: {last_err}"
336
+ )
337
+
338
+
339
+ def load_wav(wav: Path):
340
+ """Load a wav into an in-memory (channel, time) float32 tensor + sample rate.
341
+
342
+ Uses ``soundfile`` instead of ``torchaudio.load`` / torchcodec so audio
343
+ decoding does not depend on a working torchcodec/ffmpeg-DLL setup.
344
+ """
345
+ import soundfile as sf # noqa: PLC0415
346
+ import torch # noqa: PLC0415
347
+
348
+ data, sr = sf.read(str(wav), dtype="float32", always_2d=True) # (time, channels)
349
+ waveform = torch.from_numpy(data.T).contiguous() # (channels, time)
350
+ if waveform.shape[0] > 1:
351
+ waveform = waveform.mean(dim=0, keepdim=True)
352
+ return waveform, int(sr)
353
+
354
+
355
+ def _as_annotation(diarization):
356
+ """Return a pyannote Annotation (with ``itertracks``) from a pipeline result.
357
+
358
+ pyannote.audio 4.x (community-1) returns a ``DiarizeOutput`` exposing
359
+ ``speaker_diarization`` and ``exclusive_speaker_diarization``; the legacy 3.1
360
+ pipeline returns an ``Annotation`` directly. Exclusive diarization assigns at
361
+ most one speaker per instant, which is ideal for tagging subtitle cues.
362
+ """
363
+ if hasattr(diarization, "itertracks"):
364
+ return diarization
365
+ for attr in ("exclusive_speaker_diarization", "speaker_diarization"):
366
+ candidate = getattr(diarization, attr, None)
367
+ if candidate is not None and hasattr(candidate, "itertracks"):
368
+ return candidate
369
+ raise RuntimeError(
370
+ f"Unexpected diarization output type without itertracks: {type(diarization)!r}"
371
+ )
372
+
373
+
374
+ def run_diarization(
375
+ waveform,
376
+ sample_rate: int,
377
+ hf_token: str,
378
+ num_speakers: int | None = None,
379
+ min_speakers: int | None = None,
380
+ max_speakers: int | None = None,
381
+ device: str = "auto",
382
+ log: LogFn = _noop,
383
+ ) -> list[SpeakerTurn]:
384
+ """Run pyannote diarization and return merged speaker turns sorted by time."""
385
+ dev = _pick_device(device)
386
+ pipeline = _load_pipeline(hf_token, dev, log)
387
+
388
+ kwargs: dict[str, int] = {}
389
+ if num_speakers and num_speakers > 0:
390
+ kwargs["num_speakers"] = num_speakers
391
+ else:
392
+ if min_speakers and min_speakers > 0:
393
+ kwargs["min_speakers"] = min_speakers
394
+ if max_speakers and max_speakers > 0:
395
+ kwargs["max_speakers"] = max_speakers
396
+
397
+ dur = _audio_duration_sec(waveform, sample_rate)
398
+ kw_hint = ", ".join(f"{k}={v}" for k, v in kwargs.items()) or "auto-detect speakers"
399
+ log(
400
+ f"[diarize] running pyannote on {_fmt_dur(dur)} audio "
401
+ f"({dev}, {kw_hint}) — có thể mất vài phút đến ~1h với phim dài..."
402
+ )
403
+ t0 = time.time()
404
+ audio = {"waveform": waveform, "sample_rate": sample_rate}
405
+ diarization = pipeline(audio, **kwargs)
406
+ log(f"[diarize] pyannote inference done in {_fmt_dur(time.time() - t0)}.")
407
+ annotation = _as_annotation(diarization)
408
+
409
+ turns: list[SpeakerTurn] = []
410
+ for segment, _track, speaker in annotation.itertracks(yield_label=True):
411
+ turns.append(SpeakerTurn(float(segment.start), float(segment.end), str(speaker)))
412
+ turns.sort(key=lambda t: (t.start, t.end))
413
+ n_spk = len({t.speaker for t in turns})
414
+ log(f"[diarize] found {n_spk} speaker(s) across {len(turns)} turn(s).")
415
+ return turns
416
+
417
+
418
+ # --------------------------------------------------------------------------- #
419
+ # Gender estimation (coarse, pitch based)
420
+ # --------------------------------------------------------------------------- #
421
+ def estimate_speaker_genders(
422
+ waveform,
423
+ sr: int,
424
+ turns: list[SpeakerTurn],
425
+ *,
426
+ max_seconds_per_speaker: float = 30.0,
427
+ log: LogFn = _noop,
428
+ ) -> dict[str, str]:
429
+ """Estimate male/female/unknown per speaker from median fundamental frequency.
430
+
431
+ This is a lightweight heuristic (no extra model download) meant only as a
432
+ hint for the translator. Returns a mapping ``{speaker_label: gender}`` where
433
+ gender is one of ``"male"``, ``"female"`` or ``"unknown"``. *waveform* is a
434
+ ``(channel, time)`` float32 tensor as returned by :func:`load_wav`.
435
+ """
436
+ try:
437
+ import torch # noqa: PLC0415
438
+ import torchaudio.functional as AF # noqa: PLC0415
439
+ except Exception as exc: # noqa: BLE001
440
+ log(f"[diarize] gender estimation skipped (torch/torchaudio missing): {exc}")
441
+ return {}
442
+
443
+ if waveform.ndim == 1:
444
+ waveform = waveform.unsqueeze(0)
445
+ elif waveform.shape[0] > 1:
446
+ waveform = waveform.mean(dim=0, keepdim=True)
447
+
448
+ by_speaker: dict[str, list[SpeakerTurn]] = {}
449
+ for t in turns:
450
+ by_speaker.setdefault(t.speaker, []).append(t)
451
+
452
+ genders: dict[str, str] = {}
453
+ for speaker, spk_turns in by_speaker.items():
454
+ spk_turns = sorted(spk_turns, key=lambda t: t.duration, reverse=True)
455
+ chunks: list["torch.Tensor"] = []
456
+ used = 0.0
457
+ for t in spk_turns:
458
+ if used >= max_seconds_per_speaker:
459
+ break
460
+ s = max(0, int(t.start * sr))
461
+ e = min(waveform.shape[-1], int(t.end * sr))
462
+ if e - s < int(0.20 * sr):
463
+ continue
464
+ chunks.append(waveform[..., s:e])
465
+ used += (e - s) / sr
466
+ if not chunks:
467
+ genders[speaker] = "unknown"
468
+ continue
469
+ audio = torch.cat(chunks, dim=-1)
470
+ try:
471
+ pitch = AF.detect_pitch_frequency(audio, sr)
472
+ except Exception as exc: # noqa: BLE001
473
+ log(f"[diarize] pitch detection failed for {speaker}: {exc}")
474
+ genders[speaker] = "unknown"
475
+ continue
476
+ flat = pitch.flatten()
477
+ voiced = flat[(flat >= _F0_MIN_HZ) & (flat <= _F0_MAX_HZ)]
478
+ if voiced.numel() < 5:
479
+ genders[speaker] = "unknown"
480
+ continue
481
+ median_f0 = float(voiced.median().item())
482
+ if median_f0 <= _MALE_F0_MAX:
483
+ gender = "male"
484
+ elif median_f0 >= _FEMALE_F0_MIN:
485
+ gender = "female"
486
+ else:
487
+ gender = "unknown"
488
+ genders[speaker] = gender
489
+ log(f"[diarize] {speaker}: median F0 ~{median_f0:.0f} Hz -> {gender}")
490
+ return genders
491
+
492
+
493
+ # --------------------------------------------------------------------------- #
494
+ # Age + gender estimation (dedicated wav2vec2 model)
495
+ # --------------------------------------------------------------------------- #
496
+ def _weighted_median(values: list[float], weights: list[float]) -> float:
497
+ """Weighted median — robust to a few outlier windows (e.g. noisy segments).
498
+
499
+ Falls back to the plain mean when weights are missing/zero.
500
+ """
501
+ pairs = [(v, w) for v, w in zip(values, weights) if w and w > 0]
502
+ if not pairs:
503
+ return sum(values) / len(values) if values else 0.0
504
+ pairs.sort(key=lambda p: p[0])
505
+ total = sum(w for _, w in pairs)
506
+ half = total / 2.0
507
+ acc = 0.0
508
+ for v, w in pairs:
509
+ acc += w
510
+ if acc >= half:
511
+ return v
512
+ return pairs[-1][0]
513
+
514
+
515
+ def _collect_speaker_windows(
516
+ waveform,
517
+ sr,
518
+ turns,
519
+ *,
520
+ max_seconds_per_speaker: float = 45.0,
521
+ window_sec: float = 8.0,
522
+ min_sec: float = 0.6,
523
+ max_windows: int = 12,
524
+ ):
525
+ """Group turns per speaker and split them into short coherent windows.
526
+
527
+ Returns ``{speaker: [(1-D float32 torch.Tensor, duration_sec), ...]}``.
528
+
529
+ Unlike concatenating one long 30 s clip and mean-pooling it once (which
530
+ biases the predicted age toward the dataset mean and lets a couple of noisy
531
+ seconds flip the gender), we keep each window separate so the caller can
532
+ predict per window and aggregate robustly (weighted median age, weighted
533
+ mean gender probability). Longest turns are used first.
534
+ """
535
+ import torch # noqa: PLC0415
536
+
537
+ if waveform.ndim == 1:
538
+ waveform = waveform.unsqueeze(0)
539
+ elif waveform.shape[0] > 1:
540
+ waveform = waveform.mean(dim=0, keepdim=True)
541
+
542
+ by_speaker: dict[str, list[SpeakerTurn]] = {}
543
+ for t in turns:
544
+ by_speaker.setdefault(t.speaker, []).append(t)
545
+
546
+ win = max(1, int(window_sec * sr))
547
+ min_len = max(1, int(min_sec * sr))
548
+ total_samples = waveform.shape[-1]
549
+
550
+ out: dict[str, list] = {}
551
+ for speaker, spk_turns in by_speaker.items():
552
+ spk_turns = sorted(spk_turns, key=lambda t: t.duration, reverse=True)
553
+ windows: list = []
554
+ used = 0.0
555
+ for t in spk_turns:
556
+ if used >= max_seconds_per_speaker or len(windows) >= max_windows:
557
+ break
558
+ s = max(0, int(t.start * sr))
559
+ e = min(total_samples, int(t.end * sr))
560
+ pos = s
561
+ while pos + min_len <= e:
562
+ if used >= max_seconds_per_speaker or len(windows) >= max_windows:
563
+ break
564
+ w_end = min(e, pos + win)
565
+ if w_end - pos < min_len:
566
+ break
567
+ seg = waveform[..., pos:w_end].flatten()
568
+ dur = (w_end - pos) / sr
569
+ windows.append((seg, dur))
570
+ used += dur
571
+ pos = w_end
572
+ if windows:
573
+ out[speaker] = windows
574
+ return out
575
+
576
+
577
+ def _load_age_gender_model(model_name: str, device: str, log: LogFn):
578
+ """Load (and cache) the audeering age/gender wav2vec2 model + processor."""
579
+ cache_key = f"{model_name}@{device}"
580
+ if cache_key in _AGE_GENDER_CACHE:
581
+ return _AGE_GENDER_CACHE[cache_key]
582
+
583
+ import torch # noqa: PLC0415
584
+ import torch.nn as nn # noqa: PLC0415
585
+ from transformers import Wav2Vec2Processor # noqa: PLC0415
586
+ from transformers.models.wav2vec2.modeling_wav2vec2 import ( # noqa: PLC0415
587
+ Wav2Vec2Model,
588
+ Wav2Vec2PreTrainedModel,
589
+ )
590
+
591
+ class ModelHead(nn.Module):
592
+ def __init__(self, config, num_labels):
593
+ super().__init__()
594
+ self.dense = nn.Linear(config.hidden_size, config.hidden_size)
595
+ self.dropout = nn.Dropout(config.final_dropout)
596
+ self.out_proj = nn.Linear(config.hidden_size, num_labels)
597
+
598
+ def forward(self, features):
599
+ x = self.dropout(features)
600
+ x = self.dense(x)
601
+ x = torch.tanh(x)
602
+ x = self.dropout(x)
603
+ return self.out_proj(x)
604
+
605
+ class AgeGenderModel(Wav2Vec2PreTrainedModel):
606
+ # This model has no tied weights, so the mapping is always empty.
607
+ _tied_weights_keys = {}
608
+
609
+ def __init__(self, config):
610
+ super().__init__(config)
611
+ self.wav2vec2 = Wav2Vec2Model(config)
612
+ self.age = ModelHead(config, 1)
613
+ self.gender = ModelHead(config, 3)
614
+ # transformers >=5 builds `all_tied_weights_keys` in post_init();
615
+ # older releases only have init_weights(). Support both.
616
+ if hasattr(self, "post_init"):
617
+ self.post_init()
618
+ else:
619
+ self.init_weights()
620
+ # `from_pretrained` calls `all_tied_weights_keys.keys()` while
621
+ # loading; guarantee it is a dict so the load never crashes.
622
+ if not isinstance(getattr(self, "all_tied_weights_keys", None), dict):
623
+ self.all_tied_weights_keys = {}
624
+
625
+ def forward(self, input_values):
626
+ outputs = self.wav2vec2(input_values)
627
+ hidden_states = outputs[0]
628
+ hidden_states = torch.mean(hidden_states, dim=1)
629
+ logits_age = self.age(hidden_states)
630
+ logits_gender = torch.softmax(self.gender(hidden_states), dim=1)
631
+ return hidden_states, logits_age, logits_gender
632
+
633
+ log(f"[diarize] loading age/gender model {model_name} (first run downloads ~1 GB)...")
634
+ processor = Wav2Vec2Processor.from_pretrained(model_name)
635
+ model = AgeGenderModel.from_pretrained(model_name)
636
+ tied = getattr(model, "all_tied_weights_keys", None)
637
+ if not isinstance(tied, dict):
638
+ model.all_tied_weights_keys = {}
639
+ model = model.to(torch.device(device)).eval()
640
+ _AGE_GENDER_CACHE[cache_key] = (model, processor, device)
641
+ log(f"[diarize] age/gender model ready on {device}.")
642
+ return _AGE_GENDER_CACHE[cache_key]
643
+
644
+
645
+ def estimate_speaker_demographics_model(
646
+ waveform,
647
+ sr: int,
648
+ turns: list[SpeakerTurn],
649
+ *,
650
+ model_name: str = DEFAULT_AGE_GENDER_MODEL,
651
+ device: str = "auto",
652
+ max_seconds_per_speaker: float = 30.0,
653
+ log: LogFn = _noop,
654
+ ) -> dict[str, dict[str, Any]]:
655
+ """Predict gender (female/male/child) + age per speaker with a dedicated model.
656
+
657
+ Returns ``{speaker: {"gender": str, "age": float|None, "age_group": str}}``.
658
+ """
659
+ import torch # noqa: PLC0415
660
+
661
+ dev = _pick_device(device)
662
+ model, processor, dev = _load_age_gender_model(model_name, dev, log)
663
+ windows_by_speaker = _collect_speaker_windows(
664
+ waveform, sr, turns, max_seconds_per_speaker=max_seconds_per_speaker
665
+ )
666
+ n_spk = len(windows_by_speaker)
667
+ log(f"[diarize] age/gender: predicting {n_spk} speaker(s) (per-window)...")
668
+
669
+ out: dict[str, dict[str, Any]] = {}
670
+ for i, (speaker, windows) in enumerate(windows_by_speaker.items(), start=1):
671
+ ages: list[float] = []
672
+ weights: list[float] = []
673
+ gender_prob_sum = torch.zeros(len(_AGE_GENDER_LABELS))
674
+ for seg, dur in windows:
675
+ signal = seg.detach().cpu().numpy().astype("float32")
676
+ inputs = processor(signal, sampling_rate=sr)
677
+ values = torch.from_numpy(
678
+ inputs["input_values"][0].reshape(1, -1)
679
+ ).to(torch.device(dev))
680
+ with torch.no_grad():
681
+ _hidden, logits_age, logits_gender = model(values)
682
+ ages.append(float(logits_age[0].item()) * 100.0)
683
+ weights.append(dur)
684
+ gender_prob_sum += logits_gender[0].detach().cpu().float() * dur
685
+
686
+ total_w = sum(weights)
687
+ if total_w <= 0:
688
+ continue
689
+ mean_gender = gender_prob_sum / total_w
690
+ probs = {lab: float(mean_gender[k].item()) for k, lab in enumerate(_AGE_GENDER_LABELS)}
691
+ # Weighted median age is robust to a few outlier windows; mean-pooling
692
+ # one long clip instead pulled every speaker toward the dataset mean.
693
+ age_years = _weighted_median(ages, weights)
694
+ raw_gender = max(probs, key=probs.get)
695
+ gender = raw_gender
696
+ # Fix the common "adult woman -> child" mislabel: drop child when the
697
+ # predicted age is clearly adult, choosing the better of female/male.
698
+ if gender == "child" and age_years >= _CHILD_MAX_AGE:
699
+ gender = "female" if probs["female"] >= probs["male"] else "male"
700
+ # Don't commit to a low-confidence guess; "unknown" lets the translator
701
+ # infer gender from dialogue/context instead.
702
+ if gender != "child" and probs[gender] < _MODEL_GENDER_MIN_CONF:
703
+ gender = "unknown"
704
+ age_group = age_to_group(age_years, gender)
705
+ out[speaker] = {
706
+ "gender": gender,
707
+ "age": round(age_years, 1),
708
+ "age_group": age_group,
709
+ }
710
+ demoted = "" if gender == raw_gender else f" (raw={raw_gender})"
711
+ log(
712
+ f"[diarize] age/gender: [{i}/{n_spk}] {speaker}: ~{age_years:.0f}y, "
713
+ f"gender={gender}{demoted} (conf {probs[raw_gender]:.2f}) "
714
+ f"({age_group or 'n/a'}) from {len(windows)} window(s)/{total_w:.0f}s"
715
+ )
716
+ return out
717
+
718
+
719
+ def estimate_speaker_demographics(
720
+ waveform,
721
+ sr: int,
722
+ turns: list[SpeakerTurn],
723
+ *,
724
+ method: str = "auto",
725
+ model_name: str = DEFAULT_AGE_GENDER_MODEL,
726
+ device: str = "auto",
727
+ log: LogFn = _noop,
728
+ ) -> dict[str, dict[str, Any]]:
729
+ """Estimate per-speaker demographics, dispatching by *method*.
730
+
731
+ method:
732
+ "model" -> dedicated wav2vec2 age/gender model (accurate, age + gender).
733
+ "pitch" -> lightweight F0 heuristic (gender only, no age).
734
+ "auto" -> try the model, fall back to pitch on any failure.
735
+ Returns ``{speaker: {"gender","age","age_group"}}``.
736
+ """
737
+ if method in ("model", "auto"):
738
+ try:
739
+ log(f"[diarize] age/gender: loading model ({model_name})...")
740
+ return estimate_speaker_demographics_model(
741
+ waveform, sr, turns, model_name=model_name, device=device, log=log
742
+ )
743
+ except Exception as exc: # noqa: BLE001
744
+ log(f"[diarize] age/gender model failed, using pitch heuristic: {exc}")
745
+
746
+ log("[diarize] age/gender: pitch heuristic (F0) per speaker...")
747
+ genders = estimate_speaker_genders(waveform, sr, turns, log=log)
748
+ return {
749
+ spk: {"gender": g, "age": None, "age_group": ""} for spk, g in genders.items()
750
+ }
751
+
752
+
753
+ # --------------------------------------------------------------------------- #
754
+ # Speaker merge by voice embedding (fix over-segmentation)
755
+ # --------------------------------------------------------------------------- #
756
+ def _patch_symlink_fallback(log: LogFn = _noop) -> None:
757
+ """Make os.symlink fall back to copying on Windows without admin rights.
758
+
759
+ speechbrain (used by the ECAPA embedding model) symlinks files from the HF
760
+ cache into its save dir. On Windows this raises ``WinError 1314`` unless
761
+ Developer Mode or admin is enabled, which aborted the speaker merge. Copying
762
+ instead is harmless for these small model files.
763
+ """
764
+ if getattr(os, "_gemma_symlink_patched", False):
765
+ return
766
+ import shutil # noqa: PLC0415
767
+
768
+ _orig_symlink = os.symlink
769
+
770
+ def _safe_symlink(src, dst, *args, **kwargs): # type: ignore[no-untyped-def]
771
+ try:
772
+ return _orig_symlink(src, dst, *args, **kwargs)
773
+ except OSError:
774
+ src_s, dst_s = os.fspath(src), os.fspath(dst)
775
+ if os.path.isdir(src_s):
776
+ shutil.copytree(src_s, dst_s, dirs_exist_ok=True)
777
+ else:
778
+ os.makedirs(os.path.dirname(dst_s) or ".", exist_ok=True)
779
+ shutil.copyfile(src_s, dst_s)
780
+ return None
781
+
782
+ os.symlink = _safe_symlink # type: ignore[assignment]
783
+ os._gemma_symlink_patched = True # type: ignore[attr-defined]
784
+ log("[diarize] applied Windows symlink-copy fallback for embedding model.")
785
+
786
+
787
+ def _speaker_embeddings(
788
+ waveform,
789
+ sr: int,
790
+ turns: list[SpeakerTurn],
791
+ *,
792
+ model_name: str,
793
+ device: str,
794
+ max_seconds_per_speaker: float = 30.0,
795
+ log: LogFn = _noop,
796
+ ) -> dict[str, Any]:
797
+ """Compute one mean, L2-normalized voice embedding per diarization label."""
798
+ import numpy as np # noqa: PLC0415
799
+ import torch # noqa: PLC0415
800
+
801
+ os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
802
+ _patch_symlink_fallback(log)
803
+
804
+ from pyannote.audio.pipelines.speaker_verification import ( # noqa: PLC0415
805
+ PretrainedSpeakerEmbedding,
806
+ )
807
+
808
+ dev = _pick_device(device)
809
+ # A persistent cache dir avoids speechbrain writing into a literal "None"
810
+ # folder (its default when cache_dir is None) and re-downloading each run.
811
+ cache_dir = Path(tempfile.gettempdir()) / "gemma_speechbrain"
812
+ cache_dir.mkdir(parents=True, exist_ok=True)
813
+ try:
814
+ emb_model = PretrainedSpeakerEmbedding(
815
+ model_name, device=torch.device(dev), cache_dir=str(cache_dir)
816
+ )
817
+ except TypeError:
818
+ # Older pyannote signatures may not accept cache_dir.
819
+ emb_model = PretrainedSpeakerEmbedding(model_name, device=torch.device(dev))
820
+ windows_by_speaker = _collect_speaker_windows(
821
+ waveform, sr, turns, max_seconds_per_speaker=max_seconds_per_speaker
822
+ )
823
+ out: dict[str, Any] = {}
824
+ for speaker, windows in windows_by_speaker.items():
825
+ vecs: list[Any] = []
826
+ for seg, _dur in windows:
827
+ wav = seg.detach().float().reshape(1, 1, -1)
828
+ try:
829
+ emb = emb_model(wav)
830
+ except Exception: # noqa: BLE001 - skip too-short/odd windows
831
+ continue
832
+ v = np.asarray(emb, dtype="float64").reshape(-1)
833
+ norm = float(np.linalg.norm(v))
834
+ if norm > 0 and np.isfinite(norm):
835
+ vecs.append(v / norm)
836
+ if not vecs:
837
+ continue
838
+ mean = np.mean(vecs, axis=0)
839
+ norm = float(np.linalg.norm(mean))
840
+ if norm > 0 and np.isfinite(norm):
841
+ out[speaker] = mean / norm
842
+ return out
843
+
844
+
845
+ def merge_speakers_by_embedding(
846
+ waveform,
847
+ sr: int,
848
+ turns: list[SpeakerTurn],
849
+ *,
850
+ threshold: float = _MERGE_SIM_THRESHOLD,
851
+ model_name: str = DEFAULT_EMBEDDING_MODEL,
852
+ device: str = "auto",
853
+ log: LogFn = _noop,
854
+ ) -> list[SpeakerTurn]:
855
+ """Conservatively merge labels pyannote split for the same voice.
856
+
857
+ Builds one embedding per speaker label and unions any two labels whose cosine
858
+ similarity is >= *threshold*. This only fixes over-segmentation (one person
859
+ wrongly split into several labels); it never separates speakers. Returns the
860
+ (possibly relabeled) turns; on any failure it returns the input unchanged.
861
+ """
862
+ import numpy as np # noqa: PLC0415
863
+
864
+ labels = sorted({t.speaker for t in turns})
865
+ if len(labels) < 2:
866
+ return turns
867
+ try:
868
+ embs = _speaker_embeddings(
869
+ waveform, sr, turns, model_name=model_name, device=device, log=log
870
+ )
871
+ except Exception as exc: # noqa: BLE001
872
+ log(f"[diarize] speaker-embedding merge unavailable ({exc}); skipping.")
873
+ return turns
874
+
875
+ labels = [lab for lab in labels if lab in embs]
876
+ if len(labels) < 2:
877
+ return turns
878
+
879
+ durations: dict[str, float] = {}
880
+ for t in turns:
881
+ durations[t.speaker] = durations.get(t.speaker, 0.0) + t.duration
882
+
883
+ parent = {lab: lab for lab in labels}
884
+
885
+ def find(x: str) -> str:
886
+ while parent[x] != x:
887
+ parent[x] = parent[parent[x]]
888
+ x = parent[x]
889
+ return x
890
+
891
+ def union(a: str, b: str) -> None:
892
+ ra, rb = find(a), find(b)
893
+ if ra == rb:
894
+ return
895
+ # Keep the longer-spoken label as the group representative.
896
+ if durations.get(ra, 0.0) >= durations.get(rb, 0.0):
897
+ parent[rb] = ra
898
+ else:
899
+ parent[ra] = rb
900
+
901
+ for i in range(len(labels)):
902
+ for j in range(i + 1, len(labels)):
903
+ a, b = labels[i], labels[j]
904
+ sim = float(np.dot(embs[a], embs[b]))
905
+ if sim >= threshold:
906
+ log(f"[diarize] merge {a} + {b} (cosine {sim:.2f} >= {threshold:.2f}).")
907
+ union(a, b)
908
+ elif sim >= threshold - 0.1:
909
+ log(f"[diarize] near-miss {a} | {b} (cosine {sim:.2f} < {threshold:.2f}).")
910
+
911
+ mapping = {lab: find(lab) for lab in labels}
912
+ n_before = len(labels)
913
+ n_after = len(set(mapping.values()))
914
+ if n_after == n_before:
915
+ log(f"[diarize] no speakers merged ({n_before} kept).")
916
+ return turns
917
+
918
+ merged = [
919
+ SpeakerTurn(t.start, t.end, mapping.get(t.speaker, t.speaker)) for t in turns
920
+ ]
921
+ log(f"[diarize] merged speakers: {n_before} -> {n_after}.")
922
+ return merged
923
+
924
+
925
+ # --------------------------------------------------------------------------- #
926
+ # Cue assignment
927
+ # --------------------------------------------------------------------------- #
928
+ def _overlap(a_start: float, a_end: float, b_start: float, b_end: float) -> float:
929
+ return max(0.0, min(a_end, b_end) - max(a_start, b_start))
930
+
931
+
932
+ def _speaker_gender(info: Any) -> str:
933
+ return info if isinstance(info, str) else (info or {}).get("gender", "")
934
+
935
+
936
+ def _speaker_age_group(info: Any) -> str:
937
+ return "" if isinstance(info, str) else (info or {}).get("age_group", "")
938
+
939
+
940
+ def assign_speakers_to_cues(
941
+ cues: list["Cue"],
942
+ turns: list[SpeakerTurn],
943
+ demographics: dict[str, Any] | None = None,
944
+ *,
945
+ nearest_gap_tolerance: float = 0.4,
946
+ log: LogFn = _noop,
947
+ ) -> int:
948
+ """Tag each cue with the speaker that overlaps it most in time.
949
+
950
+ When a cue overlaps no turn (a small diarization gap), it is assigned to the
951
+ nearest turn within *nearest_gap_tolerance* seconds instead of being left
952
+ blank. *demographics* maps a speaker label to either a gender string or a
953
+ dict ``{"gender","age_group",...}``. Returns the number of cues tagged.
954
+ """
955
+ demographics = demographics or {}
956
+ tagged = 0
957
+ total = len(cues)
958
+ log(f"[diarize] gán speaker vào {total} cue(s)...")
959
+ report_every = max(200, total // 20) if total else 200
960
+ for idx, cue in enumerate(cues, start=1):
961
+ best_speaker = ""
962
+ best_overlap = 0.0
963
+ nearest_speaker = ""
964
+ nearest_gap = float("inf")
965
+ for t in turns:
966
+ if t.end <= cue.start:
967
+ gap = cue.start - t.end
968
+ if gap < nearest_gap:
969
+ nearest_gap = gap
970
+ nearest_speaker = t.speaker
971
+ continue
972
+ if t.start >= cue.end:
973
+ gap = t.start - cue.end
974
+ if gap < nearest_gap:
975
+ nearest_gap = gap
976
+ nearest_speaker = t.speaker
977
+ break
978
+ ov = _overlap(cue.start, cue.end, t.start, t.end)
979
+ if ov > best_overlap:
980
+ best_overlap = ov
981
+ best_speaker = t.speaker
982
+ if not best_speaker and nearest_speaker and nearest_gap <= nearest_gap_tolerance:
983
+ best_speaker = nearest_speaker
984
+ if best_speaker:
985
+ info = demographics.get(best_speaker, {})
986
+ cue.speaker = best_speaker
987
+ cue.speaker_gender = _speaker_gender(info)
988
+ cue.speaker_age_group = _speaker_age_group(info)
989
+ tagged += 1
990
+ if idx % report_every == 0 or idx == total:
991
+ log(f"[diarize] gán cue: {idx}/{total} ({tagged} tagged)...")
992
+ return tagged
993
+
994
+
995
+ # --------------------------------------------------------------------------- #
996
+ # High level entry point
997
+ # --------------------------------------------------------------------------- #
998
+ def diarize_and_tag_cues(
999
+ video: Path,
1000
+ cues: list["Cue"],
1001
+ *,
1002
+ hf_token: str | None = None,
1003
+ num_speakers: int | None = None,
1004
+ min_speakers: int | None = None,
1005
+ max_speakers: int | None = None,
1006
+ device: str = "auto",
1007
+ detect_gender: bool = True,
1008
+ gender_method: str = "auto",
1009
+ age_gender_model: str = DEFAULT_AGE_GENDER_MODEL,
1010
+ merge_speakers: bool = True,
1011
+ merge_threshold: float = _MERGE_SIM_THRESHOLD,
1012
+ embedding_model: str = DEFAULT_EMBEDDING_MODEL,
1013
+ log: LogFn = _noop,
1014
+ ) -> dict[str, Any]:
1015
+ """Run the full diarization flow and tag *cues* in place.
1016
+
1017
+ Returns the ``{speaker: {"gender","age","age_group"}}`` registry (possibly
1018
+ empty). Raises RuntimeError with an actionable message when the HF token is
1019
+ missing.
1020
+ """
1021
+ token = resolve_hf_token(hf_token)
1022
+ if not token:
1023
+ raise RuntimeError(
1024
+ "Hugging Face token required for diarization. Provide --hf-token, set "
1025
+ "the HF_TOKEN environment variable, and accept the model conditions at "
1026
+ f"https://hf.co/{DEFAULT_PIPELINE}"
1027
+ )
1028
+
1029
+ # When the speaker count is fixed by the user, pyannote already targets it,
1030
+ # so the embedding merge (which only fixes over-segmentation) is skipped.
1031
+ do_merge = bool(merge_speakers) and not (num_speakers and num_speakers > 0)
1032
+ total_steps = 4 + (1 if detect_gender else 0) + (1 if do_merge else 0)
1033
+ step = 0
1034
+
1035
+ def _next(msg: str) -> None:
1036
+ nonlocal step
1037
+ step += 1
1038
+ _log_step(log, step, total_steps, msg)
1039
+
1040
+ t_all = time.time()
1041
+ dev = _pick_device(device)
1042
+ n_cues = len(cues)
1043
+ _next(
1044
+ f"bắt đầu Pass 0 — {n_cues} cue(s), device={dev}, "
1045
+ f"gender={gender_method if detect_gender else 'off'}"
1046
+ )
1047
+
1048
+ tmp_dir = Path(tempfile.mkdtemp(prefix="gemma_diarize_"))
1049
+ wav = tmp_dir / "audio.wav"
1050
+ max_seconds = None
1051
+ if cues:
1052
+ max_seconds = max((c.end for c in cues), default=0.0) + 5.0
1053
+ _next(f"tách audio ffmpeg (16 kHz mono, tối đa {_fmt_dur(max_seconds or 0)})...")
1054
+ t0 = time.time()
1055
+ extract_audio(video, wav, max_seconds=max_seconds)
1056
+ wav_mb = wav.stat().st_size / (1024 * 1024) if wav.is_file() else 0.0
1057
+ log(f"[diarize] audio wav ready: {wav_mb:.0f} MB in {_fmt_dur(time.time() - t0)}.")
1058
+
1059
+ _next("nạp waveform + chạy pyannote (who spoke when)...")
1060
+ t0 = time.time()
1061
+ waveform, sr = load_wav(wav)
1062
+ aud_dur = _audio_duration_sec(waveform, sr)
1063
+ log(f"[diarize] waveform: {_fmt_dur(aud_dur)} @ {sr} Hz.")
1064
+
1065
+ turns = run_diarization(
1066
+ waveform,
1067
+ sr,
1068
+ token,
1069
+ num_speakers=num_speakers,
1070
+ min_speakers=min_speakers,
1071
+ max_speakers=max_speakers,
1072
+ device=device,
1073
+ log=log,
1074
+ )
1075
+ log(f"[diarize] bước pyannote xong trong {_fmt_dur(time.time() - t0)}.")
1076
+ if not turns:
1077
+ log("[diarize] no speaker turns detected; skipping speaker tags.")
1078
+ return {}
1079
+
1080
+ if do_merge:
1081
+ n_lbl = len({t.speaker for t in turns})
1082
+ _next(
1083
+ f"gộp speaker bị tách nhầm bằng embedding giọng "
1084
+ f"(ngưỡng {merge_threshold:.2f}, {n_lbl} nhãn)..."
1085
+ )
1086
+ t0 = time.time()
1087
+ turns = merge_speakers_by_embedding(
1088
+ waveform,
1089
+ sr,
1090
+ turns,
1091
+ threshold=merge_threshold,
1092
+ model_name=embedding_model,
1093
+ device=device,
1094
+ log=log,
1095
+ )
1096
+ log(f"[diarize] gộp speaker xong trong {_fmt_dur(time.time() - t0)}.")
1097
+
1098
+ demographics: dict[str, Any] = {}
1099
+ if detect_gender:
1100
+ _next(
1101
+ f"ước lượng giới/tuổi ({gender_method}) cho "
1102
+ f"{len({t.speaker for t in turns})} speaker(s)..."
1103
+ )
1104
+ t0 = time.time()
1105
+ try:
1106
+ demographics = estimate_speaker_demographics(
1107
+ waveform,
1108
+ sr,
1109
+ turns,
1110
+ method=gender_method,
1111
+ model_name=age_gender_model,
1112
+ device=device,
1113
+ log=log,
1114
+ )
1115
+ log(f"[diarize] demographics xong trong {_fmt_dur(time.time() - t0)}.")
1116
+ except Exception as exc: # noqa: BLE001
1117
+ log(
1118
+ f"[diarize] demographics failed ({exc}) — gán speaker không có giới/tuổi."
1119
+ )
1120
+
1121
+ _next("gán speaker + giới/tuổi vào từng cue...")
1122
+ t0 = time.time()
1123
+ tagged = assign_speakers_to_cues(cues, turns, demographics, log=log)
1124
+ n_spk = len({c.speaker for c in cues if c.speaker})
1125
+ log(
1126
+ f"[diarize] Pass 0 xong — {tagged}/{n_cues} cue tagged, "
1127
+ f"{n_spk} speaker(s), tổng {_fmt_dur(time.time() - t_all)} "
1128
+ f"(gán cue: {_fmt_dur(time.time() - t0)})."
1129
+ )
1130
+ return demographics
hf-upload/notebook.ipynb ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Gemma SRT Translate — Google Colab\n",
8
+ "\n",
9
+ "Dịch phụ đề SRT bằng **Gemma 4 12B vision** (llama-server + MTP).\n",
10
+ "\n",
11
+ "**Yêu cầu:** GPU **T4/L4** — Runtime → Change runtime type → GPU.\n",
12
+ "\n",
13
+ "**Lần đầu:** tải model ~12GB (+ build llama-server một lần, lưu `llama-server-bin.tgz` lên Drive). \n",
14
+ "**Các lần sau:** restore **`Gemma/Cache/llama-server-bin.tgz`** (~vài giây) — **không còn cell build dài**.\n",
15
+ "\n",
16
+ "---\n",
17
+ "\n",
18
+ "## Pipeline (2 bước)\n",
19
+ "\n",
20
+ "| Bước | Tên | Mặc định | Việc làm |\n",
21
+ "|------|-----|----------|----------|\n",
22
+ "| **Pass 1** | OCR / sửa SRT gốc | **Tắt** (`SKIP_CORRECTION = True`) | Cắt khung hình video → đọc sub burn-in → sửa lỗi chính tả trong `.srt` gốc |\n",
23
+ "| **Pass 2** | Dịch | **Luôn chạy** | Dịch sang tiếng Việt (có ngữ cảnh scene + xưng hô) |\n",
24
+ "\n",
25
+ "**`SKIP_CORRECTION` — đọc nhanh:**\n",
26
+ "- `True` (**mặc định**) → **bỏ Pass 1**, chỉ dịch → **nhanh hơn**\n",
27
+ "- `False` → **chạy Pass 1** OCR/sửa SRT trước khi dịch → **chậm hơn**, dùng khi sub gốc hay sai chữ\n",
28
+ "\n",
29
+ "**Dịch tự nhiên (lồng tiếng):** budget rộng (22 ký tự/s, tối đa ~104 ký tự/cue), **tắt Pass 2b rút gọn** mặc định.\n",
30
+ "\n",
31
+ "---\n",
32
+ "\n",
33
+ "## Cách chạy\n",
34
+ "\n",
35
+ "1. Đặt phim + SRT vào **Drive → Gemma → Phim** (cùng tên, ví dụ `Phim A.mp4` + `Phim A.srt`)\n",
36
+ "2. Sửa **`PHIM_STEMS`** ở cell cấu hình — tên file **không đuôi**, khớp y hệt trên Drive\n",
37
+ "3. Mở từ [HF /colab](https://huggingface.co/STBack23/gemma-srt-translate/colab) → **Runtime → Run all**\n",
38
+ "4. File `*.vi.srt` ra **Gemma/Phim** (model cache: **Gemma/Cache**)\n",
39
+ "\n",
40
+ "Sau khi xong: `DOWNLOAD_RESULTS` tải file về trình duyệt; `AUTO_UNMOUNT_DRIVE` / `AUTO_DISCONNECT_RUNTIME` tự gỡ Drive và ngắt runtime."
41
+ ]
42
+ },
43
+ {
44
+ "cell_type": "code",
45
+ "metadata": {},
46
+ "source": [
47
+ "# ═══ CẤU HÌNH — sửa trước khi chạy ═══\n",
48
+ "from pathlib import Path\n",
49
+ "\n",
50
+ "HF_REPO = \"STBack23/gemma-srt-translate\"\n",
51
+ "MODELS_REPO = \"unsloth/gemma-4-12B-it-qat-GGUF\" # repo model GGUF (~12GB) — không cần sửa\n",
52
+ "\n",
53
+ "# ── Google Drive ─────────────────────────────────────────────\n",
54
+ "USE_DRIVE_CACHE = True # True = cache model + llama-server trên Drive (khuyến nghị)\n",
55
+ "\n",
56
+ "# Thư mục trên Drive: Gemma/Cache (model) + Gemma/Phim (video + SRT vào/ra)\n",
57
+ "DRIVE_ROOT = \"/content/drive/MyDrive/Gemma\"\n",
58
+ "DRIVE_CACHE = f\"{DRIVE_ROOT}/Cache\"\n",
59
+ "DRIVE_PHIM_DIR = f\"{DRIVE_ROOT}/Phim\"\n",
60
+ "\n",
61
+ "VIDEO_EXTS = (\".mp4\", \".mkv\", \".avi\", \".mov\")\n",
62
+ "\n",
63
+ "def job(stem, ext_video=\".mp4\"):\n",
64
+ " \"\"\"stem = tên file trên Drive (KHÔNG đuôi), khớp y hệt — kể cả dấu, khoảng trắng.\"\"\"\n",
65
+ " base = f\"{DRIVE_PHIM_DIR}/{stem}\"\n",
66
+ " return {\"stem\": stem, \"video\": f\"{base}{ext_video}\", \"srt\": f\"{base}.srt\"}\n",
67
+ "\n",
68
+ "# ── Danh sách phim cần dịch ──────────────────────────────────\n",
69
+ "# Tên trong PHIM_STEMS phải trùng file trong Gemma/Phim (không gồm .mp4 / .srt)\n",
70
+ "PHIM_STEMS = [\n",
71
+ " \"Thư Gửi Mùa Hè\", # → Gemma/Phim/Thư Gửi Mùa Hè.mp4 + Thư Gửi Mùa Hè.srt\n",
72
+ " # \"ten-phim-khac\",\n",
73
+ "]\n",
74
+ "JOBS = [job(stem) for stem in PHIM_STEMS]\n",
75
+ "# Cách khác — đường dẫn đầy đủ:\n",
76
+ "# JOBS = [{\"video\": f\"{DRIVE_PHIM_DIR}/a.mp4\", \"srt\": f\"{DRIVE_PHIM_DIR}/a.srt\"}]\n",
77
+ "# Upload 1 phim qua nút Colab: PHIM_STEMS = [], JOBS = [], UPLOAD_WIDGET = True\n",
78
+ "UPLOAD_WIDGET = False\n",
79
+ "\n",
80
+ "# ── Tùy chọn dịch ───────────────────────────────────────────\n",
81
+ "SOURCE_LANG = \"auto\" # ngôn ngữ SRT gốc (\"auto\" = model tự nhận)\n",
82
+ "TARGET_LANG = \"Vietnamese\" # ngôn ngữ đích\n",
83
+ "\n",
84
+ "# Pass 1 — OCR / sửa SRT gốc từ khung hình video (xem bảng ở đầu notebook):\n",
85
+ "# True → BỎ QUA Pass 1 — chỉ dịch Pass 2 (MẶC ĐỊNH, nhanh)\n",
86
+ "# False → CHẠY Pass 1 OCR/sửa sub trước — chậm hơn, dùng khi sub hay sai chữ\n",
87
+ "SKIP_CORRECTION = True\n",
88
+ "\n",
89
+ "LIMIT_CUES = 0 # 0 = dịch hết file; đặt 20 để thử nhanh vài cue đầu\n",
90
+ "\n",
91
+ "# ── Sau khi dịch xong ─────────────────────────────────────────\n",
92
+ "DOWNLOAD_RESULTS = True # True = tải *.vi.srt về trình duyệt\n",
93
+ "AUTO_UNMOUNT_DRIVE = True # True = gỡ mount Google Drive\n",
94
+ "AUTO_DISCONNECT_RUNTIME = True # True = ngắt runtime Colab (tiết kiệm GPU quota)\n",
95
+ "DISCONNECT_DELAY_SEC = 15 # giây chờ trước khi ngắt (để download kịp)\n",
96
+ "\n",
97
+ "# ── Đường dẫn nội bộ Colab (thường không cần sửa) ────────────\n",
98
+ "ROOT = \"/content/gemma-srt\"\n",
99
+ "MODELS_DIR = f\"{ROOT}/models\"\n",
100
+ "LLAMA_DIR = \"/content/llama.cpp\"\n",
101
+ "LLAMA_SERVER = f\"{LLAMA_DIR}/build/bin/llama-server\""
102
+ ],
103
+ "execution_count": null,
104
+ "outputs": []
105
+ },
106
+ {
107
+ "cell_type": "code",
108
+ "metadata": {},
109
+ "source": [
110
+ "# Kiểm tra GPU\n",
111
+ "!nvidia-smi --query-gpu=name,memory.total --format=csv,noheader\n",
112
+ "\n",
113
+ "import torch\n",
114
+ "if not torch.cuda.is_available():\n",
115
+ " raise RuntimeError(\"Chưa có GPU. Runtime → Change runtime type → GPU (L4).\")"
116
+ ],
117
+ "execution_count": null,
118
+ "outputs": []
119
+ },
120
+ {
121
+ "cell_type": "code",
122
+ "metadata": {},
123
+ "source": [
124
+ "# Mount Google Drive — Gemma/Cache (model) + Gemma/Phim (phim)\n",
125
+ "if USE_DRIVE_CACHE:\n",
126
+ " from google.colab import drive\n",
127
+ " from pathlib import Path\n",
128
+ " import os\n",
129
+ " drive.mount(\"/content/drive\")\n",
130
+ " os.makedirs(DRIVE_CACHE, exist_ok=True)\n",
131
+ " os.makedirs(DRIVE_PHIM_DIR, exist_ok=True)\n",
132
+ " print(f\"Cache model: {DRIVE_CACHE}\")\n",
133
+ " print(f\"Thư mục phim: {DRIVE_PHIM_DIR}\")\n",
134
+ " cache_tgz = Path(f\"{DRIVE_CACHE}/llama-server-bin.tgz\")\n",
135
+ " if cache_tgz.is_file():\n",
136
+ " sz = cache_tgz.stat().st_size\n",
137
+ " mb = sz / 1_048_576\n",
138
+ " print(f\" llama-server-bin.tgz: {mb:.1f} MB\" + (\" OK\" if sz > 5_000_000 else \" HỎNG\"))\n",
139
+ " else:\n",
140
+ " for old in (\"llama-server.tgz\", \"llama-server\"):\n",
141
+ " p = Path(f\"{DRIVE_CACHE}/{old}\")\n",
142
+ " if p.is_file():\n",
143
+ " print(f\" cache cũ {old}: {p.stat().st_size/1024:.0f} KB — sẽ thay bằng llama-server-bin.tgz\")\n",
144
+ " if not any(Path(f\"{DRIVE_CACHE}/{n}\").is_file() for n in (\"llama-server-bin.tgz\", \"llama-server.tgz\", \"llama-server\")):\n",
145
+ " print(\" llama-server cache: chưa có (lần đầu build ~20–50 phút)\")\n",
146
+ " phim = Path(DRIVE_PHIM_DIR)\n",
147
+ " files = sorted(p.name for p in phim.iterdir() if p.is_file())\n",
148
+ " if files:\n",
149
+ " print(f\" ({len(files)} file trong Phim)\")\n",
150
+ " for name in files[:12]:\n",
151
+ " print(f\" - {name}\")\n",
152
+ " if len(files) > 12:\n",
153
+ " print(f\" ... và {len(files) - 12} file khác\")\n",
154
+ " else:\n",
155
+ " print(\" (chưa có file — upload video + .srt vào Gemma/Phim)\")"
156
+ ],
157
+ "execution_count": null,
158
+ "outputs": []
159
+ },
160
+ {
161
+ "cell_type": "code",
162
+ "metadata": {},
163
+ "source": [
164
+ "# Cài dependency + tải code từ Hugging Face\n",
165
+ "!pip install -q huggingface_hub pyyaml\n",
166
+ "\n",
167
+ "from huggingface_hub import snapshot_download\n",
168
+ "from pathlib import Path\n",
169
+ "import os\n",
170
+ "\n",
171
+ "if HF_REPO.startswith(\"YOUR_\"):\n",
172
+ " raise ValueError(\"Sửa HF_REPO thành repo Hugging Face của bạn, ví dụ: 'username/gemma-srt-translate'\")\n",
173
+ "\n",
174
+ "print(f\"[code] Downloading {HF_REPO} ...\")\n",
175
+ "snapshot_download(\n",
176
+ " repo_id=HF_REPO,\n",
177
+ " repo_type=\"model\",\n",
178
+ " local_dir=ROOT,\n",
179
+ " local_dir_use_symlinks=False,\n",
180
+ ")\n",
181
+ "print(f\"[code] OK: {ROOT}\")\n",
182
+ "assert Path(f\"{ROOT}/translate_srt.py\").is_file(), \"translate_srt.py not found in repo\""
183
+ ],
184
+ "execution_count": null,
185
+ "outputs": []
186
+ },
187
+ {
188
+ "cell_type": "code",
189
+ "metadata": {},
190
+ "source": [
191
+ "# Tải model GGUF (~12GB) — cache trên Drive nếu bật\n",
192
+ "from huggingface_hub import hf_hub_download\n",
193
+ "from pathlib import Path\n",
194
+ "import os\n",
195
+ "import shutil\n",
196
+ "\n",
197
+ "MODEL_FILES = [\n",
198
+ " \"gemma-4-12B-it-qat-UD-Q4_K_XL.gguf\",\n",
199
+ " \"mmproj-F16.gguf\",\n",
200
+ " \"mtp-gemma-4-12B-it.gguf\",\n",
201
+ "]\n",
202
+ "\n",
203
+ "models_dest = MODELS_DIR\n",
204
+ "if USE_DRIVE_CACHE:\n",
205
+ " models_dest = f\"{DRIVE_CACHE}/models\"\n",
206
+ "Path(models_dest).mkdir(parents=True, exist_ok=True)\n",
207
+ "\n",
208
+ "paths = {}\n",
209
+ "for name in MODEL_FILES:\n",
210
+ " dest_file = Path(models_dest) / name\n",
211
+ " if dest_file.is_file():\n",
212
+ " print(f\"[model] cached: {name}\")\n",
213
+ " paths[name] = dest_file\n",
214
+ " continue\n",
215
+ " print(f\"[model] downloading {MODELS_REPO}/{name} ...\")\n",
216
+ " p = hf_hub_download(\n",
217
+ " repo_id=MODELS_REPO,\n",
218
+ " filename=name,\n",
219
+ " local_dir=models_dest,\n",
220
+ " local_dir_use_symlinks=False,\n",
221
+ " )\n",
222
+ " paths[name] = Path(p)\n",
223
+ " print(f\" OK: {p}\")\n",
224
+ "\n",
225
+ "# Symlink/copy vào ROOT/models cho translate_srt.py\n",
226
+ "Path(MODELS_DIR).mkdir(parents=True, exist_ok=True)\n",
227
+ "for name, src in paths.items():\n",
228
+ " dst = Path(MODELS_DIR) / name\n",
229
+ " if not dst.exists():\n",
230
+ " try:\n",
231
+ " os.symlink(src, dst)\n",
232
+ " except OSError:\n",
233
+ " shutil.copy2(src, dst)\n",
234
+ "\n",
235
+ "MODEL_PATH = Path(MODELS_DIR) / MODEL_FILES[0]\n",
236
+ "MMPROJ_PATH = Path(MODELS_DIR) / MODEL_FILES[1]\n",
237
+ "DRAFT_PATH = Path(MODELS_DIR) / MODEL_FILES[2]\n",
238
+ "print(f\"\\nModels ready in {MODELS_DIR}\")"
239
+ ],
240
+ "execution_count": null,
241
+ "outputs": []
242
+ },
243
+ {
244
+ "cell_type": "code",
245
+ "metadata": {},
246
+ "source": [
247
+ "# llama-server — restore từ Drive (Gemma/Cache/llama-server-bin.tgz)\n",
248
+ "import sys\n",
249
+ "\n",
250
+ "sys.path.insert(0, ROOT)\n",
251
+ "from scripts.ensure_llama_colab import ensure_llama_server\n",
252
+ "\n",
253
+ "LLAMA_SERVER = str(ensure_llama_server(\n",
254
+ " llama_dir=LLAMA_DIR,\n",
255
+ " drive_cache=DRIVE_CACHE if USE_DRIVE_CACHE else None,\n",
256
+ " allow_build=False,\n",
257
+ "))\n",
258
+ "print(f\"LLAMA_SERVER = {LLAMA_SERVER}\")\n"
259
+ ],
260
+ "execution_count": null,
261
+ "outputs": []
262
+ },
263
+ {
264
+ "cell_type": "code",
265
+ "metadata": {},
266
+ "source": [
267
+ "# Kiểm tra ffmpeg\n",
268
+ "!ffmpeg -version | head -1"
269
+ ],
270
+ "execution_count": null,
271
+ "outputs": []
272
+ },
273
+ {
274
+ "cell_type": "code",
275
+ "metadata": {},
276
+ "source": [
277
+ "# Kiểm tra danh sách JOBS (Drive) hoặc upload 1 phim qua widget\n",
278
+ "from pathlib import Path\n",
279
+ "from google.colab import files\n",
280
+ "import os\n",
281
+ "\n",
282
+ "UPLOAD_DIR = \"/content/uploads\"\n",
283
+ "os.makedirs(UPLOAD_DIR, exist_ok=True)\n",
284
+ "resolved_jobs = []\n",
285
+ "\n",
286
+ "def _list_phim_hint() -> str:\n",
287
+ " phim = Path(DRIVE_PHIM_DIR)\n",
288
+ " if not phim.is_dir():\n",
289
+ " return f\"Chưa có thư mục: {DRIVE_PHIM_DIR}\"\n",
290
+ " lines = [\"File hiện có trong Gemma/Phim:\"]\n",
291
+ " for p in sorted(phim.iterdir()):\n",
292
+ " if p.is_file():\n",
293
+ " lines.append(f\" - {p.name}\")\n",
294
+ " if len(lines) == 1:\n",
295
+ " lines.append(\" (trống — upload video + .srt vào đây)\")\n",
296
+ " return \"\\n\".join(lines)\n",
297
+ "\n",
298
+ "def _find_video(stem: str, preferred: str | None = None) -> Path | None:\n",
299
+ " if preferred:\n",
300
+ " p = Path(preferred)\n",
301
+ " if p.is_file():\n",
302
+ " return p\n",
303
+ " for ext in VIDEO_EXTS:\n",
304
+ " p = Path(DRIVE_PHIM_DIR) / f\"{stem}{ext}\"\n",
305
+ " if p.is_file():\n",
306
+ " return p\n",
307
+ " return None\n",
308
+ "\n",
309
+ "if JOBS:\n",
310
+ " for i, entry in enumerate(JOBS, 1):\n",
311
+ " stem = entry.get(\"stem\") or Path(entry[\"video\"]).stem\n",
312
+ " v = _find_video(stem, entry.get(\"video\"))\n",
313
+ " s = Path(entry[\"srt\"])\n",
314
+ " if v is None:\n",
315
+ " raise FileNotFoundError(\n",
316
+ " f\"Job {i}: không thấy video cho '{stem}'\\n\"\n",
317
+ " f\"Đã thử: {', '.join(stem + ext for ext in VIDEO_EXTS)}\\n\\n\"\n",
318
+ " f\"Tên trong PHIM_STEMS phải KHỚP Y HỆT tên file (không đuôi).\\n\\n\"\n",
319
+ " f\"{_list_phim_hint()}\"\n",
320
+ " )\n",
321
+ " if not s.is_file():\n",
322
+ " s = Path(DRIVE_PHIM_DIR) / f\"{stem}.srt\"\n",
323
+ " if not s.is_file():\n",
324
+ " raise FileNotFoundError(\n",
325
+ " f\"Job {i}: không thấy SRT: {s}\\n\\n\"\n",
326
+ " f\"Cần file: {stem}.srt cùng thư mục Phim.\\n\\n\"\n",
327
+ " f\"{_list_phim_hint()}\"\n",
328
+ " )\n",
329
+ " out = Path(entry[\"output\"]) if entry.get(\"output\") else v.with_name(v.stem + \".vi.srt\")\n",
330
+ " resolved_jobs.append({\"video\": v, \"srt\": s, \"output\": out})\n",
331
+ " print(f\"Job {i}/{len(JOBS)}: {v.name} + {s.name} -> {out.name}\")\n",
332
+ "elif UPLOAD_WIDGET:\n",
333
+ " print(\"Upload 1 video (.mp4/.mkv) và 1 SRT (.srt):\")\n",
334
+ " uploaded = files.upload()\n",
335
+ " video = srt = None\n",
336
+ " for name in uploaded:\n",
337
+ " p = Path(UPLOAD_DIR) / name\n",
338
+ " p.write_bytes(uploaded[name])\n",
339
+ " low = name.lower()\n",
340
+ " if low.endswith((\".mp4\", \".mkv\", \".avi\", \".mov\")):\n",
341
+ " video = p\n",
342
+ " elif low.endswith(\".srt\"):\n",
343
+ " srt = p\n",
344
+ " if not video or not srt:\n",
345
+ " raise ValueError(\"Cần 1 file video và 1 file .srt\")\n",
346
+ " out = video.with_name(video.stem + \".vi.srt\")\n",
347
+ " resolved_jobs.append({\"video\": video, \"srt\": srt, \"output\": out})\n",
348
+ " print(f\"Job 1/1: {video.name} -> {out.name}\")\n",
349
+ "else:\n",
350
+ " raise ValueError(\"Thêm phim vào JOBS hoặc đặt UPLOAD_WIDGET = True\")\n",
351
+ "\n",
352
+ "print(f\"\\nTổng: {len(resolved_jobs)} phim (chạy tuần tự)\")"
353
+ ],
354
+ "execution_count": null,
355
+ "outputs": []
356
+ },
357
+ {
358
+ "cell_type": "code",
359
+ "metadata": {},
360
+ "source": [
361
+ "# ═══ CHẠY DỊCH SRT (tuần tự từng phim) ═══\n",
362
+ "import sys\n",
363
+ "import os\n",
364
+ "from pathlib import Path\n",
365
+ "\n",
366
+ "sys.path.insert(0, ROOT)\n",
367
+ "from scripts.ensure_llama_colab import ensure_llama_server, llama_bin_valid\n",
368
+ "\n",
369
+ "if not llama_bin_valid(Path(LLAMA_SERVER)):\n",
370
+ " LLAMA_SERVER = str(ensure_llama_server(\n",
371
+ " llama_dir=LLAMA_DIR,\n",
372
+ " drive_cache=DRIVE_CACHE if USE_DRIVE_CACHE else None,\n",
373
+ " allow_build=False,\n",
374
+ " ))\n",
375
+ "\n",
376
+ "from translate_srt import build_parser, run_pipeline\n",
377
+ "\n",
378
+ "completed = []\n",
379
+ "\n",
380
+ "for i, job in enumerate(resolved_jobs, 1):\n",
381
+ " v, s, out = job[\"video\"], job[\"srt\"], job[\"output\"]\n",
382
+ " print(\"\\n\" + \"=\" * 50)\n",
383
+ " print(f\"=== Phim {i}/{len(resolved_jobs)}: {v.name} ===\")\n",
384
+ " print(\"=\" * 50)\n",
385
+ "\n",
386
+ " argv = [\n",
387
+ " \"--video\", str(v),\n",
388
+ " \"--input-srt\", str(s),\n",
389
+ " \"--output-srt\", str(out),\n",
390
+ " \"--source-lang\", SOURCE_LANG,\n",
391
+ " \"--target-lang\", TARGET_LANG,\n",
392
+ " \"--llama-server\", LLAMA_SERVER,\n",
393
+ " \"--model\", str(MODEL_PATH),\n",
394
+ " \"--mmproj\", str(MMPROJ_PATH),\n",
395
+ " \"--model-draft\", str(DRAFT_PATH),\n",
396
+ " \"--limit\", str(LIMIT_CUES),\n",
397
+ " \"--ngl\", \"999\",\n",
398
+ " \"--ctx\", \"8192\",\n",
399
+ " ]\n",
400
+ " if SKIP_CORRECTION:\n",
401
+ " argv.append(\"--skip-correction\")\n",
402
+ "\n",
403
+ " args = build_parser().parse_args(argv)\n",
404
+ " args.log_fn = lambda msg, _i=i: print(f\"[{_i}] {msg}\", flush=True)\n",
405
+ " run_pipeline(args)\n",
406
+ " completed.append(out)\n",
407
+ " print(f\"\\nXong phim {i}: {out}\")\n",
408
+ "\n",
409
+ "print(\"\\n\" + \"=\" * 50)\n",
410
+ "print(f\"HOÀN TẤT {len(completed)} phim:\")\n",
411
+ "for p in completed:\n",
412
+ " print(f\" - {p}\")"
413
+ ],
414
+ "execution_count": null,
415
+ "outputs": []
416
+ },
417
+ {
418
+ "cell_type": "code",
419
+ "metadata": {},
420
+ "source": [
421
+ "# Tải kết quả về máy (tuỳ chọn) + ngắt kết nối\n",
422
+ "from pathlib import Path\n",
423
+ "import time\n",
424
+ "\n",
425
+ "if DOWNLOAD_RESULTS:\n",
426
+ " from google.colab import files\n",
427
+ " for out in completed:\n",
428
+ " out = Path(out)\n",
429
+ " for p in [\n",
430
+ " out,\n",
431
+ " out.with_suffix(\".corrected\" + out.suffix),\n",
432
+ " out.with_suffix(out.suffix + \".report.json\"),\n",
433
+ " ]:\n",
434
+ " if p.is_file():\n",
435
+ " print(f\"Download: {p.name}\")\n",
436
+ " files.download(str(p))\n",
437
+ "else:\n",
438
+ " print(\"Bỏ qua download — file SRT đã nằm trên Drive (xem đường dẫn output trong JOBS).\")\n",
439
+ "\n",
440
+ "if AUTO_UNMOUNT_DRIVE and USE_DRIVE_CACHE:\n",
441
+ " from google.colab import drive\n",
442
+ " drive.flush_and_unmount()\n",
443
+ " print(\"Đã gỡ mount Google Drive.\")\n",
444
+ "\n",
445
+ "if AUTO_DISCONNECT_RUNTIME:\n",
446
+ " delay = max(0, int(DISCONNECT_DELAY_SEC))\n",
447
+ " if delay:\n",
448
+ " print(f\"Ngắt Colab runtime sau {delay}s...\")\n",
449
+ " time.sleep(delay)\n",
450
+ " from google.colab import runtime\n",
451
+ " runtime.unassign()"
452
+ ],
453
+ "execution_count": null,
454
+ "outputs": []
455
+ }
456
+ ],
457
+ "metadata": {
458
+ "accelerator": "GPU",
459
+ "colab": {
460
+ "gpuType": "L4",
461
+ "provenance": []
462
+ },
463
+ "kernelspec": {
464
+ "display_name": "Python 3",
465
+ "name": "python3"
466
+ },
467
+ "language_info": {
468
+ "name": "python"
469
+ }
470
+ },
471
+ "nbformat": 4,
472
+ "nbformat_minor": 0
473
+ }
hf-upload/requirements-diarize.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Optional dependencies for speaker diarization (Pass 0).
2
+ # Only needed when you enable "Phân tích giọng nói nhân vật" / --diarize.
3
+ #
4
+ # Setup (Windows, NVIDIA GPU recommended):
5
+ # 1. Install PyTorch with CUDA matching your driver, e.g.:
6
+ # pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu124
7
+ # (CPU-only also works but is much slower:)
8
+ # pip install torch torchaudio
9
+ # 2. pip install -r requirements-diarize.txt
10
+ # 3. Create a Hugging Face token at https://hf.co/settings/tokens
11
+ # 4. Accept the model conditions at:
12
+ # https://hf.co/pyannote/speaker-diarization-community-1
13
+ # 5. Set HF_TOKEN env var or paste the token into the app's "HF token" field.
14
+ #
15
+ # torchcodec is used by pyannote.audio 4.x for audio decoding and needs ffmpeg
16
+ # available on PATH (this project already ships/uses ffmpeg).
17
+
18
+ # Audio is decoded with soundfile (not torchcodec) for a robust Windows setup.
19
+ pyannote.audio>=4.0
20
+ torch
21
+ torchaudio
22
+ soundfile>=0.11
23
+ # transformers is needed for the audeering age/gender model (--gender-method model).
24
+ transformers>=4.40
hf-upload/scripts/build_llama_server.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Build llama-server with CUDA for Colab (T4=75, L4=89).
3
+ set -euo pipefail
4
+
5
+ LLAMA_DIR="${1:-/content/llama.cpp}"
6
+ CUDA_ARCH="${2:-75}"
7
+
8
+ export CUDA_HOME="${CUDA_HOME:-/usr/local/cuda}"
9
+ export PATH="${CUDA_HOME}/bin:${PATH}"
10
+ export LD_LIBRARY_PATH="${CUDA_HOME}/lib64:${LD_LIBRARY_PATH:-}"
11
+
12
+ echo "[build] CUDA_HOME=${CUDA_HOME} arch=${CUDA_ARCH}"
13
+
14
+ if [[ ! -d "${LLAMA_DIR}/.git" ]]; then
15
+ git clone --depth 1 https://github.com/ggml-org/llama.cpp.git "${LLAMA_DIR}"
16
+ else
17
+ git -C "${LLAMA_DIR}" fetch origin master
18
+ git -C "${LLAMA_DIR}" reset --hard origin/master
19
+ fi
20
+
21
+ rm -rf "${LLAMA_DIR}/build"
22
+
23
+ cmake -S "${LLAMA_DIR}" -B "${LLAMA_DIR}/build" \
24
+ -DGGML_CUDA=ON \
25
+ -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCH}" \
26
+ -DCMAKE_BUILD_TYPE=Release \
27
+ -DCMAKE_CUDA_COMPILER="${CUDA_HOME}/bin/nvcc"
28
+
29
+ cmake --build "${LLAMA_DIR}/build" --config Release -j "$(nproc)" --target llama-server
30
+
31
+ BIN="${LLAMA_DIR}/build/bin/llama-server"
32
+ if [[ ! -x "${BIN}" ]]; then
33
+ echo "[build] ERROR: ${BIN} not found"
34
+ exit 1
35
+ fi
36
+
37
+ echo "[build] OK: ${BIN}"
38
+ "${BIN}" --version 2>/dev/null || true
hf-upload/scripts/download_models.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tải model Gemma 4 QAT + mmproj + MTP từ Hugging Face."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ DEFAULT_SOURCE = "unsloth/gemma-4-12B-it-qat-GGUF"
10
+ DEFAULT_FILES = [
11
+ "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
12
+ "mmproj-F16.gguf",
13
+ "mtp-gemma-4-12B-it.gguf",
14
+ ]
15
+
16
+
17
+ def download(
18
+ dest: Path,
19
+ source_repo: str = DEFAULT_SOURCE,
20
+ files: list[str] | None = None,
21
+ token: str | None = None,
22
+ ) -> dict[str, Path]:
23
+ from huggingface_hub import hf_hub_download
24
+
25
+ dest.mkdir(parents=True, exist_ok=True)
26
+ files = files or DEFAULT_FILES
27
+ result: dict[str, Path] = {}
28
+
29
+ for name in files:
30
+ print(f"[download] {source_repo}/{name} -> {dest}/")
31
+ path = hf_hub_download(
32
+ repo_id=source_repo,
33
+ filename=name,
34
+ local_dir=str(dest),
35
+ local_dir_use_symlinks=False,
36
+ token=token,
37
+ )
38
+ result[name] = Path(path)
39
+ print(f" OK: {path}")
40
+
41
+ return result
42
+
43
+
44
+ def main() -> int:
45
+ p = argparse.ArgumentParser(description="Download Gemma 4 GGUF files for SRT translation")
46
+ p.add_argument(
47
+ "--dest",
48
+ type=Path,
49
+ default=Path("models"),
50
+ help="Output directory (default: ./models)",
51
+ )
52
+ p.add_argument(
53
+ "--source-repo",
54
+ default=DEFAULT_SOURCE,
55
+ help=f"Hugging Face model repo (default: {DEFAULT_SOURCE})",
56
+ )
57
+ p.add_argument(
58
+ "--file",
59
+ action="append",
60
+ dest="files",
61
+ help="Download specific file(s); repeat flag. Default: all 3 core files.",
62
+ )
63
+ p.add_argument("--token", default=None, help="HF token (optional, for gated models)")
64
+ args = p.parse_args()
65
+
66
+ download(args.dest, args.source_repo, args.files, args.token)
67
+ print(f"\nDone. Models in: {args.dest.resolve()}")
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())
hf-upload/scripts/ensure_llama_colab.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Restore or build llama-server for Google Colab (CUDA + Drive cache)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import tarfile
10
+ import time
11
+ from pathlib import Path
12
+
13
+ MIN_LLAMA_TGZ_BYTES = 5_000_000
14
+ DRIVE_LLAMA_TGZ = "llama-server-bin.tgz"
15
+ DRIVE_COPY_BLOCK = 4 * 1024 * 1024
16
+ # Gemma 4 draft-MTP merged 2026-06-07 (llama.cpp PR #23398).
17
+ MIN_LLAMA_BUILD_FOR_MTP = 9549
18
+ # Tag b9553 (9e3b928) — verified Gemma 4 draft-mtp on L4; newer master may regress.
19
+ LLAMA_CPP_PIN = "b9553"
20
+
21
+
22
+ def _fmt_size(n: int) -> str:
23
+ if n >= 1_048_576:
24
+ return f"{n / 1_048_576:.1f} MB"
25
+ return f"{n / 1024:.0f} KB"
26
+
27
+
28
+ def _run(cmd, *, check=True, label="", live=False):
29
+ print(f"[llama] $ {' '.join(map(str, cmd))}", flush=True)
30
+ if live:
31
+ proc = subprocess.Popen(
32
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
33
+ )
34
+ assert proc.stdout is not None
35
+ for line in proc.stdout:
36
+ print(line, end="", flush=True)
37
+ proc.wait()
38
+ if check and proc.returncode:
39
+ raise RuntimeError(f"{label or cmd[0]} failed (exit {proc.returncode})")
40
+ return proc
41
+ r = subprocess.run(cmd, capture_output=True, text=True)
42
+ if r.stdout:
43
+ print(r.stdout[-4000:])
44
+ if r.returncode and r.stderr:
45
+ print("--- stderr ---")
46
+ print(r.stderr[-8000:])
47
+ if check and r.returncode:
48
+ raise RuntimeError(f"{label or cmd[0]} failed (exit {r.returncode})")
49
+ return r
50
+
51
+
52
+ def _setup_cuda_env() -> Path | None:
53
+ for cuda in (Path("/usr/local/cuda"), Path("/usr/local/cuda-12.2"), Path("/usr/local/cuda-12.4")):
54
+ nvcc = cuda / "bin" / "nvcc"
55
+ if nvcc.is_file():
56
+ os.environ["CUDA_HOME"] = str(cuda)
57
+ os.environ["PATH"] = f"{cuda / 'bin'}:" + os.environ.get("PATH", "")
58
+ os.environ["LD_LIBRARY_PATH"] = f"{cuda / 'lib64'}:" + os.environ.get("LD_LIBRARY_PATH", "")
59
+ print(f"[llama] CUDA: {cuda} | nvcc OK")
60
+ return nvcc
61
+ return None
62
+
63
+
64
+ def _cuda_arch() -> str:
65
+ try:
66
+ out = subprocess.check_output(
67
+ ["nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader"],
68
+ text=True, timeout=10,
69
+ ).strip().splitlines()[0]
70
+ return out.replace(".", "")
71
+ except Exception:
72
+ return "75"
73
+
74
+
75
+ def _llama_env_for(p: Path) -> dict:
76
+ env = os.environ.copy()
77
+ lib = str(p.parent)
78
+ env["LD_LIBRARY_PATH"] = f"{lib}:{env.get('LD_LIBRARY_PATH', '')}"
79
+ return env
80
+
81
+
82
+ def llama_version_text(p: Path) -> str:
83
+ p = Path(p)
84
+ try:
85
+ r = subprocess.run(
86
+ [str(p), "--version"],
87
+ capture_output=True, text=True, timeout=60, env=_llama_env_for(p),
88
+ )
89
+ except Exception:
90
+ return ""
91
+ return ((r.stdout or "") + (r.stderr or "")).strip()
92
+
93
+
94
+ def llama_build_number(p: Path) -> int | None:
95
+ """Legacy llama.cpp prints ``version: 9535 (hash)``; newer builds may use semver."""
96
+ text = llama_version_text(p)
97
+ m = re.search(r"version:\s*(\d+)", text)
98
+ if not m:
99
+ return None
100
+ n = int(m.group(1))
101
+ # Newer releases use small semver (e.g. version: 1) — not comparable to b9549.
102
+ return n if n >= 1000 else None
103
+
104
+
105
+ def _llama_help_text(p: Path) -> str:
106
+ try:
107
+ r = subprocess.run(
108
+ [str(p), "--help"],
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=120,
112
+ env=_llama_env_for(p),
113
+ )
114
+ except Exception:
115
+ return ""
116
+ return (r.stdout or "") + (r.stderr or "")
117
+
118
+
119
+ def llama_supports_mtp(p: Path) -> bool:
120
+ if not llama_bin_valid(p):
121
+ return False
122
+ if "draft-mtp" in _llama_help_text(p):
123
+ return True
124
+ n = llama_build_number(p)
125
+ return n is not None and n >= MIN_LLAMA_BUILD_FOR_MTP
126
+
127
+
128
+ def llama_bin_valid(p: Path) -> bool:
129
+ """Shared build: llama-server ~17–20 KB + .so cùng thư mục."""
130
+ p = Path(p)
131
+ if not p.is_file() or not os.access(p, os.X_OK):
132
+ return False
133
+ if p.stat().st_size < 8_000:
134
+ return False
135
+ return bool(llama_version_text(p))
136
+
137
+
138
+ def setup_ld_library_path(bin_path: Path) -> None:
139
+ lib = str(bin_path.parent)
140
+ os.environ["LD_LIBRARY_PATH"] = f"{lib}:{os.environ.get('LD_LIBRARY_PATH', '')}"
141
+
142
+
143
+ def _print_bin_info(bin_path: Path, *, require_mtp: bool) -> None:
144
+ ver = llama_version_text(bin_path)
145
+ build = llama_build_number(bin_path)
146
+ mtp = llama_supports_mtp(bin_path)
147
+ print(f"[llama] OK: {bin_path} ({_fmt_size(bin_path.stat().st_size)})")
148
+ if ver:
149
+ print(f"[llama] {ver.splitlines()[0]}")
150
+ if require_mtp:
151
+ if mtp:
152
+ tag = f"build {build}" if build else "draft-mtp in --help"
153
+ print(f"[llama] MTP Gemma 4: supported ({tag})")
154
+ else:
155
+ print("[llama] MTP Gemma 4: NOT supported — will rebuild")
156
+
157
+
158
+ def _purge_invalid_llama_bin(build_dir: Path) -> bool:
159
+ bin_dir = build_dir / "bin"
160
+ if not bin_dir.is_dir():
161
+ return False
162
+ p = bin_dir / "llama-server"
163
+ if p.is_file() and not llama_bin_valid(p):
164
+ print(f"[llama] Xóa binary hỏng (cmake sẽ link lại): {p} ({_fmt_size(p.stat().st_size)})")
165
+ p.unlink(missing_ok=True)
166
+ return True
167
+ return False
168
+
169
+
170
+ def _invalidate_llama_artifacts(llama_dir: Path, drive_cache: Path | None) -> None:
171
+ build_dir = llama_dir / "build"
172
+ if build_dir.exists():
173
+ print(f"[llama] Xóa build cũ: {build_dir}")
174
+ shutil.rmtree(build_dir, ignore_errors=True)
175
+ if drive_cache:
176
+ drive_tgz = drive_cache / DRIVE_LLAMA_TGZ
177
+ if drive_tgz.is_file():
178
+ print(f"[llama] Xóa cache Drive cũ: {drive_tgz}")
179
+ drive_tgz.unlink(missing_ok=True)
180
+ for legacy_name in ("llama-server", "llama-server.tgz"):
181
+ legacy = drive_cache / legacy_name
182
+ if legacy.is_file():
183
+ legacy.unlink(missing_ok=True)
184
+
185
+
186
+ def _drive_copy_file(src: Path, dest: Path, *, label: str = "", min_bytes: int = 1) -> int:
187
+ src = Path(src)
188
+ dest = Path(dest)
189
+ dest.parent.mkdir(parents=True, exist_ok=True)
190
+ sz_src = src.stat().st_size
191
+ if dest.exists():
192
+ dest.unlink()
193
+ tmp = dest.with_name(dest.name + ".part")
194
+ if tmp.exists():
195
+ tmp.unlink()
196
+ with open(src, "rb") as fsrc, open(tmp, "wb") as fdst:
197
+ while True:
198
+ chunk = fsrc.read(DRIVE_COPY_BLOCK)
199
+ if not chunk:
200
+ break
201
+ fdst.write(chunk)
202
+ fdst.flush()
203
+ os.fsync(fdst.fileno())
204
+ sz_tmp = tmp.stat().st_size
205
+ if sz_tmp != sz_src or sz_tmp < min_bytes:
206
+ tmp.unlink(missing_ok=True)
207
+ raise RuntimeError(f"{label}copy thất bại: {sz_tmp} bytes, cần {sz_src}")
208
+ tmp.replace(dest)
209
+ dest.chmod(0o755)
210
+ for _ in range(5):
211
+ sz_dest = dest.stat().st_size
212
+ if sz_dest == sz_src:
213
+ return sz_dest
214
+ time.sleep(1)
215
+ raise RuntimeError(
216
+ f"{label}Drive vẫn sai size sau copy: {dest.stat().st_size} bytes, cần {sz_src}"
217
+ )
218
+
219
+
220
+ def _find_llama_bin(llama_dir: Path, llama_server: Path | None) -> Path | None:
221
+ candidates = []
222
+ if llama_server:
223
+ candidates.append(Path(llama_server))
224
+ candidates.extend([
225
+ llama_dir / "build/bin/llama-server",
226
+ llama_dir / "build/llama-server",
227
+ ])
228
+ seen: set[Path] = set()
229
+ for p in candidates:
230
+ p = p.resolve()
231
+ if p in seen:
232
+ continue
233
+ seen.add(p)
234
+ if llama_bin_valid(p):
235
+ return p
236
+ if p.is_file():
237
+ print(f"[llama] Bỏ qua (không chạy --version): {p} ({_fmt_size(p.stat().st_size)})")
238
+ return None
239
+
240
+
241
+ def _restore_llama_from_drive_tgz(tgz: Path, dest: Path) -> Path:
242
+ local_tgz = Path("/content/llama-server-restore.tgz")
243
+ _drive_copy_file(tgz, local_tgz, label="restore tgz ", min_bytes=MIN_LLAMA_TGZ_BYTES)
244
+ if dest.parent.exists():
245
+ for old in dest.parent.iterdir():
246
+ if old.is_file():
247
+ old.unlink()
248
+ dest.parent.mkdir(parents=True, exist_ok=True)
249
+ with tarfile.open(local_tgz, "r:gz") as tar:
250
+ tar.extractall(path=dest.parent)
251
+ dest.chmod(0o755)
252
+ if not llama_bin_valid(dest):
253
+ raise RuntimeError("Giải nén cache thất bại — thử build lại")
254
+ local_tgz.unlink(missing_ok=True)
255
+ n = sum(1 for f in dest.parent.iterdir() if f.is_file())
256
+ print(f"[llama] restored {n} file từ Drive → {dest.parent}")
257
+ return dest
258
+
259
+
260
+ def _cache_llama_to_drive(bin_path: Path, drive_cache: Path) -> None:
261
+ bin_dir = bin_path.parent
262
+ local_tgz = Path("/content/llama-server-cache.tgz")
263
+ if local_tgz.exists():
264
+ local_tgz.unlink()
265
+ with tarfile.open(local_tgz, "w:gz") as tar:
266
+ for f in sorted(bin_dir.iterdir()):
267
+ if f.is_file():
268
+ tar.add(f, arcname=f.name)
269
+ drive_tgz = drive_cache / DRIVE_LLAMA_TGZ
270
+ sz_tgz = _drive_copy_file(local_tgz, drive_tgz, label="cache tgz ", min_bytes=MIN_LLAMA_TGZ_BYTES)
271
+ local_tgz.unlink(missing_ok=True)
272
+ for legacy_name in ("llama-server", "llama-server.tgz"):
273
+ legacy = drive_cache / legacy_name
274
+ if legacy.is_file():
275
+ legacy.unlink(missing_ok=True)
276
+ build = llama_build_number(bin_path)
277
+ print(
278
+ f"[llama] cached to Drive: {drive_tgz} "
279
+ f"(build {build or '?'}, llama-server {_fmt_size(bin_path.stat().st_size)}, "
280
+ f"tgz {_fmt_size(sz_tgz)}, {sum(1 for f in bin_dir.iterdir() if f.is_file())} files)"
281
+ )
282
+
283
+
284
+ def _sync_llama_source(llama_dir: Path) -> None:
285
+ repo = "https://github.com/ggml-org/llama.cpp.git"
286
+ tag = LLAMA_CPP_PIN
287
+ if llama_dir.exists():
288
+ shutil.rmtree(llama_dir, ignore_errors=True)
289
+ llama_dir.parent.mkdir(parents=True, exist_ok=True)
290
+ _run(
291
+ ["git", "clone", "--depth", "1", "--branch", tag, repo, str(llama_dir)],
292
+ label=f"git clone tag {tag}",
293
+ )
294
+
295
+
296
+ def _build_llama_server(
297
+ llama_dir: Path,
298
+ drive_cache: Path | None,
299
+ *,
300
+ fresh_source: bool = False,
301
+ ) -> Path:
302
+ cuda_arch = _cuda_arch()
303
+ nproc = min(4, os.cpu_count() or 2)
304
+ print(
305
+ f"[llama] Building llama-server (CUDA arch {cuda_arch}, -j {nproc}, "
306
+ f"pin {LLAMA_CPP_PIN}, MTP min build {MIN_LLAMA_BUILD_FOR_MTP})...",
307
+ flush=True,
308
+ )
309
+ print("[llama] Compile L4/T4: thường 20–50 phút. Log in từng dòng — RAM GPU ~0 là bình thường.", flush=True)
310
+
311
+ _run(["apt-get", "-qq", "update"], check=False)
312
+ _run([
313
+ "apt-get", "-qq", "install", "-y",
314
+ "build-essential", "cmake", "git", "pkg-config", "libcurl4-openssl-dev",
315
+ ], label="apt build deps")
316
+
317
+ nvcc = _setup_cuda_env()
318
+ if nvcc is None:
319
+ for pkg in ("cuda-nvcc-12-2", "cuda-nvcc-12-4", "nvidia-cuda-toolkit"):
320
+ r = _run(["apt-get", "-qq", "install", "-y", pkg], check=False, label=pkg)
321
+ if r.returncode == 0:
322
+ nvcc = _setup_cuda_env()
323
+ if nvcc:
324
+ break
325
+ if nvcc is None:
326
+ raise RuntimeError(
327
+ "Không tìm thấy nvcc. Runtime → Change runtime type → GPU (T4/L4), Restart, Run all."
328
+ )
329
+
330
+ if fresh_source:
331
+ _sync_llama_source(llama_dir)
332
+
333
+ build_dir = llama_dir / "build"
334
+ if fresh_source and build_dir.exists():
335
+ shutil.rmtree(build_dir, ignore_errors=True)
336
+
337
+ cache_file = build_dir / "CMakeCache.txt"
338
+ if not cache_file.is_file():
339
+ if build_dir.exists():
340
+ shutil.rmtree(build_dir, ignore_errors=True)
341
+ _run([
342
+ "cmake", "-S", str(llama_dir), "-B", str(build_dir),
343
+ "-DGGML_CUDA=ON",
344
+ f"-DCMAKE_CUDA_ARCHITECTURES={cuda_arch}",
345
+ "-DCMAKE_BUILD_TYPE=Release",
346
+ f"-DCMAKE_CUDA_COMPILER={nvcc}",
347
+ "-DLLAMA_BUILD_TESTS=OFF",
348
+ "-DLLAMA_BUILD_EXAMPLES=OFF",
349
+ ], label="cmake configure")
350
+ else:
351
+ print("[llama] Tiếp tục build cũ (CMakeCache.txt có sẵn)...", flush=True)
352
+
353
+ need_clean = _purge_invalid_llama_bin(build_dir)
354
+ print("[llama] Đang compile llama-server...", flush=True)
355
+ build_cmd = [
356
+ "cmake", "--build", str(build_dir), "--config", "Release",
357
+ "-j", str(nproc), "--target", "llama-server",
358
+ ]
359
+ if need_clean:
360
+ build_cmd.insert(4, "--clean-first")
361
+ print("[llama] --clean-first (xóa stub hỏng, buộc link lại)", flush=True)
362
+ _run(build_cmd, label="cmake build", live=True)
363
+
364
+ bin_path = _find_llama_bin(llama_dir, None)
365
+ if not bin_path:
366
+ raise FileNotFoundError(
367
+ f"Build xong nhưng llama-server không chạy được trong {build_dir}/bin."
368
+ )
369
+ if not llama_supports_mtp(bin_path):
370
+ raise RuntimeError(
371
+ "Build xong nhưng llama-server không hỗ trợ draft-mtp (MTP Gemma 4). "
372
+ "Thử FORCE_LLAMA_REBUILD = True."
373
+ )
374
+ print(f"[llama] Build OK: {bin_path} ({_fmt_size(bin_path.stat().st_size)} + .so cùng thư mục)")
375
+ print(llama_version_text(bin_path).splitlines()[0] if llama_version_text(bin_path) else str(bin_path))
376
+ if drive_cache:
377
+ _cache_llama_to_drive(bin_path, drive_cache)
378
+ return bin_path
379
+
380
+
381
+ def ensure_llama_server(
382
+ *,
383
+ llama_dir: str | Path = "/content/llama.cpp",
384
+ llama_server: str | Path | None = None,
385
+ drive_cache: str | Path | None = None,
386
+ allow_build: bool = True,
387
+ require_mtp: bool = True,
388
+ force_rebuild: bool = False,
389
+ ) -> Path:
390
+ """Restore từ Drive cache hoặc build llama.cpp mới (>= b9549 cho MTP Gemma 4)."""
391
+ llama_dir = Path(llama_dir)
392
+ llama_server = Path(llama_server) if llama_server else llama_dir / "build/bin/llama-server"
393
+ drive_cache = Path(drive_cache) if drive_cache else None
394
+
395
+ if force_rebuild:
396
+ print("[llama] FORCE_LLAMA_REBUILD=True — xóa cache + build lại...")
397
+ _invalidate_llama_artifacts(llama_dir, drive_cache)
398
+ elif require_mtp:
399
+ bin_path = _find_llama_bin(llama_dir, llama_server)
400
+ if bin_path and not llama_supports_mtp(bin_path):
401
+ print("[llama] Binary cũ (không có draft-mtp) — xóa và build lại...")
402
+ _invalidate_llama_artifacts(llama_dir, drive_cache)
403
+
404
+ bin_path = _find_llama_bin(llama_dir, llama_server)
405
+ if bin_path and (not require_mtp or llama_supports_mtp(bin_path)):
406
+ _print_bin_info(bin_path, require_mtp=require_mtp)
407
+ setup_ld_library_path(bin_path)
408
+ return bin_path
409
+
410
+ if drive_cache and not force_rebuild:
411
+ drive_tgz = drive_cache / DRIVE_LLAMA_TGZ
412
+ if drive_tgz.is_file() and drive_tgz.stat().st_size > MIN_LLAMA_TGZ_BYTES:
413
+ dest = llama_dir / "build/bin/llama-server"
414
+ try:
415
+ _restore_llama_from_drive_tgz(drive_tgz, dest)
416
+ if not require_mtp or llama_supports_mtp(dest):
417
+ _print_bin_info(dest, require_mtp=require_mtp)
418
+ setup_ld_library_path(dest)
419
+ return dest
420
+ print("[llama] Cache Drive không hỗ trợ draft-mtp — build lại cho MTP...")
421
+ _invalidate_llama_artifacts(llama_dir, drive_cache)
422
+ except Exception as e:
423
+ print(f"[llama] Cache .tgz hỏng ({e}) — build lại...")
424
+ drive_tgz.unlink(missing_ok=True)
425
+
426
+ for legacy_name in ("llama-server", "llama-server.tgz"):
427
+ legacy = drive_cache / legacy_name
428
+ if legacy.is_file():
429
+ print(f"[llama] Xóa cache cũ: {legacy_name} ({_fmt_size(legacy.stat().st_size)})")
430
+ legacy.unlink(missing_ok=True)
431
+
432
+ if not allow_build:
433
+ raise FileNotFoundError(
434
+ f"Không có llama-server MTP (>= build {MIN_LLAMA_BUILD_FOR_MTP}). "
435
+ f"Đặt allow_build=True hoặc FORCE_LLAMA_REBUILD=True."
436
+ )
437
+
438
+ bin_path = _build_llama_server(llama_dir, drive_cache, fresh_source=True)
439
+ setup_ld_library_path(bin_path)
440
+ _print_bin_info(bin_path, require_mtp=require_mtp)
441
+ return bin_path
hf-upload/translate_srt.py ADDED
@@ -0,0 +1,2279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Correct and translate SRT subtitles using Gemma 4 vision + scene context.
2
+
3
+ Key difference from the old version: a single persistent ``llama-server`` is
4
+ started once (model stays in VRAM) and every cue/scene is handled through HTTP
5
+ requests. The old version spawned ``llama-cli.exe`` per cue, reloading the 6.4 GB
6
+ model every time, which is why a 10-minute video took ~1 hour. With a persistent
7
+ server the same job runs in a few minutes.
8
+
9
+ Pipeline:
10
+ parse_srt -> group_scenes -> extract frames (ffmpeg) ->
11
+ [pass 1] OCR + correct source text per scene (vision, optional) ->
12
+ [optional] merge adjacent cue pairs (wider timestamps for dubbing) ->
13
+ [pass 2] scene vision (multi-frame) + relationship lock + translate ->
14
+ write translated SRT, corrected source SRT, and a JSON report.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import atexit
21
+ import base64
22
+ import json
23
+ import re
24
+ import socket
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ import urllib.error
30
+ import urllib.request
31
+ from collections import Counter
32
+ from dataclasses import dataclass, field
33
+ from difflib import SequenceMatcher
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ ROOT = Path(__file__).resolve().parent
38
+ MODELS_DIR = ROOT / "models"
39
+ DEFAULT_LLAMA_SERVER = ROOT / "tools" / "llama-cuda" / "llama-server.exe"
40
+ DEFAULT_MODEL = MODELS_DIR / "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf"
41
+ DEFAULT_MMPROJ = MODELS_DIR / "mmproj-F16.gguf"
42
+ DEFAULT_DRAFT = MODELS_DIR / "mtp-gemma-4-12B-it.gguf"
43
+
44
+ # Hide console windows for child processes on Windows (llama-server, ffmpeg).
45
+ _SUBPROCESS_FLAGS = (
46
+ subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
47
+ )
48
+
49
+ SRT_TIME = re.compile(
50
+ r"(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})"
51
+ )
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # Data model
56
+ # --------------------------------------------------------------------------- #
57
+ @dataclass
58
+ class Cue:
59
+ index: int
60
+ start: float
61
+ end: float
62
+ text: str
63
+ corrected_source: str = ""
64
+ was_corrected: bool = False
65
+ ocr_text: str = ""
66
+ visual_context: str = ""
67
+ correction_reason: str = ""
68
+ translated: str = ""
69
+ honorific_notes: str = ""
70
+ char_budget: int = 0
71
+ timing_notes: str = ""
72
+ speaker: str = ""
73
+ speaker_gender: str = ""
74
+ speaker_age_group: str = ""
75
+ merged_from: list[int] = field(default_factory=list)
76
+
77
+ @property
78
+ def duration(self) -> float:
79
+ return self.end - self.start
80
+
81
+ @property
82
+ def midpoint(self) -> float:
83
+ return (self.start + self.end) / 2.0
84
+
85
+ @property
86
+ def start_ts(self) -> str:
87
+ return _seconds_to_srt_time(self.start)
88
+
89
+ @property
90
+ def end_ts(self) -> str:
91
+ return _seconds_to_srt_time(self.end)
92
+
93
+ @property
94
+ def source(self) -> str:
95
+ return self.corrected_source or self.text
96
+
97
+
98
+ @dataclass
99
+ class Scene:
100
+ cues: list[Cue] = field(default_factory=list)
101
+
102
+ @property
103
+ def start(self) -> float:
104
+ return self.cues[0].start
105
+
106
+ @property
107
+ def end(self) -> float:
108
+ return self.cues[-1].end
109
+
110
+ @property
111
+ def midpoint(self) -> float:
112
+ return (self.start + self.end) / 2.0
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # SRT parsing / writing
117
+ # --------------------------------------------------------------------------- #
118
+ def _srt_time_to_seconds(h: str, m: str, s: str, ms: str) -> float:
119
+ return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
120
+
121
+
122
+ def _seconds_to_srt_time(seconds: float) -> str:
123
+ if seconds < 0:
124
+ seconds = 0.0
125
+ ms = int(round(seconds * 1000))
126
+ h, ms = divmod(ms, 3600_000)
127
+ m, ms = divmod(ms, 60_000)
128
+ s, ms = divmod(ms, 1000)
129
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
130
+
131
+
132
+ def parse_srt(path: Path) -> list[Cue]:
133
+ raw = path.read_text(encoding="utf-8-sig", errors="replace")
134
+ blocks = re.split(r"\n\s*\n", raw.strip())
135
+ cues: list[Cue] = []
136
+ idx = 0
137
+ for block in blocks:
138
+ lines = block.splitlines()
139
+ m = None
140
+ time_line = -1
141
+ for i, ln in enumerate(lines):
142
+ m = SRT_TIME.search(ln)
143
+ if m:
144
+ time_line = i
145
+ break
146
+ if not m:
147
+ continue
148
+ start = _srt_time_to_seconds(m.group(1), m.group(2), m.group(3), m.group(4))
149
+ end = _srt_time_to_seconds(m.group(5), m.group(6), m.group(7), m.group(8))
150
+ text = "\n".join(lines[time_line + 1 :]).strip()
151
+ idx += 1
152
+ cues.append(Cue(index=idx, start=start, end=end, text=text))
153
+ return cues
154
+
155
+
156
+ def write_srt(cues: list[Cue], path: Path, use_translation: bool) -> None:
157
+ out: list[str] = []
158
+ for i, cue in enumerate(cues, start=1):
159
+ body = cue.translated if use_translation else cue.source
160
+ out.append(str(i))
161
+ out.append(f"{cue.start_ts} --> {cue.end_ts}")
162
+ out.append(body.strip())
163
+ out.append("")
164
+ path.write_text("\n".join(out).strip() + "\n", encoding="utf-8")
165
+
166
+
167
+ def write_report(cues: list[Cue], path: Path) -> None:
168
+ data = [
169
+ {
170
+ "index": c.index,
171
+ "start": c.start_ts,
172
+ "end": c.end_ts,
173
+ "original": c.text,
174
+ "corrected_source": c.corrected_source,
175
+ "was_corrected": c.was_corrected,
176
+ "ocr_text": c.ocr_text,
177
+ "visual_context": c.visual_context,
178
+ "correction_reason": c.correction_reason,
179
+ "translated": c.translated,
180
+ "honorific_notes": c.honorific_notes,
181
+ "speaker": c.speaker,
182
+ "speaker_gender": c.speaker_gender,
183
+ "speaker_age_group": c.speaker_age_group,
184
+ "duration_sec": round(c.duration, 2),
185
+ "char_budget": c.char_budget,
186
+ "translated_chars": len(c.translated.replace("\n", "")),
187
+ "within_budget": len(c.translated.replace("\n", "")) <= c.char_budget
188
+ if c.char_budget
189
+ else True,
190
+ "timing_notes": c.timing_notes,
191
+ "merged_from": c.merged_from,
192
+ }
193
+ for c in cues
194
+ ]
195
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
196
+
197
+
198
+ # --------------------------------------------------------------------------- #
199
+ # Scene grouping
200
+ # --------------------------------------------------------------------------- #
201
+ def group_scenes(
202
+ cues: list[Cue], max_gap: float, max_cues: int, max_duration: float
203
+ ) -> list[Scene]:
204
+ scenes: list[Scene] = []
205
+ current: list[Cue] = []
206
+ for cue in cues:
207
+ if not current:
208
+ current = [cue]
209
+ continue
210
+ gap = cue.start - current[-1].end
211
+ span = cue.end - current[0].start
212
+ if gap > max_gap or len(current) >= max_cues or span > max_duration:
213
+ scenes.append(Scene(current))
214
+ current = [cue]
215
+ else:
216
+ current.append(cue)
217
+ if current:
218
+ scenes.append(Scene(current))
219
+ return scenes
220
+
221
+
222
+ def _join_subtitle_text(a: str, b: str) -> str:
223
+ a, b = a.strip(), b.strip()
224
+ if not a:
225
+ return b
226
+ if not b:
227
+ return a
228
+ if a.endswith(("\n",)):
229
+ return f"{a.rstrip()}\n{b}"
230
+ if a[-1] in ",。!?、;:…" or b[0] in ",。!?、;:…":
231
+ return f"{a}{b}"
232
+ if any("\u4e00" <= ch <= "\u9fff" for ch in (a[-1], b[0])):
233
+ return f"{a}{b}"
234
+ return f"{a} {b}"
235
+
236
+
237
+ # Characters / particles that mark the END of a spoken sentence in the source.
238
+ # When a cue already ends a sentence, the next cue starts a new thought and the
239
+ # two should not be glued together.
240
+ _SENT_FINAL_PUNCT = "。..!!??…⋯~"
241
+ _SENT_FINAL_PARTICLE = ("吗", "呢", "吧")
242
+
243
+
244
+ def _src_text(c: "Cue") -> str:
245
+ return (c.corrected_source or c.text or "").strip()
246
+
247
+
248
+ def _visible_len(s: str) -> int:
249
+ """Length ignoring whitespace/newlines (CJK chars count as 1)."""
250
+ return len(re.sub(r"\s+", "", s))
251
+
252
+
253
+ def _ends_sentence(s: str) -> bool:
254
+ s = s.rstrip()
255
+ if not s:
256
+ return False
257
+ if s[-1] in _SENT_FINAL_PUNCT:
258
+ return True
259
+ return s.endswith(_SENT_FINAL_PARTICLE)
260
+
261
+
262
+ def _speakers_compatible(a: "Cue", b: "Cue") -> bool:
263
+ """Same speaker, or at least one is unknown (diarization missed it)."""
264
+ if a.speaker and b.speaker:
265
+ return a.speaker == b.speaker
266
+ return True
267
+
268
+
269
+ def merge_adjacent_pairs(
270
+ cues: list[Cue],
271
+ *,
272
+ max_gap: float = 0.12,
273
+ max_combined_duration: float = 10.0,
274
+ max_combined_chars: int = 32,
275
+ fragment_chars: int = 12,
276
+ ) -> tuple[list[Cue], int]:
277
+ """Glue a fragmented cue back to its neighbour, but only when it is sensible.
278
+
279
+ The source SRT is often split mid-sentence into tiny cues. Merging at most two
280
+ consecutive cues reconstructs a readable line for dubbing, but only when:
281
+ - the cues are near-contiguous (next starts where this one ends) and the
282
+ combined duration fits — a real time gap means a separate utterance;
283
+ - both cues belong to the same speaker (never merge across speakers);
284
+ - the first cue does NOT already end a sentence (punctuation / 吗呢吧);
285
+ - the combined line stays short enough to fit on screen;
286
+ - at least one side is a short fragment (so two full sentences stay apart).
287
+ """
288
+ if len(cues) < 2:
289
+ return cues, 0
290
+
291
+ out: list[Cue] = []
292
+ merges = 0
293
+ i = 0
294
+ while i < len(cues):
295
+ if i + 1 < len(cues):
296
+ a, b = cues[i], cues[i + 1]
297
+ gap = b.start - a.end
298
+ combined_dur = b.end - a.start
299
+ src_a = _src_text(a)
300
+ src_b = _src_text(b)
301
+ len_a = _visible_len(src_a)
302
+ len_b = _visible_len(src_b)
303
+ if (
304
+ -0.05 <= gap <= max_gap
305
+ and combined_dur <= max_combined_duration
306
+ and _speakers_compatible(a, b)
307
+ and not _ends_sentence(src_a)
308
+ and (len_a + len_b) <= max_combined_chars
309
+ and min(len_a, len_b) <= fragment_chars
310
+ ):
311
+ merged_text = _join_subtitle_text(src_a, src_b)
312
+ # Keep the speaker of the longer-spoken cue; note both when they differ.
313
+ if a.speaker and b.speaker and a.speaker != b.speaker:
314
+ dominant = a if a.duration >= b.duration else b
315
+ merged_speaker = dominant.speaker
316
+ merged_gender = dominant.speaker_gender
317
+ merged_age = dominant.speaker_age_group
318
+ else:
319
+ merged_speaker = a.speaker or b.speaker
320
+ merged_gender = a.speaker_gender or b.speaker_gender
321
+ merged_age = a.speaker_age_group or b.speaker_age_group
322
+ out.append(
323
+ Cue(
324
+ index=0,
325
+ start=a.start,
326
+ end=b.end,
327
+ text=_join_subtitle_text(a.text, b.text),
328
+ corrected_source=merged_text,
329
+ was_corrected=a.was_corrected or b.was_corrected,
330
+ speaker=merged_speaker,
331
+ speaker_gender=merged_gender,
332
+ speaker_age_group=merged_age,
333
+ merged_from=[a.index, b.index],
334
+ )
335
+ )
336
+ merges += 1
337
+ i += 2
338
+ continue
339
+ out.append(cues[i])
340
+ i += 1
341
+
342
+ for j, cue in enumerate(out, start=1):
343
+ cue.index = j
344
+ return out, merges
345
+
346
+
347
+ # --------------------------------------------------------------------------- #
348
+ # ffmpeg frame extraction
349
+ # --------------------------------------------------------------------------- #
350
+ def _run_ffmpeg(args: list[str], timeout: int = 60) -> None:
351
+ cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", *args]
352
+ proc = subprocess.run(
353
+ cmd, capture_output=True, timeout=timeout, creationflags=_SUBPROCESS_FLAGS
354
+ )
355
+ if proc.returncode != 0:
356
+ raise RuntimeError(
357
+ "ffmpeg failed: " + proc.stderr.decode("utf-8", "replace").strip()
358
+ )
359
+
360
+
361
+ def extract_frame(
362
+ video: Path,
363
+ timestamp: float,
364
+ output: Path,
365
+ crop_subtitle: bool,
366
+ max_width: int = 768,
367
+ ) -> Path:
368
+ vf = []
369
+ if crop_subtitle:
370
+ vf.append("crop=iw:ih*0.30:0:ih*0.70")
371
+ vf.append(f"scale='min({max_width},iw)':-2")
372
+ args = [
373
+ "-ss",
374
+ f"{timestamp:.3f}",
375
+ "-i",
376
+ str(video),
377
+ "-frames:v",
378
+ "1",
379
+ "-vf",
380
+ ",".join(vf),
381
+ "-q:v",
382
+ "3",
383
+ "-y",
384
+ str(output),
385
+ ]
386
+ _run_ffmpeg(args)
387
+ if not output.exists():
388
+ raise RuntimeError(f"Failed to extract frame at {timestamp:.3f}s")
389
+ return output
390
+
391
+
392
+ def _image_data_url(path: Path) -> str:
393
+ data = base64.b64encode(path.read_bytes()).decode("ascii")
394
+ return f"data:image/jpeg;base64,{data}"
395
+
396
+
397
+ # --------------------------------------------------------------------------- #
398
+ # JSON extraction helpers
399
+ # --------------------------------------------------------------------------- #
400
+ _THOUGHT = re.compile(r"<\|channel\|?>thought.*?<\|?channel\|>", re.DOTALL)
401
+ _FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```")
402
+
403
+
404
+ def _strip_noise(text: str) -> str:
405
+ text = _THOUGHT.sub("", text)
406
+ text = re.sub(r"<\|[^>]*\|?>", "", text)
407
+ return text.strip()
408
+
409
+
410
+ def _extract_json(text: str) -> Any:
411
+ if not text or not text.strip():
412
+ raise ValueError("Empty model response")
413
+ cleaned = _strip_noise(text)
414
+ candidates: list[str] = []
415
+ fence = _FENCE.search(cleaned)
416
+ if fence:
417
+ candidates.append(fence.group(1))
418
+ candidates.append(cleaned)
419
+ # Outermost {...} or [...] block. Prefer whichever bracket opens first so an
420
+ # array that wraps objects is not mistaken for a single inner object.
421
+ blocks: list[tuple[int, str]] = []
422
+ for opener, closer in (("{", "}"), ("[", "]")):
423
+ start = cleaned.find(opener)
424
+ end = cleaned.rfind(closer)
425
+ if start != -1 and end != -1 and end > start:
426
+ blocks.append((start, cleaned[start : end + 1]))
427
+ for _, block in sorted(blocks, key=lambda b: b[0]):
428
+ candidates.append(block)
429
+ for cand in candidates:
430
+ try:
431
+ return json.loads(cand)
432
+ except json.JSONDecodeError:
433
+ continue
434
+ raise ValueError("Could not parse JSON from model output:\n" + text)
435
+
436
+
437
+ # --------------------------------------------------------------------------- #
438
+ # Persistent llama-server client
439
+ # --------------------------------------------------------------------------- #
440
+ def _free_port() -> int:
441
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
442
+ s.bind(("127.0.0.1", 0))
443
+ return s.getsockname()[1]
444
+
445
+
446
+ class LlamaServer:
447
+ """Starts (or reuses) a persistent llama-server and talks to it over HTTP."""
448
+
449
+ def __init__(
450
+ self,
451
+ server_bin: Path,
452
+ model: Path,
453
+ mmproj: Path,
454
+ ngl: int = 999,
455
+ ctx_size: int = 8192,
456
+ port: int | None = None,
457
+ draft: Path | None = None,
458
+ use_mtp: bool = True,
459
+ temperature: float = 0.7,
460
+ max_tokens: int = 1024,
461
+ start_timeout: int = 180,
462
+ mtp_start_timeout: int = 420,
463
+ existing_url: str | None = None,
464
+ ) -> None:
465
+ self.server_bin = server_bin
466
+ self.model = model
467
+ self.mmproj = mmproj
468
+ self.ngl = ngl
469
+ self.ctx_size = ctx_size
470
+ self.draft = draft
471
+ self.use_mtp = use_mtp
472
+ self.temperature = temperature
473
+ self.max_tokens = max_tokens
474
+ self.start_timeout = start_timeout
475
+ self.mtp_start_timeout = mtp_start_timeout
476
+ self.proc: subprocess.Popen | None = None
477
+
478
+ if existing_url:
479
+ self.base_url = existing_url.rstrip("/")
480
+ self.owns_server = False
481
+ else:
482
+ self.port = port or _free_port()
483
+ self.base_url = f"http://127.0.0.1:{self.port}"
484
+ self.owns_server = True
485
+
486
+ # -- lifecycle -------------------------------------------------------- #
487
+ def _server_args(self, with_mtp: bool) -> list[str]:
488
+ args = [
489
+ str(self.server_bin),
490
+ "-m",
491
+ str(self.model),
492
+ "--mmproj",
493
+ str(self.mmproj),
494
+ "-ngl",
495
+ str(self.ngl),
496
+ "-fa",
497
+ "on",
498
+ "-c",
499
+ str(self.ctx_size),
500
+ "--host",
501
+ "127.0.0.1",
502
+ "--port",
503
+ str(self.port),
504
+ "--parallel",
505
+ "1",
506
+ "--reasoning",
507
+ "off",
508
+ "--reasoning-budget",
509
+ "0",
510
+ ]
511
+ if with_mtp and self.draft and self.draft.exists():
512
+ args += [
513
+ "--model-draft",
514
+ str(self.draft),
515
+ "--spec-type",
516
+ "draft-mtp",
517
+ "--spec-draft-n-max",
518
+ "4",
519
+ "--spec-draft-ngl",
520
+ str(self.ngl),
521
+ "-ngld",
522
+ str(self.ngl),
523
+ ]
524
+ return args
525
+
526
+ def _wait_healthy(self, timeout: int) -> bool:
527
+ deadline = time.time() + timeout
528
+ url = f"{self.base_url}/health"
529
+ while time.time() < deadline:
530
+ if self.proc is not None and self.proc.poll() is not None:
531
+ return False
532
+ try:
533
+ with urllib.request.urlopen(url, timeout=3) as resp:
534
+ if resp.status == 200:
535
+ return True
536
+ except (urllib.error.URLError, OSError):
537
+ time.sleep(1.0)
538
+ return False
539
+
540
+ def start(self) -> None:
541
+ if not self.owns_server:
542
+ if not self._wait_healthy(15):
543
+ raise RuntimeError(f"No healthy server at {self.base_url}")
544
+ return
545
+
546
+ attempts = [True, False] if self.use_mtp else [False]
547
+ last_log = ""
548
+ for with_mtp in attempts:
549
+ label = "with MTP" if with_mtp else "without MTP"
550
+ print(f"[server] starting llama-server ({label})...", flush=True)
551
+ log = tempfile.NamedTemporaryFile(
552
+ prefix="llama_server_", suffix=".log", delete=False, mode="w"
553
+ )
554
+ self._log_path = Path(log.name)
555
+ self.proc = subprocess.Popen(
556
+ self._server_args(with_mtp),
557
+ stdout=log,
558
+ stderr=subprocess.STDOUT,
559
+ cwd=str(self.server_bin.parent),
560
+ creationflags=_SUBPROCESS_FLAGS,
561
+ )
562
+ atexit.register(self.stop)
563
+ timeout = self.mtp_start_timeout if with_mtp else self.start_timeout
564
+ if self._wait_healthy(timeout):
565
+ print(f"[server] ready at {self.base_url} ({label})", flush=True)
566
+ return
567
+ self.stop()
568
+ last_log = self._log_path.read_text(errors="replace")[-2000:]
569
+ print(f"[server] failed to start {label}; retrying...", flush=True)
570
+ if last_log.strip():
571
+ print("[server] log (tail):\n" + last_log, flush=True)
572
+ raise RuntimeError("llama-server did not become healthy.\n" + last_log)
573
+
574
+ def stop(self) -> None:
575
+ if self.proc is not None and self.proc.poll() is None:
576
+ self.proc.terminate()
577
+ try:
578
+ self.proc.wait(timeout=15)
579
+ except subprocess.TimeoutExpired:
580
+ self.proc.kill()
581
+ self.proc = None
582
+
583
+ # -- requests --------------------------------------------------------- #
584
+ def chat(
585
+ self,
586
+ prompt: str,
587
+ images: list[Path] | None = None,
588
+ system: str | None = None,
589
+ max_tokens: int | None = None,
590
+ retries: int = 2,
591
+ temperature: float | None = None,
592
+ ) -> str:
593
+ content: list[dict[str, Any]] = []
594
+ for img in images or []:
595
+ content.append(
596
+ {"type": "image_url", "image_url": {"url": _image_data_url(img)}}
597
+ )
598
+ content.append({"type": "text", "text": prompt})
599
+
600
+ messages: list[dict[str, Any]] = []
601
+ if system:
602
+ messages.append({"role": "system", "content": system})
603
+ messages.append({"role": "user", "content": content})
604
+
605
+ payload = {
606
+ "messages": messages,
607
+ "temperature": self.temperature if temperature is None else temperature,
608
+ "top_p": 0.95,
609
+ "top_k": 64,
610
+ "max_tokens": max_tokens or self.max_tokens,
611
+ "cache_prompt": True,
612
+ "stream": False,
613
+ }
614
+ body = json.dumps(payload).encode("utf-8")
615
+ url = f"{self.base_url}/v1/chat/completions"
616
+
617
+ last_err: Exception | None = None
618
+ for attempt in range(retries + 1):
619
+ try:
620
+ req = urllib.request.Request(
621
+ url, data=body, headers={"Content-Type": "application/json"}
622
+ )
623
+ with urllib.request.urlopen(req, timeout=600) as resp:
624
+ data = json.loads(resp.read().decode("utf-8"))
625
+ msg = data["choices"][0]["message"]
626
+ content = msg.get("content") or ""
627
+ if not content.strip():
628
+ # Some templates route everything into reasoning_content.
629
+ content = msg.get("reasoning_content") or ""
630
+ if not content.strip():
631
+ raise ValueError("empty content")
632
+ return content
633
+ except (urllib.error.URLError, OSError, KeyError, ValueError) as exc:
634
+ last_err = exc
635
+ time.sleep(2.0 * (attempt + 1))
636
+ raise RuntimeError(f"Chat request failed: {last_err}")
637
+
638
+ def chat_json(
639
+ self,
640
+ prompt: str,
641
+ images: list[Path] | None = None,
642
+ system: str | None = None,
643
+ max_tokens: int | None = None,
644
+ temperature: float | None = None,
645
+ ) -> Any:
646
+ text = self.chat(
647
+ prompt,
648
+ images=images,
649
+ system=system,
650
+ max_tokens=max_tokens,
651
+ temperature=temperature,
652
+ )
653
+ return _extract_json(text)
654
+
655
+
656
+ # --------------------------------------------------------------------------- #
657
+ # Subtitle timing helpers
658
+ # --------------------------------------------------------------------------- #
659
+ def subtitle_char_budget(
660
+ duration: float,
661
+ chars_per_sec: float = 17.0,
662
+ max_line_chars: int = 42,
663
+ max_lines: int = 2,
664
+ min_budget: int = 6,
665
+ ) -> int:
666
+ """Estimate how many characters fit comfortably on screen for *duration* seconds."""
667
+ if duration <= 0:
668
+ return min_budget
669
+ cps_budget = int(duration * chars_per_sec)
670
+ display_cap = max_line_chars * max_lines
671
+ return max(min_budget, min(cps_budget, display_cap))
672
+
673
+
674
+ def _translated_char_count(text: str) -> int:
675
+ return len(text.replace("\n", ""))
676
+
677
+
678
+ def _normalize_subtitle_text(text: str) -> str:
679
+ """Single-line subtitles: collapse model-inserted line breaks."""
680
+ return re.sub(r"\s*\n\s*", " ", text).strip()
681
+
682
+
683
+ def _apply_char_budgets(
684
+ cues: list[Cue], chars_per_sec: float, max_line_chars: int, max_lines: int
685
+ ) -> None:
686
+ for c in cues:
687
+ c.char_budget = subtitle_char_budget(
688
+ c.duration, chars_per_sec, max_line_chars, max_lines
689
+ )
690
+
691
+
692
+ # --------------------------------------------------------------------------- #
693
+ # Prompts
694
+ # --------------------------------------------------------------------------- #
695
+ CORRECTION_SYSTEM = (
696
+ "You are a meticulous subtitle proofreader. You look at video frames that may "
697
+ "contain burned-in (hard) subtitles and compare them with the provided SRT text. "
698
+ "Reply with JSON only."
699
+ )
700
+
701
+ TRANSLATION_SYSTEM = (
702
+ "You are an expert Vietnamese film/TV subtitle translator. Write the way native "
703
+ "Vietnamese speakers actually talk: natural, idiomatic, smooth and CONCISE spoken "
704
+ "language — never word-for-word, stiff, or translationese. Convey the full meaning, "
705
+ "tone, emotion and humor, but cut redundant words, drop subjects/pronouns that are "
706
+ "obvious from context, and avoid clunky calques (e.g. 'thông qua việc', 'một cách', "
707
+ "repeating 'tôi ... tôi ...'). A shorter, natural line is better than a long, "
708
+ "faithful-but-awkward one. Keep correct forms of address / pronouns. Reply with JSON only."
709
+ )
710
+
711
+
712
+ def build_correction_prompt(scene: Scene, source_lang: str) -> str:
713
+ lines = []
714
+ for i, c in enumerate(scene.cues):
715
+ lines.append(
716
+ f"- image_index={i} | cue_index={c.index} | "
717
+ f"{c.start_ts} --> {c.end_ts} | srt_text: {c.text!r}"
718
+ )
719
+ src = "the same language" if source_lang == "auto" else source_lang
720
+ return (
721
+ f"Source language: {src}.\n"
722
+ "Each cue below has a corresponding cropped video frame (in the same order as "
723
+ "the images). Read any burned-in/hard subtitle text visible in each frame and "
724
+ "compare it with the SRT text. If the SRT text is wrong (typos, missing words, "
725
+ "misrecognized characters), fix it to match what is actually shown/spoken. If "
726
+ "the frame has no visible subtitle, keep the SRT text unchanged.\n\n"
727
+ "Cues:\n" + "\n".join(lines) + "\n\n"
728
+ 'Return ONLY a JSON array, one object per cue, in this exact shape:\n'
729
+ '[{"cue_index": <int>, "corrected_source": "<string>", '
730
+ '"was_corrected": <true|false>, "ocr_text": "<text seen in frame or empty>", '
731
+ '"visual_context": "<short scene description>", '
732
+ '"correction_reason": "<why changed or kept>"}]'
733
+ )
734
+
735
+
736
+ _GENDER_LABEL = {
737
+ "male": "nam",
738
+ "female": "nữ",
739
+ "child": "trẻ em",
740
+ "unknown": "chưa rõ",
741
+ }
742
+
743
+ _AGE_GROUP_LABEL = {
744
+ "child": "trẻ em",
745
+ "teen": "thiếu niên",
746
+ "young_adult": "thanh niên",
747
+ "middle_aged": "trung niên",
748
+ "elderly": "lớn tuổi",
749
+ }
750
+
751
+
752
+ def _gender_label(gender: str) -> str:
753
+ return _GENDER_LABEL.get((gender or "").lower(), "chưa rõ")
754
+
755
+
756
+ def _age_label(age_group: str) -> str:
757
+ return _AGE_GROUP_LABEL.get((age_group or "").lower(), "")
758
+
759
+
760
+ def _speaker_descriptor(gender: str, age_group: str) -> str:
761
+ """e.g. 'nữ, thanh niên' or 'nam' or 'chưa rõ'."""
762
+ parts = [_gender_label(gender)]
763
+ age = _age_label(age_group)
764
+ if age:
765
+ parts.append(age)
766
+ return ", ".join(parts)
767
+
768
+
769
+ def _speaker_tag(cue: Cue) -> str:
770
+ """Compact 'who is speaking' tag, e.g. 'SPEAKER_01 (nữ, thanh niên)'."""
771
+ if not cue.speaker:
772
+ return ""
773
+ return f"{cue.speaker} ({_speaker_descriptor(cue.speaker_gender, cue.speaker_age_group)})"
774
+
775
+
776
+ def build_speaker_registry(cues: list[Cue]) -> str:
777
+ """List every detected speaker + gender + age so pronouns stay stable."""
778
+ seen: dict[str, tuple[str, str]] = {}
779
+ for c in cues:
780
+ if c.speaker and c.speaker not in seen:
781
+ seen[c.speaker] = (c.speaker_gender, c.speaker_age_group)
782
+ if not seen:
783
+ return ""
784
+ rows = [
785
+ f"- {spk}: {_speaker_descriptor(gender, age_group)}"
786
+ for spk, (gender, age_group) in seen.items()
787
+ ]
788
+ return "\n".join(rows)
789
+
790
+
791
+ # --------------------------------------------------------------------------- #
792
+ # Character name consistency (CJK source -> locked Vietnamese spelling)
793
+ # --------------------------------------------------------------------------- #
794
+ _CJK_NAME_TOKEN = re.compile(r"[\u4e00-\u9fff]{2,3}")
795
+ _CJK_STOPWORDS = frozenset(
796
+ {
797
+ "什么", "怎么", "没有", "不是", "这个", "那个", "自己", "时候", "知道",
798
+ "可以", "已经", "因为", "所以", "但是", "如果", "我们", "你们", "他们",
799
+ "这样", "为什么", "一下", "真的", "还是", "就是", "不要", "现在", "以前",
800
+ "今天", "明天", "一起", "东西", "事情", "地方", "个人", "大家", "对不起",
801
+ "谢谢", "没关系", "等等", "不对", "当然", "可能", "应该", "一定", "起来",
802
+ "出去", "回来", "告诉", "觉得", "认为", "喜欢", "希望", "开始", "结束",
803
+ "是不是", "好不好", "能不能", "会不会", "干什么", "怎么办", "怎么样",
804
+ "在吗", "是吗", "对吧", "行了", "算了", "没事", "好了",
805
+ # Common nouns/verbs/adverbs that look like 2-3 char names but are not.
806
+ "别墅", "礼物", "到了", "收尾", "这里", "那里", "这边", "那边", "哪里",
807
+ "一个", "两个", "怎样", "多少", "一点", "有点", "一些", "这些", "那些",
808
+ "时间", "问题", "工作", "公司", "医院", "手术", "电话", "老师", "医生",
809
+ "护士", "患者", "病人", "孩子", "生日", "结婚", "离婚", "生活", "感觉",
810
+ "关系", "办法", "方法", "样子", "意思", "消息", "情况", "决定", "准备",
811
+ "需要", "然后", "还有", "而且", "不过", "只是", "其实", "当时", "后来",
812
+ "之后", "之前", "一直", "马上", "立刻", "突然", "终于", "总是", "经常",
813
+ "也许", "大概", "也是", "刚才", "刚刚", "最后", "最近", "以后", "以为",
814
+ "妈妈", "爸爸", "母亲", "父亲", "儿子", "女儿", "老公", "老婆", "妻子",
815
+ "丈夫", "先生", "太太", "小姐", "样东", "东西", "时候", "事情", "钱财",
816
+ "资金", "公里", "回家", "出来", "进来", "下来", "上来", "过来", "过去",
817
+ "不会", "不能", "不想", "不用", "明白", "清楚", "记得", "忘记", "答应",
818
+ }
819
+ )
820
+ _VN_NAME_WORD = (
821
+ r"[A-ZÀÁẢÃẠĂẮẰẲẴẶÂẤẦẨẪẬĐÉÈẺẼẸÊẾỀỂỄỆÍÌỈĨỊÓÒỎÕỌÔỐỒỔỖỘƠỚỜỞỠỢÚÙỦŨỤƯỨỪỬỮỰÝỲỶỸỴ]"
822
+ r"[a-zàáảãạăắằẳẵặâấầẩẫậđéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵ]*"
823
+ )
824
+ _VN_NAME_RE = re.compile(rf"(?:{_VN_NAME_WORD})(?:\s+(?:{_VN_NAME_WORD})){{1,3}}")
825
+ _VN_NAME_SKIP_FIRST = frozenset(
826
+ {
827
+ # Kinship / personal pronouns that precede names but are not part of them.
828
+ "Anh", "Em", "Cô", "Chú", "Bác", "Ông", "Bà", "Chị", "Dì", "Cậu",
829
+ "Mợ", "Thím", "Cụ", "Bố", "Ba", "Mẹ", "Má", "Con", "Cháu",
830
+ "Tôi", "Mình", "Tao", "Mày", "Ta", "Họ", "Nó", "Hắn", "Y", "Chúng",
831
+ # Sentence-initial connectives / adverbs that get Title-cased before a
832
+ # name (e.g. "Nên Cố Hàn Thâm" = "Cho nên, Cố Hàn Thâm...").
833
+ "Nhưng", "Vậy", "Được", "Không", "Có", "Rồi", "Sao", "Vì", "Nếu",
834
+ "Khi", "Mà", "Thì", "Hay", "Hoặc", "Nên", "Cũng", "Đã", "Đang", "Sẽ",
835
+ "Lại", "Vẫn", "Còn", "Chỉ", "Cứ", "Phải", "Rất", "Quá", "Thật",
836
+ "Chính", "Của", "Cho", "Với", "Và", "Trong", "Này", "Đó", "Kia", "Ai",
837
+ "Gì", "Đây", "Thế", "Tại", "Bởi", "Do", "Theo", "Từ", "Đến", "Về",
838
+ "Lúc", "Giờ", "Mai", "Nay", "Nãy", "Sau", "Trước", "Trên", "Dưới",
839
+ "Ngoài", "Bên", "Cùng", "Mọi", "Mỗi", "Cả", "Những", "Các", "Một",
840
+ "Hai", "Bốn", "Năm", "Người", "Việc", "Chuyện", "Để", "Là", "Khiến",
841
+ }
842
+ )
843
+ _NAME_SIMILARITY_THRESHOLD = 0.68
844
+
845
+
846
+ def _extract_cjk_name_tokens(text: str) -> list[str]:
847
+ return [
848
+ tok
849
+ for tok in _CJK_NAME_TOKEN.findall(text or "")
850
+ if tok not in _CJK_STOPWORDS
851
+ ]
852
+
853
+
854
+ def _extract_vn_name_phrases(text: str) -> list[str]:
855
+ names: list[str] = []
856
+ for m in _VN_NAME_RE.finditer(text or ""):
857
+ parts = m.group(0).strip().split()
858
+ # Strip leading kinship/pronoun/connective words that get Title-cased in
859
+ # front of a name (e.g. "Nên Cố Hàn Thâm" -> "Cố Hàn Thâm", a sentence
860
+ # that begins with "Nên" = "cho nên"). Repeat to peel multiple, e.g.
861
+ # "Và Anh Tú" -> "Tú" would over-strip, so stop once a non-skip word is hit.
862
+ while parts and parts[0] in _VN_NAME_SKIP_FIRST:
863
+ parts.pop(0)
864
+ if len(parts) < 2:
865
+ continue
866
+ names.append(" ".join(parts))
867
+ return names
868
+
869
+
870
+ def _name_part_similarity(a: str, b: str) -> float:
871
+ return SequenceMatcher(None, a.lower(), b.lower()).ratio()
872
+
873
+
874
+ def _name_phrase_similarity(a: str, b: str) -> float:
875
+ a_parts = a.split()
876
+ b_parts = b.split()
877
+ if not a_parts or not b_parts:
878
+ return 0.0
879
+ if len(a_parts) == len(b_parts):
880
+ scores = [_name_part_similarity(x, y) for x, y in zip(a_parts, b_parts)]
881
+ return sum(scores) / len(scores)
882
+ if abs(len(a_parts) - len(b_parts)) != 1:
883
+ return 0.0
884
+ short, long = (
885
+ (a_parts, b_parts) if len(a_parts) < len(b_parts) else (b_parts, a_parts)
886
+ )
887
+ best = 0.0
888
+ for i in range(len(long) - len(short) + 1):
889
+ chunk = long[i : i + len(short)]
890
+ scores = [_name_part_similarity(x, y) for x, y in zip(short, chunk)]
891
+ best = max(best, sum(scores) / len(scores))
892
+ return best
893
+
894
+
895
+ @dataclass
896
+ class NameRegistry:
897
+ """Lock one Vietnamese spelling per source proper name (CJK in SRT)."""
898
+
899
+ source_tokens: set[str] = field(default_factory=set)
900
+ source_to_vn: dict[str, str] = field(default_factory=dict)
901
+ aliases: dict[str, str] = field(default_factory=dict)
902
+
903
+ @classmethod
904
+ def from_cues(cls, cues: list[Cue]) -> "NameRegistry":
905
+ counts: Counter[str] = Counter()
906
+ for c in cues:
907
+ counts.update(_extract_cjk_name_tokens(c.source))
908
+ # Only tokens that RECUR are treated as proper names. The old rule that
909
+ # also kept any 2-char token seen once flooded the registry with common
910
+ # words (别墅=villa, 礼物=gift, 到了=arrived...) that then corrupted real
911
+ # names. Genuine character/place names recur across a film.
912
+ tokens = {tok for tok, n in counts.items() if n >= 2}
913
+ return cls(source_tokens=tokens)
914
+
915
+ @property
916
+ def has_names(self) -> bool:
917
+ return bool(self.source_tokens or self.source_to_vn)
918
+
919
+ def _register(self, source: str, canonical: str) -> None:
920
+ canonical = canonical.strip()
921
+ if not source or not canonical:
922
+ return
923
+ prev = self.source_to_vn.get(source)
924
+ if prev and prev != canonical:
925
+ self._add_alias(canonical, prev)
926
+ return
927
+ self.source_to_vn[source] = canonical
928
+
929
+ def _add_alias(self, wrong: str, canonical: str) -> None:
930
+ wrong = wrong.strip()
931
+ canonical = canonical.strip()
932
+ if not wrong or wrong == canonical:
933
+ return
934
+ self.aliases[wrong] = canonical
935
+
936
+ def _pick_canonical_for_source(self, vn_names: list[str]) -> str:
937
+ if not vn_names:
938
+ return ""
939
+ scored: list[tuple[float, str]] = []
940
+ for name in vn_names:
941
+ score = float(len(name.split()))
942
+ for other in self.source_to_vn.values():
943
+ score += _name_phrase_similarity(name, other) * 2.0
944
+ scored.append((score, name))
945
+ scored.sort(key=lambda p: (-p[0], -len(p[1])))
946
+ return scored[0][1]
947
+
948
+ def observe_cue(self, cue: Cue) -> None:
949
+ """Learn mappings from a translated cue and fix drift in-place."""
950
+ if not cue.translated:
951
+ return
952
+ src_names = [
953
+ tok
954
+ for tok in _extract_cjk_name_tokens(cue.source)
955
+ if tok in self.source_tokens or tok in self.source_to_vn
956
+ ]
957
+ vn_names = _extract_vn_name_phrases(cue.translated)
958
+ for src in src_names:
959
+ if src in self.source_to_vn:
960
+ canonical = self.source_to_vn[src]
961
+ for vn in vn_names:
962
+ if vn != canonical and _name_phrase_similarity(vn, canonical) >= _NAME_SIMILARITY_THRESHOLD:
963
+ self._add_alias(vn, canonical)
964
+ elif vn_names:
965
+ self._register(src, self._pick_canonical_for_source(vn_names))
966
+ cue.translated = self.normalize_text(cue.translated)
967
+
968
+ def normalize_text(self, text: str) -> str:
969
+ if not text:
970
+ return text
971
+ out = text
972
+ for wrong in sorted(self.aliases, key=len, reverse=True):
973
+ if wrong in out:
974
+ out = out.replace(wrong, self.aliases[wrong])
975
+ for canonical in self.source_to_vn.values():
976
+ for candidate in _extract_vn_name_phrases(out):
977
+ if candidate == canonical:
978
+ continue
979
+ if _name_phrase_similarity(candidate, canonical) >= _NAME_SIMILARITY_THRESHOLD:
980
+ out = out.replace(candidate, canonical)
981
+ return out
982
+
983
+ def reconcile_all(self, cues: list[Cue]) -> None:
984
+ """Final pass: cluster similar Vietnamese names and unify spellings."""
985
+ phrases: Counter[str] = Counter()
986
+ for c in cues:
987
+ phrases.update(_extract_vn_name_phrases(c.translated))
988
+ canonicals = list(self.source_to_vn.values())
989
+ ordered = sorted(phrases, key=lambda p: (-phrases[p], -len(p)))
990
+ for phrase in ordered:
991
+ if phrase in canonicals:
992
+ continue
993
+ best_canon = ""
994
+ best_score = _NAME_SIMILARITY_THRESHOLD
995
+ for canon in canonicals:
996
+ score = _name_phrase_similarity(phrase, canon)
997
+ if score > best_score:
998
+ best_score = score
999
+ best_canon = canon
1000
+ if best_canon:
1001
+ self._add_alias(phrase, best_canon)
1002
+ for c in cues:
1003
+ if c.translated:
1004
+ c.translated = self.normalize_text(c.translated)
1005
+
1006
+ def build_prompt_block(self) -> str:
1007
+ if self.source_to_vn:
1008
+ rows = [
1009
+ f"- {src} → {vn}" for src, vn in sorted(self.source_to_vn.items())
1010
+ ]
1011
+ body = (
1012
+ "\nNAME GUIDE (locked character/place names — use EXACT spellings):\n"
1013
+ + "\n".join(rows)
1014
+ )
1015
+ elif self.source_tokens:
1016
+ sample = ", ".join(sorted(self.source_tokens)[:16])
1017
+ body = (
1018
+ "\nNAME CONSISTENCY (proper names detected in source SRT):\n"
1019
+ f"- Examples in this file: {sample}\n"
1020
+ "- Pick ONE Vietnamese spelling per name and reuse it in every cue."
1021
+ )
1022
+ else:
1023
+ return ""
1024
+ return (
1025
+ body
1026
+ + "\n- When the source mentions a listed name, output ONLY the locked "
1027
+ "Vietnamese form on the right.\n"
1028
+ "- Never alternate spellings for the same person (e.g. Ôn vs Ô, "
1029
+ "Diễn vs Ngiễn, Thời vs Thi, extra/missing middle syllables).\n"
1030
+ "- For a NEW name not yet listed: choose ONE natural Vietnamese "
1031
+ "transliteration and reuse it in every later cue.\n"
1032
+ )
1033
+
1034
+
1035
+ # --------------------------------------------------------------------------- #
1036
+ # Global forms-of-address resolution (xưng hô) per speaker pair
1037
+ # --------------------------------------------------------------------------- #
1038
+ # These CJK patterns are NOT used to force a relationship anymore; they only
1039
+ # surface kinship / romance keywords as *hints* for the LLM resolver below.
1040
+ _CJK_ROMANCE_RE = re.compile(
1041
+ r"同岁|同龄|男朋友|女朋友|男友|女友|老公|老婆|妻子|丈夫|"
1042
+ r"亲爱的|宝贝|宝宝|喜欢你|爱你|我爱你|结婚|订婚|"
1043
+ r"供我上|供你上|养你读|养我读|供我读|供你读|"
1044
+ r"我们(?:俩|两个|一起)|咱俩|情侣|对象"
1045
+ )
1046
+ _CJK_FAMILY_RE = re.compile(
1047
+ r"妈妈|母亲|妈|爸爸|父亲|爸|爹|娘|"
1048
+ r"儿子|女儿|闺女|孩儿|乖儿子|乖女儿|"
1049
+ r"咱妈|咱爸|老妈|老爸|哥哥|姐姐|弟弟|妹妹|"
1050
+ r"爷爷|奶奶|外婆|外公|姥姥|姥爷|叔叔|阿姨|舅舅|姑姑"
1051
+ )
1052
+ def _speaker_pair_key(a: str, b: str) -> frozenset[str]:
1053
+ return frozenset({a, b})
1054
+
1055
+
1056
+ def _collect_relationship_evidence(texts: list[str]) -> str:
1057
+ """List kinship / romance CJK keywords found in a pair's dialogue (hints only)."""
1058
+ joined = "\n".join(t for t in texts if t)
1059
+
1060
+ def _clean(pattern: re.Pattern[str]) -> list[str]:
1061
+ seen: list[str] = []
1062
+ for m in pattern.finditer(joined):
1063
+ tok = re.sub(r"[^\u4e00-\u9fff]", "", m.group(0))
1064
+ if tok and tok not in seen:
1065
+ seen.append(tok)
1066
+ return seen
1067
+
1068
+ parts: list[str] = []
1069
+ fam = _clean(_CJK_FAMILY_RE)
1070
+ rom = _clean(_CJK_ROMANCE_RE)
1071
+ if fam:
1072
+ parts.append("kinship words: " + ", ".join(fam[:8]))
1073
+ if rom:
1074
+ parts.append("romance/same-age words: " + ", ".join(rom[:8]))
1075
+ return "; ".join(parts)
1076
+
1077
+
1078
+ def _conversing_pairs(
1079
+ cues: list[Cue], *, min_exchanges: int = 2
1080
+ ) -> list[frozenset[str]]:
1081
+ """Pairs of speakers that actually take turns talking to each other."""
1082
+ counts: Counter[frozenset[str]] = Counter()
1083
+ seq = [c.speaker for c in cues if c.speaker]
1084
+ for a, b in zip(seq, seq[1:]):
1085
+ if a and b and a != b:
1086
+ counts[_speaker_pair_key(a, b)] += 1
1087
+ return [pair for pair, n in counts.items() if n >= min_exchanges]
1088
+
1089
+
1090
+ def _pair_dialogue_sample(
1091
+ cues: list[Cue], pair: frozenset[str], *, max_lines: int = 40
1092
+ ) -> list[str]:
1093
+ """Chronological, evenly-spaced dialogue lines for a speaker pair."""
1094
+ lines = [
1095
+ f"[{c.speaker}] {c.source.strip()}"
1096
+ for c in cues
1097
+ if c.speaker in pair and c.source.strip()
1098
+ ]
1099
+ if len(lines) <= max_lines:
1100
+ return lines
1101
+ step = len(lines) / max_lines
1102
+ return [lines[int(i * step)] for i in range(max_lines)]
1103
+
1104
+
1105
+ @dataclass
1106
+ class AddressLink:
1107
+ """How one speaker refers to themselves and addresses the other."""
1108
+
1109
+ self_term: str = ""
1110
+ other_term: str = ""
1111
+
1112
+
1113
+ @dataclass
1114
+ class AddressRegistry:
1115
+ """Locked, globally-resolved forms of address per directed speaker pair."""
1116
+
1117
+ directed: dict[tuple[str, str], AddressLink] = field(default_factory=dict)
1118
+ relationships: dict[frozenset[str], dict[str, str]] = field(default_factory=dict)
1119
+
1120
+ @property
1121
+ def has_map(self) -> bool:
1122
+ return bool(self.directed)
1123
+
1124
+ def set_pair(
1125
+ self,
1126
+ a: str,
1127
+ b: str,
1128
+ *,
1129
+ a_self: str,
1130
+ a_other: str,
1131
+ b_self: str,
1132
+ b_other: str,
1133
+ relationship: str = "",
1134
+ confidence: str = "",
1135
+ evidence: str = "",
1136
+ ) -> None:
1137
+ if not a or not b or a == b:
1138
+ return
1139
+ # Need at least the "other" terms (how each addresses the other).
1140
+ if not (a_other.strip() or b_other.strip()):
1141
+ return
1142
+ self.directed[(a, b)] = AddressLink(a_self.strip(), a_other.strip())
1143
+ self.directed[(b, a)] = AddressLink(b_self.strip(), b_other.strip())
1144
+ self.relationships[_speaker_pair_key(a, b)] = {
1145
+ "a": a,
1146
+ "b": b,
1147
+ "relationship": relationship.strip(),
1148
+ "confidence": confidence.strip(),
1149
+ "evidence": evidence.strip(),
1150
+ }
1151
+
1152
+ def describe_lines(self) -> list[str]:
1153
+ out: list[str] = []
1154
+ for meta in self.relationships.values():
1155
+ a, b = meta["a"], meta["b"]
1156
+ ab = self.directed.get((a, b))
1157
+ ba = self.directed.get((b, a))
1158
+ if not ab or not ba:
1159
+ continue
1160
+ rel = meta.get("relationship") or "?"
1161
+ out.append(
1162
+ f"{a}->{b}: {ab.self_term or '?'}/{ab.other_term or '?'} | "
1163
+ f"{b}->{a}: {ba.self_term or '?'}/{ba.other_term or '?'} ({rel})"
1164
+ )
1165
+ return out
1166
+
1167
+ def build_prompt_block(self, scene: Scene | None = None) -> str:
1168
+ if not self.directed:
1169
+ return ""
1170
+ scene_spks = (
1171
+ {c.speaker for c in scene.cues if c.speaker} if scene is not None else None
1172
+ )
1173
+ rows: list[str] = []
1174
+ for meta in self.relationships.values():
1175
+ a, b = meta["a"], meta["b"]
1176
+ if scene_spks is not None and a not in scene_spks and b not in scene_spks:
1177
+ continue
1178
+ ab = self.directed.get((a, b))
1179
+ ba = self.directed.get((b, a))
1180
+ if not ab or not ba:
1181
+ continue
1182
+ rel = meta.get("relationship") or "?"
1183
+ rows.append(
1184
+ f"- {a} <-> {b} ({rel}): when {a} speaks to {b}, {a} calls self "
1185
+ f"\"{ab.self_term}\" and addresses {b} as \"{ab.other_term}\"; "
1186
+ f"when {b} speaks to {a}, {b} calls self \"{ba.self_term}\" and "
1187
+ f"addresses {a} as \"{ba.other_term}\"."
1188
+ )
1189
+ if not rows:
1190
+ return ""
1191
+ return (
1192
+ "\nADDRESS MAP (forms of address locked for the WHOLE film — MANDATORY):\n"
1193
+ + "\n".join(rows)
1194
+ + "\n- Use EXACTLY these self-term / other-term for each speaker pair in "
1195
+ "every cue. NEVER switch to a different pronoun pair between cues or "
1196
+ "mid-scene (no flipping anh/em <-> mẹ/con <-> chị/em <-> cô/cháu...).\n"
1197
+ "- These were resolved from the whole conversation; trust them over voice "
1198
+ "age hints.\n"
1199
+ "- If a cue's speaker pair is NOT listed here, infer the natural Vietnamese "
1200
+ "forms of address from dialogue, names and scene, then keep them stable.\n"
1201
+ )
1202
+
1203
+
1204
+ ADDRESS_SYSTEM = (
1205
+ "You are an expert Vietnamese translator specializing in forms of address "
1206
+ "(xưng hô). Given a whole conversation between two characters, decide the "
1207
+ "natural Vietnamese pronoun pair they use, consistently for the entire film. "
1208
+ "Reply with JSON only."
1209
+ )
1210
+
1211
+
1212
+ def build_address_prompt(
1213
+ spk_a: str,
1214
+ desc_a: str,
1215
+ spk_b: str,
1216
+ desc_b: str,
1217
+ dialogue: list[str],
1218
+ evidence: str,
1219
+ source_lang: str,
1220
+ ) -> str:
1221
+ src = "the source language" if source_lang == "auto" else source_lang
1222
+ ev = f"\nDetected source keywords (hints, may be wrong): {evidence}\n" if evidence else ""
1223
+ return (
1224
+ f"Two characters talk to each other in {src}. Decide the correct Vietnamese "
1225
+ "forms of address between them and keep them STABLE for the whole film.\n\n"
1226
+ f"Speaker A = {spk_a} (voice guess: {desc_a}).\n"
1227
+ f"Speaker B = {spk_b} (voice guess: {desc_b}).\n"
1228
+ f"{ev}\n"
1229
+ "Conversation sample (chronological):\n"
1230
+ + "\n".join(dialogue)
1231
+ + "\n\nGuidance:\n"
1232
+ "- Infer the relationship from what they SAY (kinship terms like 妈/儿子, "
1233
+ "romance like 老公/同岁, formality), NOT from the voice age guess which is "
1234
+ "often wrong.\n"
1235
+ "- Vietnamese has many pronoun pairs; pick whichever fits naturally: "
1236
+ "vợ/chồng, anh-em, chị-em, mẹ-con, bố-con, ông/bà-cháu, cô/chú/dì/cậu-cháu, "
1237
+ "thầy/cô-em (teacher/student), bạn-tớ/cậu, mày-tao, anh/chị-tôi, etc.\n"
1238
+ "- For EACH direction give how the speaker refers to THEMSELVES (self) and how "
1239
+ "they ADDRESS the other (other), e.g. self=\"anh\", other=\"em\".\n\n"
1240
+ "Return ONLY JSON in this exact shape:\n"
1241
+ '{"relationship": "<short label, e.g. romantic couple / mother-son / boss-staff>",'
1242
+ ' "confidence": "high|medium|low",'
1243
+ ' "a_to_b": {"self": "<vi>", "other": "<vi>"},'
1244
+ ' "b_to_a": {"self": "<vi>", "other": "<vi>"},'
1245
+ ' "notes": "<short reason>"}'
1246
+ )
1247
+
1248
+
1249
+ def resolve_address_map(
1250
+ client: "LlamaServer",
1251
+ cues: list[Cue],
1252
+ source_lang: str,
1253
+ *,
1254
+ min_exchanges: int = 2,
1255
+ temperature: float = 0.2,
1256
+ log: Any = None,
1257
+ ) -> AddressRegistry:
1258
+ """Resolve a stable Vietnamese forms-of-address map for each conversing pair."""
1259
+ def _log(msg: str) -> None:
1260
+ if log:
1261
+ log(msg)
1262
+
1263
+ reg = AddressRegistry()
1264
+ genders: dict[str, str] = {}
1265
+ ages: dict[str, str] = {}
1266
+ for c in cues:
1267
+ if c.speaker:
1268
+ genders.setdefault(c.speaker, c.speaker_gender or "")
1269
+ ages.setdefault(c.speaker, c.speaker_age_group or "")
1270
+
1271
+ pairs = _conversing_pairs(cues, min_exchanges=min_exchanges)
1272
+ if not pairs:
1273
+ return reg
1274
+ _log(f" Resolving forms of address for {len(pairs)} speaker pair(s)...")
1275
+ for pair in pairs:
1276
+ a, b = sorted(pair)
1277
+ sample = _pair_dialogue_sample(cues, pair)
1278
+ if not sample:
1279
+ continue
1280
+ evidence = _collect_relationship_evidence(
1281
+ [c.source for c in cues if c.speaker in pair]
1282
+ )
1283
+ prompt = build_address_prompt(
1284
+ a,
1285
+ _speaker_descriptor(genders.get(a, ""), ages.get(a, "")),
1286
+ b,
1287
+ _speaker_descriptor(genders.get(b, ""), ages.get(b, "")),
1288
+ sample,
1289
+ evidence,
1290
+ source_lang,
1291
+ )
1292
+ try:
1293
+ result = client.chat_json(
1294
+ prompt,
1295
+ system=ADDRESS_SYSTEM,
1296
+ max_tokens=512,
1297
+ temperature=temperature,
1298
+ )
1299
+ except Exception as exc: # noqa: BLE001
1300
+ _log(f" [warn] address resolution failed for {a}<->{b}: {exc}")
1301
+ continue
1302
+ if not isinstance(result, dict):
1303
+ continue
1304
+ ab = result.get("a_to_b") if isinstance(result.get("a_to_b"), dict) else {}
1305
+ ba = result.get("b_to_a") if isinstance(result.get("b_to_a"), dict) else {}
1306
+ reg.set_pair(
1307
+ a,
1308
+ b,
1309
+ a_self=str(ab.get("self") or ""),
1310
+ a_other=str(ab.get("other") or ""),
1311
+ b_self=str(ba.get("self") or ""),
1312
+ b_other=str(ba.get("other") or ""),
1313
+ relationship=str(result.get("relationship") or ""),
1314
+ confidence=str(result.get("confidence") or ""),
1315
+ evidence=evidence,
1316
+ )
1317
+ return reg
1318
+
1319
+
1320
+ # --------------------------------------------------------------------------- #
1321
+ # Pass 2 scene vision (multi-frame)
1322
+ # --------------------------------------------------------------------------- #
1323
+ SCENE_VISION_SYSTEM = (
1324
+ "You analyze video frames to help subtitle translators. "
1325
+ "Describe who is visible and their apparent relationship. Reply with JSON only."
1326
+ )
1327
+
1328
+
1329
+ def extract_scene_frames(
1330
+ video: Path,
1331
+ scene: Scene,
1332
+ frame_dir: Path,
1333
+ n_frames: int = 3,
1334
+ max_width: int = 768,
1335
+ ) -> list[Path]:
1336
+ """Extract n evenly-spaced full frames across a scene (start/mid/end)."""
1337
+ n = max(1, min(n_frames, 5))
1338
+ span = max(scene.end - scene.start, 0.05)
1339
+ if n == 1:
1340
+ timestamps = [scene.midpoint]
1341
+ else:
1342
+ pad = min(0.15, span * 0.08)
1343
+ lo, hi = scene.start + pad, scene.end - pad
1344
+ if hi <= lo:
1345
+ lo, hi = scene.start, scene.end
1346
+ step = (hi - lo) / (n - 1)
1347
+ timestamps = [lo + step * i for i in range(n)]
1348
+ paths: list[Path] = []
1349
+ base = int(scene.start * 1000)
1350
+ for i, ts in enumerate(timestamps):
1351
+ out = frame_dir / f"scene_{base:08d}_f{i:02d}.jpg"
1352
+ try:
1353
+ extract_frame(video, ts, out, crop_subtitle=False, max_width=max_width)
1354
+ paths.append(out)
1355
+ except Exception as exc: # noqa: BLE001
1356
+ print(f" [warn] scene frame {i} failed @ {ts:.2f}s: {exc}", flush=True)
1357
+ return paths
1358
+
1359
+
1360
+ @dataclass
1361
+ class SceneVisionResult:
1362
+ description: str = ""
1363
+
1364
+
1365
+ def describe_scene_vision(
1366
+ client: LlamaServer,
1367
+ scene: Scene,
1368
+ images: list[Path],
1369
+ speaker_registry: str,
1370
+ ) -> SceneVisionResult:
1371
+ if not images:
1372
+ return SceneVisionResult()
1373
+ spk_in_scene = sorted({c.speaker for c in scene.cues if c.speaker})
1374
+ spk_line = ", ".join(spk_in_scene) if spk_in_scene else "unknown"
1375
+ prompt = (
1376
+ f"You are given {len(images)} frame(s) from the SAME scene, in chronological "
1377
+ f"order (first image ≈ early, last ≈ late).\n"
1378
+ f"Dialogue speakers tagged in this scene: {spk_line}.\n"
1379
+ + (f"Known speakers:\n{speaker_registry}\n" if speaker_registry else "")
1380
+ + "\nTasks:\n"
1381
+ "1. Count visible people; note apparent gender and age (young adult / middle / "
1382
+ "elderly) from appearance.\n"
1383
+ "2. Infer relationship ONLY when visually clear: romantic_couple, parent_child, "
1384
+ "colleagues, friends, strangers, unknown.\n"
1385
+ "3. Do NOT assume parent–child just because one person looks older — drama "
1386
+ "romances often pair young-looking actors.\n"
1387
+ "4. Note setting (home, restaurant, office…) briefly.\n\n"
1388
+ "Return ONLY JSON:\n"
1389
+ '{"scene_description":"<1-2 sentences>",'
1390
+ '"visible_characters":"<who you see>",'
1391
+ '"likely_relationship":"romantic_couple|parent_child|colleagues|friends|'
1392
+ 'strangers|unknown",'
1393
+ '"relationship_confidence":"high|medium|low"}'
1394
+ )
1395
+ try:
1396
+ result = client.chat_json(
1397
+ prompt,
1398
+ images=images,
1399
+ system=SCENE_VISION_SYSTEM,
1400
+ max_tokens=512,
1401
+ )
1402
+ except Exception as exc: # noqa: BLE001
1403
+ print(f" [warn] scene vision describe failed: {exc}", flush=True)
1404
+ return SceneVisionResult()
1405
+ if not isinstance(result, dict):
1406
+ return SceneVisionResult()
1407
+ parts = [
1408
+ result.get("scene_description", ""),
1409
+ result.get("visible_characters", ""),
1410
+ ]
1411
+ rel = (result.get("likely_relationship") or "").strip()
1412
+ conf = (result.get("relationship_confidence") or "").strip()
1413
+ if rel and rel != "unknown":
1414
+ parts.append(f"Relationship: {rel} ({conf})")
1415
+ description = " | ".join(p.strip() for p in parts if p and str(p).strip())
1416
+ return SceneVisionResult(description=description)
1417
+
1418
+
1419
+ def _apply_scene_vision_to_cues(scene: Scene, description: str) -> None:
1420
+ if not description:
1421
+ return
1422
+ for c in scene.cues:
1423
+ c.visual_context = description
1424
+
1425
+
1426
+ def apply_speaker_report(cues: list[Cue], path: Path) -> int:
1427
+ """Restore speaker/gender/age tags from a previous .report.json (skip diarization)."""
1428
+ data = json.loads(path.read_text(encoding="utf-8"))
1429
+ by_index = {int(item["index"]): item for item in data if "index" in item}
1430
+ by_start = {item.get("start"): item for item in data if item.get("start")}
1431
+ tagged = 0
1432
+ for c in cues:
1433
+ item = by_index.get(c.index) or by_start.get(c.start_ts)
1434
+ if not item:
1435
+ continue
1436
+ spk = (item.get("speaker") or "").strip()
1437
+ if spk:
1438
+ c.speaker = spk
1439
+ c.speaker_gender = (item.get("speaker_gender") or "").strip()
1440
+ c.speaker_age_group = (item.get("speaker_age_group") or "").strip()
1441
+ tagged += 1
1442
+ return tagged
1443
+
1444
+
1445
+ def _history_block(previous: list[Cue], limit: int = 8) -> str:
1446
+ if not previous:
1447
+ return "(none)"
1448
+ rows = []
1449
+ for c in previous[-limit:]:
1450
+ spk = _speaker_tag(c)
1451
+ prefix = f"[{spk}] " if spk else ""
1452
+ rows.append(f"- {prefix}src: {c.source!r} | translated: {c.translated!r}")
1453
+ return "\n".join(rows)
1454
+
1455
+
1456
+ def build_translation_prompt(
1457
+ scene: Scene,
1458
+ previous: list[Cue],
1459
+ source_lang: str,
1460
+ target_lang: str,
1461
+ max_line_chars: int = 42,
1462
+ max_lines: int = 2,
1463
+ speaker_registry: str = "",
1464
+ name_registry: str = "",
1465
+ relationship_block: str = "",
1466
+ scene_vision: str = "",
1467
+ n_scene_images: int = 1,
1468
+ ) -> str:
1469
+ src = "the source language" if source_lang == "auto" else source_lang
1470
+ lines = []
1471
+ for c in scene.cues:
1472
+ budget = c.char_budget or subtitle_char_budget(c.duration)
1473
+ spk = _speaker_tag(c)
1474
+ spk_field = f" | speaker: {spk}" if spk else ""
1475
+ # The scene-level description is shown once in the SCENE VISION block, so
1476
+ # only attach a per-cue visual when it adds something different (avoids
1477
+ # repeating the same paragraph on every cue line).
1478
+ vis = (
1479
+ c.visual_context
1480
+ if c.visual_context and c.visual_context != scene_vision
1481
+ else ""
1482
+ )
1483
+ vis_field = f" | visual: {vis!r}" if vis else ""
1484
+ lines.append(
1485
+ f"- index={c.index} | {c.start_ts} --> {c.end_ts} | "
1486
+ f"duration={c.duration:.1f}s | max_chars={budget}{spk_field} | "
1487
+ f"source: {c.source!r}{vis_field}"
1488
+ )
1489
+ line_rule = (
1490
+ "- Each cue is ONE single line — never use \\n or line breaks in translated.\n"
1491
+ if max_lines <= 1
1492
+ else f"- Up to {max_lines} lines per cue (use \\n between lines if needed).\n"
1493
+ )
1494
+ speaker_block = ""
1495
+ if speaker_registry:
1496
+ speaker_block = (
1497
+ "\nSPEAKER GUIDE (from voice diarization — who is speaking):\n"
1498
+ + speaker_registry
1499
+ + "\n- Each cue's 'speaker' field tells you WHICH character is talking. "
1500
+ "Labels: gender nam=male, nữ=female, trẻ em=child, chưa rõ=unknown; age "
1501
+ "thiếu niên=teen, thanh niên=young adult, trung niên=middle-aged, lớn "
1502
+ "tuổi=elderly.\n"
1503
+ "- GENDER is the reliable anchor: keep each speaker's gender consistent and "
1504
+ "use it to pick the right gendered Vietnamese address — nam → anh/cậu/chú/"
1505
+ "ông/lão...; nữ → cô/chị/dì/bà...; never flip a character's gender between "
1506
+ "cues.\n"
1507
+ "- AGE group is only a ROUGH HINT from voice and is OFTEN WRONG. Trust the "
1508
+ "ADDRESS MAP (if provided), scene images and dialogue over the age label.\n"
1509
+ "- 'chưa rõ' gender or a missing age means it is uncertain: infer everything "
1510
+ "from dialogue, names and the scene image instead of guessing blindly.\n"
1511
+ "- Keep ONE consistent persona per speaker label across the whole film.\n"
1512
+ )
1513
+ name_block = name_registry or ""
1514
+ rel_block = relationship_block or ""
1515
+ vision_intro = (
1516
+ f"{n_scene_images} frame(s) from this scene are attached (chronological order).\n"
1517
+ if n_scene_images > 1
1518
+ else "An image of the current scene is attached for visual context.\n"
1519
+ )
1520
+ scene_vision_block = ""
1521
+ if scene_vision:
1522
+ scene_vision_block = (
1523
+ f"\nSCENE VISION (from video frames — trust over wrong age labels):\n"
1524
+ f"{scene_vision}\n"
1525
+ )
1526
+ if rel_block:
1527
+ pronoun_block = (
1528
+ "PRONOUNS / REGISTER:\n"
1529
+ "- Follow the ADDRESS MAP above EXACTLY for every listed speaker pair: use "
1530
+ "the given self-term and other-term and NEVER swap to a different pronoun "
1531
+ "pair between cues (do not flip anh/em <-> mẹ/con <-> chị/em <-> cô/cháu, "
1532
+ "etc.).\n"
1533
+ f"- For pairs NOT in the map, infer the natural {target_lang} forms of "
1534
+ "address from the dialogue, names, gender and scene, and keep them "
1535
+ "consistent.\n"
1536
+ "- Keep character names EXACTLY as in NAME GUIDE / earlier lines — do not "
1537
+ "drift spellings between cues.\n\n"
1538
+ )
1539
+ else:
1540
+ pronoun_block = (
1541
+ "PRONOUNS / REGISTER:\n"
1542
+ f"- Choose natural, consistent {target_lang} forms of address for each "
1543
+ "speaker pair based on the dialogue, names, gender and scene. Once chosen, "
1544
+ "NEVER switch the pronoun pair for the same two people across cues.\n"
1545
+ "- Keep character names EXACTLY as in NAME GUIDE / earlier lines — do not "
1546
+ "drift spellings between cues.\n\n"
1547
+ )
1548
+ return (
1549
+ f"Translate the following subtitle cues from {src} into {target_lang}.\n"
1550
+ + vision_intro
1551
+ + scene_vision_block
1552
+ + "\nTRANSLATION PRIORITY (natural & concise spoken Vietnamese):\n"
1553
+ "- Render the MEANING the way a Vietnamese person would actually say it aloud — "
1554
+ "not the literal words. Smooth, everyday spoken phrasing.\n"
1555
+ "- Be concise: drop filler, redundant subjects/pronouns and repeated words. A "
1556
+ "shorter, natural line beats a long faithful-but-stiff one.\n"
1557
+ "- Avoid translationese and awkward calques (e.g. 'thông qua việc', 'một cách', "
1558
+ "doubled 'tôi ... tôi ...'); use natural particles (à, nhé, thôi, đấy, mà) where they fit.\n"
1559
+ "- Keep names, honorifics, emotional beats, jokes and plot-critical details.\n"
1560
+ f"{line_rule}"
1561
+ "- Keep within max_chars when you can; if a natural line is shorter, leave it short.\n"
1562
+ + speaker_block
1563
+ + rel_block
1564
+ + name_block
1565
+ + pronoun_block
1566
+ + "Previously translated cues (for consistency):\n"
1567
+ + _history_block(previous)
1568
+ + "\n\nCues to translate now:\n"
1569
+ + "\n".join(lines)
1570
+ + "\n\nReturn ONLY a JSON array, one object per cue, in this exact shape:\n"
1571
+ '[{"index": <int>, "translated": "<string>", '
1572
+ '"honorific_notes": "<brief note on pronouns/forms of address>", '
1573
+ '"timing_notes": "<how you kept it readable within max_chars, or empty>"}]'
1574
+ )
1575
+
1576
+
1577
+ def build_shorten_prompt(cue: Cue, target_lang: str, max_line_chars: int) -> str:
1578
+ over = _translated_char_count(cue.translated) - cue.char_budget
1579
+ return (
1580
+ f"The subtitle below is somewhat long for its on-screen time ({over} chars over guide).\n"
1581
+ f"Duration: {cue.duration:.1f}s | max_chars guide: {cue.char_budget} | "
1582
+ f"source: {cue.source!r}\n"
1583
+ f"Current translation ({_translated_char_count(cue.translated)} chars): "
1584
+ f"{cue.translated!r}\n\n"
1585
+ f"Rewrite in {target_lang} only if needed — keep the same meaning, tone, and "
1586
+ "honorifics. Trim filler words only; do NOT drop key content. Prefer staying close "
1587
+ "to the current wording over aggressive compression.\n"
1588
+ "One single line only — do not use \\n.\n"
1589
+ "Return ONLY JSON: "
1590
+ '{"index": '
1591
+ f"{cue.index}, "
1592
+ '"translated": "<string>", '
1593
+ '"timing_notes": "<what changed, or empty if kept>"}'
1594
+ )
1595
+
1596
+
1597
+ # --------------------------------------------------------------------------- #
1598
+ # Passes
1599
+ # --------------------------------------------------------------------------- #
1600
+ def correct_scene(
1601
+ client: LlamaServer,
1602
+ scene: Scene,
1603
+ video: Path,
1604
+ frame_dir: Path,
1605
+ source_lang: str,
1606
+ ) -> None:
1607
+ images: list[Path] = []
1608
+ for c in scene.cues:
1609
+ out = frame_dir / f"cue_{c.index:05d}_sub.jpg"
1610
+ try:
1611
+ extract_frame(video, c.midpoint, out, crop_subtitle=True)
1612
+ images.append(out)
1613
+ except Exception as exc: # noqa: BLE001
1614
+ print(f" [warn] frame extract failed cue #{c.index}: {exc}", flush=True)
1615
+ # Fall back to an empty/missing frame: skip this image slot.
1616
+ images.append(out if out.exists() else _placeholder_frame(frame_dir))
1617
+
1618
+ prompt = build_correction_prompt(scene, source_lang)
1619
+ try:
1620
+ result = client.chat_json(
1621
+ prompt, images=images, system=CORRECTION_SYSTEM, max_tokens=1536
1622
+ )
1623
+ except Exception as exc: # noqa: BLE001
1624
+ print(f" [warn] correction failed: {exc}", flush=True)
1625
+ for c in scene.cues:
1626
+ c.corrected_source = c.text
1627
+ return
1628
+
1629
+ by_index = {}
1630
+ if isinstance(result, list):
1631
+ for item in result:
1632
+ if isinstance(item, dict) and "cue_index" in item:
1633
+ by_index[int(item["cue_index"])] = item
1634
+ for c in scene.cues:
1635
+ item = by_index.get(c.index, {})
1636
+ c.corrected_source = (item.get("corrected_source") or c.text).strip()
1637
+ c.was_corrected = bool(item.get("was_corrected", False))
1638
+ c.ocr_text = (item.get("ocr_text") or "").strip()
1639
+ c.visual_context = (item.get("visual_context") or "").strip()
1640
+ c.correction_reason = (item.get("correction_reason") or "").strip()
1641
+
1642
+
1643
+ _PLACEHOLDER: Path | None = None
1644
+
1645
+
1646
+ def _placeholder_frame(frame_dir: Path) -> Path:
1647
+ global _PLACEHOLDER
1648
+ if _PLACEHOLDER and _PLACEHOLDER.exists():
1649
+ return _PLACEHOLDER
1650
+ out = frame_dir / "_placeholder.jpg"
1651
+ _run_ffmpeg(
1652
+ ["-f", "lavfi", "-i", "color=c=black:s=64x64", "-frames:v", "1", "-y", str(out)]
1653
+ )
1654
+ _PLACEHOLDER = out
1655
+ return out
1656
+
1657
+
1658
+ def translate_scene(
1659
+ client: LlamaServer,
1660
+ scene: Scene,
1661
+ previous: list[Cue],
1662
+ video: Path,
1663
+ frame_dir: Path,
1664
+ source_lang: str,
1665
+ target_lang: str,
1666
+ use_scene_image: bool,
1667
+ max_line_chars: int,
1668
+ max_lines: int,
1669
+ speaker_registry: str = "",
1670
+ name_registry: NameRegistry | None = None,
1671
+ address_registry: AddressRegistry | None = None,
1672
+ scene_frames: int = 3,
1673
+ use_scene_vision: bool = True,
1674
+ ) -> None:
1675
+ images: list[Path] = []
1676
+ scene_vision = ""
1677
+ if use_scene_image:
1678
+ images = extract_scene_frames(
1679
+ video, scene, frame_dir, n_frames=scene_frames
1680
+ )
1681
+ if use_scene_vision and images:
1682
+ vision = describe_scene_vision(
1683
+ client, scene, images, speaker_registry
1684
+ )
1685
+ scene_vision = vision.description
1686
+ _apply_scene_vision_to_cues(scene, scene_vision)
1687
+
1688
+ prompt = build_translation_prompt(
1689
+ scene,
1690
+ previous,
1691
+ source_lang,
1692
+ target_lang,
1693
+ max_line_chars=max_line_chars,
1694
+ max_lines=max_lines,
1695
+ speaker_registry=speaker_registry,
1696
+ name_registry=name_registry.build_prompt_block() if name_registry else "",
1697
+ relationship_block=(
1698
+ address_registry.build_prompt_block(scene)
1699
+ if address_registry
1700
+ else ""
1701
+ ),
1702
+ scene_vision=scene_vision,
1703
+ n_scene_images=len(images),
1704
+ )
1705
+ try:
1706
+ result = client.chat_json(
1707
+ prompt, images=images or None, system=TRANSLATION_SYSTEM, max_tokens=1536
1708
+ )
1709
+ except Exception as exc: # noqa: BLE001
1710
+ print(f" [warn] translation failed: {exc}", flush=True)
1711
+ for c in scene.cues:
1712
+ c.translated = c.source
1713
+ return
1714
+
1715
+ by_index = {}
1716
+ if isinstance(result, list):
1717
+ for item in result:
1718
+ if isinstance(item, dict) and "index" in item:
1719
+ by_index[int(item["index"])] = item
1720
+ for c in scene.cues:
1721
+ item = by_index.get(c.index, {})
1722
+ c.translated = _normalize_subtitle_text(item.get("translated") or c.source)
1723
+ c.honorific_notes = (item.get("honorific_notes") or "").strip()
1724
+ c.timing_notes = (item.get("timing_notes") or "").strip()
1725
+ if name_registry is not None:
1726
+ name_registry.observe_cue(c)
1727
+
1728
+
1729
+ def shorten_overbudget_cues(
1730
+ client: LlamaServer,
1731
+ cues: list[Cue],
1732
+ target_lang: str,
1733
+ max_line_chars: int,
1734
+ *,
1735
+ slack_chars: int = 8,
1736
+ ) -> int:
1737
+ """Second pass: tighten only cues clearly over the char budget."""
1738
+ fixed = 0
1739
+ for c in cues:
1740
+ if not c.translated or not c.char_budget:
1741
+ continue
1742
+ over = _translated_char_count(c.translated) - c.char_budget
1743
+ if over <= slack_chars:
1744
+ continue
1745
+ prompt = build_shorten_prompt(c, target_lang, max_line_chars)
1746
+ try:
1747
+ result = client.chat_json(
1748
+ prompt, system=TRANSLATION_SYSTEM, max_tokens=512
1749
+ )
1750
+ except Exception as exc: # noqa: BLE001
1751
+ print(f" [warn] shorten failed cue #{c.index}: {exc}", flush=True)
1752
+ continue
1753
+ item = result if isinstance(result, dict) else {}
1754
+ if isinstance(result, list) and result:
1755
+ item = result[0] if isinstance(result[0], dict) else {}
1756
+ shorter = (item.get("translated") or "").strip()
1757
+ shorter = _normalize_subtitle_text(shorter)
1758
+ if shorter and _translated_char_count(shorter) < _translated_char_count(c.translated):
1759
+ c.translated = shorter
1760
+ note = (item.get("timing_notes") or "").strip()
1761
+ if note:
1762
+ c.timing_notes = note
1763
+ fixed += 1
1764
+ return fixed
1765
+
1766
+
1767
+ # --------------------------------------------------------------------------- #
1768
+ # Pipeline
1769
+ # --------------------------------------------------------------------------- #
1770
+ def _plog(args: argparse.Namespace, msg: str) -> None:
1771
+ log_fn = getattr(args, "log_fn", None)
1772
+ if log_fn:
1773
+ log_fn(msg)
1774
+ else:
1775
+ print(msg, flush=True)
1776
+
1777
+
1778
+ def _should_stop(args: argparse.Namespace) -> bool:
1779
+ stop_check = getattr(args, "stop_check", None)
1780
+ return bool(stop_check and stop_check())
1781
+
1782
+
1783
+ def run_pipeline(args: argparse.Namespace) -> None:
1784
+ video = Path(args.video)
1785
+ input_srt = Path(args.input_srt)
1786
+ output_srt = Path(args.output_srt)
1787
+ report_path = Path(args.report) if args.report else output_srt.with_suffix(
1788
+ output_srt.suffix + ".report.json"
1789
+ )
1790
+
1791
+ if not video.exists():
1792
+ raise SystemExit(f"Video not found: {video}")
1793
+ if not input_srt.exists():
1794
+ raise SystemExit(f"SRT not found: {input_srt}")
1795
+
1796
+ server_bin = Path(args.llama_server)
1797
+ model = Path(args.model)
1798
+ mmproj = Path(args.mmproj)
1799
+ if not args.server_url:
1800
+ if not server_bin.exists():
1801
+ raise SystemExit(f"llama-server not found: {server_bin}")
1802
+ if not model.exists():
1803
+ raise SystemExit(f"Model not found: {model}")
1804
+ if not mmproj.exists():
1805
+ raise SystemExit(f"mmproj not found: {mmproj}")
1806
+
1807
+ cues = parse_srt(input_srt)
1808
+ if args.limit and args.limit > 0:
1809
+ cues = cues[: args.limit]
1810
+ if not cues:
1811
+ raise SystemExit("No cues found in SRT.")
1812
+ _plog(args, f"Loaded {len(cues)} cues from {input_srt.name}")
1813
+
1814
+ speaker_report = getattr(args, "speaker_report", "") or ""
1815
+ if speaker_report:
1816
+ report_src = Path(speaker_report)
1817
+ if not report_src.exists():
1818
+ raise SystemExit(f"Speaker report not found: {report_src}")
1819
+ n_tagged = apply_speaker_report(cues, report_src)
1820
+ _plog(args, f" Loaded speaker tags from report: {n_tagged}/{len(cues)} cue(s).")
1821
+
1822
+ speaker_genders: dict[str, str] = {}
1823
+ if getattr(args, "diarize", False):
1824
+ _plog(args, "Pass 0: speaker diarization (pyannote) — who speaks each cue...")
1825
+ t0 = time.time()
1826
+ try:
1827
+ from diarize_audio import diarize_and_tag_cues
1828
+
1829
+ speaker_genders = diarize_and_tag_cues(
1830
+ video,
1831
+ cues,
1832
+ hf_token=getattr(args, "hf_token", None),
1833
+ num_speakers=getattr(args, "num_speakers", 0) or None,
1834
+ min_speakers=getattr(args, "min_speakers", 0) or None,
1835
+ max_speakers=getattr(args, "max_speakers", 0) or None,
1836
+ device=getattr(args, "diarize_device", "auto"),
1837
+ detect_gender=not getattr(args, "no_gender", False),
1838
+ gender_method=getattr(args, "gender_method", "auto"),
1839
+ merge_speakers=getattr(args, "merge_speakers", True),
1840
+ merge_threshold=getattr(args, "merge_speaker_threshold", 0.70),
1841
+ log=lambda m: _plog(args, m),
1842
+ )
1843
+ n_spk = len({c.speaker for c in cues if c.speaker})
1844
+ _plog(
1845
+ args,
1846
+ f" Diarization done in {time.time() - t0:.0f}s "
1847
+ f"({n_spk} speaker(s) tagged).",
1848
+ )
1849
+ except Exception as exc: # noqa: BLE001
1850
+ _plog(args, f" [warn] diarization failed, continuing without it: {exc}")
1851
+
1852
+ scenes = group_scenes(
1853
+ cues, args.scene_max_gap, args.scene_max_cues, args.scene_max_dur
1854
+ )
1855
+ _plog(args, f"Grouped into {len(scenes)} scene(s).")
1856
+
1857
+ _apply_char_budgets(
1858
+ cues, args.chars_per_sec, args.max_line_chars, args.max_lines
1859
+ )
1860
+
1861
+ draft = Path(args.model_draft) if args.model_draft else None
1862
+ client = LlamaServer(
1863
+ server_bin=server_bin,
1864
+ model=model,
1865
+ mmproj=mmproj,
1866
+ ngl=args.ngl,
1867
+ ctx_size=args.ctx,
1868
+ port=args.port,
1869
+ draft=draft,
1870
+ use_mtp=not args.no_mtp,
1871
+ temperature=args.temp,
1872
+ existing_url=args.server_url,
1873
+ )
1874
+ client.start()
1875
+
1876
+ tmp = tempfile.mkdtemp(prefix="gemma_srt_")
1877
+ frame_dir = Path(tmp)
1878
+
1879
+ try:
1880
+ if not args.skip_correction:
1881
+ _plog(args, "Pass 1/2: OCR + correcting source text (vision, per scene)...")
1882
+ t0 = time.time()
1883
+ for i, scene in enumerate(scenes, start=1):
1884
+ if _should_stop(args):
1885
+ _plog(args, "Stopped by user during correction pass.")
1886
+ return
1887
+ rng = f"#{scene.cues[0].index}-#{scene.cues[-1].index}"
1888
+ _plog(args, f" [{i}/{len(scenes)}] scene {rng} @ {scene.start:.1f}s")
1889
+ if getattr(args, "progress_fn", None):
1890
+ args.progress_fn(i, len(scenes), "correction")
1891
+ correct_scene(client, scene, video, frame_dir, args.source_lang)
1892
+ n_fixed = sum(1 for c in cues if c.was_corrected)
1893
+ _plog(args, f" Correction done in {time.time() - t0:.0f}s ({n_fixed} fixed).")
1894
+ else:
1895
+ for c in cues:
1896
+ c.corrected_source = c.text
1897
+
1898
+ if args.merge_pairs:
1899
+ before = len(cues)
1900
+ cues, n_merge = merge_adjacent_pairs(
1901
+ cues,
1902
+ max_gap=args.merge_max_gap,
1903
+ max_combined_duration=args.merge_max_duration,
1904
+ max_combined_chars=args.merge_max_chars,
1905
+ fragment_chars=args.merge_fragment_chars,
1906
+ )
1907
+ _plog(
1908
+ args,
1909
+ f"Merged {n_merge} fragment pair(s): {before} -> {len(cues)} cue(s) "
1910
+ f"(same-speaker, sentence-aware).",
1911
+ )
1912
+ scenes = group_scenes(
1913
+ cues, args.scene_max_gap, args.scene_max_cues, args.scene_max_dur
1914
+ )
1915
+ _apply_char_budgets(
1916
+ cues, args.chars_per_sec, args.max_line_chars, args.max_lines
1917
+ )
1918
+
1919
+ _plog(args, "Pass 2/2: translating with scene context + honorifics...")
1920
+ speaker_registry = build_speaker_registry(cues)
1921
+ if speaker_registry:
1922
+ _plog(args, " Using speaker registry for consistent pronouns:")
1923
+ for row in speaker_registry.splitlines():
1924
+ _plog(args, f" {row}")
1925
+ address_registry = AddressRegistry()
1926
+ if getattr(args, "address_resolution", True):
1927
+ _plog(
1928
+ args,
1929
+ " Resolving global forms of address (xưng hô) per speaker pair...",
1930
+ )
1931
+ t_addr = time.time()
1932
+ address_registry = resolve_address_map(
1933
+ client,
1934
+ cues,
1935
+ args.source_lang,
1936
+ temperature=getattr(args, "address_temp", 0.2),
1937
+ log=lambda m: _plog(args, m),
1938
+ )
1939
+ if address_registry.has_map:
1940
+ _plog(
1941
+ args,
1942
+ f" Address map resolved in {time.time() - t_addr:.0f}s:",
1943
+ )
1944
+ for line in address_registry.describe_lines():
1945
+ _plog(args, f" {line}")
1946
+ else:
1947
+ _plog(args, " No conversing speaker pairs resolved (continuing).")
1948
+ name_registry = NameRegistry.from_cues(cues)
1949
+ if name_registry.has_names:
1950
+ _plog(
1951
+ args,
1952
+ f" Tracking {len(name_registry.source_tokens)} source name token(s) "
1953
+ "for spelling consistency.",
1954
+ )
1955
+ scene_frames = max(1, getattr(args, "scene_frames", 3))
1956
+ use_scene_vision = not getattr(args, "no_scene_vision", False)
1957
+ if not args.no_scene_image:
1958
+ _plog(
1959
+ args,
1960
+ f" Scene vision: {scene_frames} frame(s)/scene"
1961
+ + (" + describe pass" if use_scene_vision else ""),
1962
+ )
1963
+ t0 = time.time()
1964
+ translated: list[Cue] = []
1965
+ for i, scene in enumerate(scenes, start=1):
1966
+ if _should_stop(args):
1967
+ _plog(args, "Stopped by user during translation pass.")
1968
+ return
1969
+ rng = f"#{scene.cues[0].index}-#{scene.cues[-1].index}"
1970
+ _plog(args, f" [{i}/{len(scenes)}] scene {rng}")
1971
+ if getattr(args, "progress_fn", None):
1972
+ args.progress_fn(i, len(scenes), "translation")
1973
+ translate_scene(
1974
+ client,
1975
+ scene,
1976
+ translated,
1977
+ video,
1978
+ frame_dir,
1979
+ args.source_lang,
1980
+ args.target_lang,
1981
+ use_scene_image=not args.no_scene_image,
1982
+ max_line_chars=args.max_line_chars,
1983
+ max_lines=args.max_lines,
1984
+ speaker_registry=speaker_registry,
1985
+ name_registry=name_registry,
1986
+ address_registry=address_registry,
1987
+ scene_frames=scene_frames,
1988
+ use_scene_vision=use_scene_vision,
1989
+ )
1990
+ translated.extend(scene.cues)
1991
+ name_registry.reconcile_all(cues)
1992
+ if name_registry.source_to_vn:
1993
+ _plog(args, " Locked name spellings:")
1994
+ for src, vn in sorted(name_registry.source_to_vn.items()):
1995
+ _plog(args, f" {src} → {vn}")
1996
+ _plog(args, f" Translation done in {time.time() - t0:.0f}s.")
1997
+
1998
+ if args.shorten:
1999
+ over = sum(
2000
+ 1
2001
+ for c in cues
2002
+ if c.translated
2003
+ and c.char_budget
2004
+ and _translated_char_count(c.translated) > c.char_budget + args.shorten_slack
2005
+ )
2006
+ within = sum(
2007
+ 1
2008
+ for c in cues
2009
+ if c.translated
2010
+ and c.char_budget
2011
+ and _translated_char_count(c.translated) <= c.char_budget + args.shorten_slack
2012
+ )
2013
+ if over:
2014
+ _plog(
2015
+ args,
2016
+ f"Pass 2b: {within} cue(s) already within budget (skip); "
2017
+ f"shortening {over} over-budget cue(s)...",
2018
+ )
2019
+ t0 = time.time()
2020
+ n_fixed = shorten_overbudget_cues(
2021
+ client,
2022
+ cues,
2023
+ args.target_lang,
2024
+ args.max_line_chars,
2025
+ slack_chars=args.shorten_slack,
2026
+ )
2027
+ still_over = sum(
2028
+ 1
2029
+ for c in cues
2030
+ if c.translated
2031
+ and c.char_budget
2032
+ and _translated_char_count(c.translated) > c.char_budget
2033
+ )
2034
+ _plog(
2035
+ args,
2036
+ f" Shortened {n_fixed} cue(s) in {time.time() - t0:.0f}s "
2037
+ f"({still_over} still over budget).",
2038
+ )
2039
+ else:
2040
+ _plog(args, f"Pass 2b: all {within} cue(s) within budget — skip shorten.")
2041
+
2042
+ within = sum(
2043
+ 1
2044
+ for c in cues
2045
+ if c.translated
2046
+ and (
2047
+ not c.char_budget
2048
+ or _translated_char_count(c.translated) <= c.char_budget
2049
+ )
2050
+ )
2051
+ _plog(args, f" Timing fit: {within}/{len(cues)} cues within char budget.")
2052
+
2053
+ write_srt(cues, output_srt, use_translation=True)
2054
+ corrected_path = output_srt.with_suffix(".corrected" + output_srt.suffix)
2055
+ write_srt(cues, corrected_path, use_translation=False)
2056
+ write_report(cues, report_path)
2057
+
2058
+ _plog(args, "Done.")
2059
+ _plog(args, f" Translated SRT: {output_srt}")
2060
+ _plog(args, f" Corrected source: {corrected_path}")
2061
+ _plog(args, f" Report JSON: {report_path}")
2062
+ finally:
2063
+ if client.owns_server:
2064
+ client.stop()
2065
+
2066
+
2067
+ # --------------------------------------------------------------------------- #
2068
+ # CLI
2069
+ # --------------------------------------------------------------------------- #
2070
+ def build_parser() -> argparse.ArgumentParser:
2071
+ p = argparse.ArgumentParser(
2072
+ description="Correct and translate SRT using Gemma 4 vision + scene context "
2073
+ "(persistent llama-server)."
2074
+ )
2075
+ p.add_argument("--video", required=True, help="Input video file")
2076
+ p.add_argument("--input-srt", required=True, help="Source SRT file")
2077
+ p.add_argument("--output-srt", required=True, help="Output translated SRT path")
2078
+ p.add_argument("--report", default="", help="JSON report path (default: <output>.report.json)")
2079
+
2080
+ p.add_argument("--source-lang", default="auto", help="Source language (default: auto)")
2081
+ p.add_argument("--target-lang", default="Vietnamese", help="Target language (default: Vietnamese)")
2082
+ p.add_argument("--limit", type=int, default=0, help="Process only first N cues (0=all)")
2083
+
2084
+ p.add_argument("--skip-correction", action="store_true", help="Skip vision OCR/correction pass")
2085
+ p.add_argument("--no-scene-image", action="store_true", help="Translate without scene image context")
2086
+ p.add_argument(
2087
+ "--scene-frames",
2088
+ type=int,
2089
+ default=3,
2090
+ help="Number of video frames per scene for Pass 2 vision (default: 3, max 5)",
2091
+ )
2092
+ p.add_argument(
2093
+ "--no-scene-vision",
2094
+ action="store_true",
2095
+ help="Skip Pass 2 scene describe step (still sends frames to translation)",
2096
+ )
2097
+ p.add_argument(
2098
+ "--address-resolution",
2099
+ action=argparse.BooleanOptionalAction,
2100
+ default=True,
2101
+ help="Resolve a stable Vietnamese forms-of-address (xưng hô) map per speaker "
2102
+ "pair before translation, so pronouns stay consistent (default: on). Needs "
2103
+ "speaker tags (diarization or --speaker-report).",
2104
+ )
2105
+ p.add_argument(
2106
+ "--address-temp",
2107
+ type=float,
2108
+ default=0.2,
2109
+ help="Temperature for the forms-of-address resolution pass (default: 0.2)",
2110
+ )
2111
+
2112
+ p.add_argument(
2113
+ "--speaker-report",
2114
+ default="",
2115
+ help="Reuse speaker/gender tags from a previous .report.json (skips diarization)",
2116
+ )
2117
+ p.add_argument(
2118
+ "--diarize",
2119
+ action=argparse.BooleanOptionalAction,
2120
+ default=False,
2121
+ help="Pass 0: run pyannote speaker diarization and tag cues with speaker + "
2122
+ "gender so pronouns stay consistent (default: off)",
2123
+ )
2124
+ p.add_argument(
2125
+ "--hf-token",
2126
+ default="",
2127
+ help="Hugging Face token for pyannote (or set HF_TOKEN env var)",
2128
+ )
2129
+ p.add_argument(
2130
+ "--num-speakers",
2131
+ type=int,
2132
+ default=0,
2133
+ help="Exact number of speakers if known (0=auto-detect)",
2134
+ )
2135
+ p.add_argument(
2136
+ "--min-speakers", type=int, default=0, help="Minimum speakers hint (0=unset)"
2137
+ )
2138
+ p.add_argument(
2139
+ "--max-speakers", type=int, default=0, help="Maximum speakers hint (0=unset)"
2140
+ )
2141
+ p.add_argument(
2142
+ "--no-gender",
2143
+ action="store_true",
2144
+ help="Diarize speakers but skip age/gender estimation entirely",
2145
+ )
2146
+ p.add_argument(
2147
+ "--gender-method",
2148
+ choices=["auto", "model", "pitch"],
2149
+ default="auto",
2150
+ help="How to estimate age/gender: 'model' (audeering wav2vec2, accurate, "
2151
+ "predicts age too), 'pitch' (lightweight F0, gender only), 'auto' "
2152
+ "(model with pitch fallback). Default: auto",
2153
+ )
2154
+ p.add_argument(
2155
+ "--diarize-device",
2156
+ default="auto",
2157
+ help="Device for diarization: auto|cuda|cpu (default: auto)",
2158
+ )
2159
+ p.add_argument(
2160
+ "--merge-speakers",
2161
+ action=argparse.BooleanOptionalAction,
2162
+ default=True,
2163
+ help="After diarization, merge labels that voice embeddings say are the same "
2164
+ "person (fixes pyannote over-splitting one speaker). Skipped when "
2165
+ "--num-speakers is set. Default: on",
2166
+ )
2167
+ p.add_argument(
2168
+ "--merge-speaker-threshold",
2169
+ type=float,
2170
+ default=0.70,
2171
+ help="Conservative cosine-similarity threshold to merge two speaker labels "
2172
+ "(higher = safer/fewer merges). Default: 0.70",
2173
+ )
2174
+
2175
+ p.add_argument("--scene-max-gap", dest="scene_max_gap", type=float, default=1.5)
2176
+ p.add_argument("--scene-max-cues", dest="scene_max_cues", type=int, default=4)
2177
+ p.add_argument("--scene-max-dur", dest="scene_max_dur", type=float, default=20.0)
2178
+
2179
+ p.add_argument(
2180
+ "--chars-per-sec",
2181
+ type=float,
2182
+ default=22.0,
2183
+ help="Readable chars/sec for timing guide (default: 22; was 17)",
2184
+ )
2185
+ p.add_argument(
2186
+ "--max-line-chars",
2187
+ type=int,
2188
+ default=52,
2189
+ help="Max characters per subtitle line (default: 52)",
2190
+ )
2191
+ p.add_argument(
2192
+ "--max-lines",
2193
+ type=int,
2194
+ default=2,
2195
+ help="Max lines per cue for budget cap (default: 2)",
2196
+ )
2197
+ p.add_argument(
2198
+ "--shorten",
2199
+ action=argparse.BooleanOptionalAction,
2200
+ default=False,
2201
+ help="Enable pass 2b to trim cues far over char budget (default: off)",
2202
+ )
2203
+ p.add_argument(
2204
+ "--shorten-slack",
2205
+ type=int,
2206
+ default=8,
2207
+ help="Only shorten if translation exceeds budget by more than N chars (default: 8)",
2208
+ )
2209
+ p.add_argument(
2210
+ "--merge-pairs",
2211
+ action=argparse.BooleanOptionalAction,
2212
+ default=True,
2213
+ help="Merge adjacent cue pairs before translation (default: on)",
2214
+ )
2215
+ p.add_argument(
2216
+ "--merge-max-gap",
2217
+ type=float,
2218
+ default=0.12,
2219
+ help="Max gap in seconds between two cues to merge. Only near-contiguous cues "
2220
+ "(end timestamp touching the next start) are merged (default: 0.12)",
2221
+ )
2222
+ p.add_argument(
2223
+ "--merge-max-duration",
2224
+ type=float,
2225
+ default=10.0,
2226
+ help="Max combined duration for a merged pair in seconds (default: 10)",
2227
+ )
2228
+ p.add_argument(
2229
+ "--merge-max-chars",
2230
+ type=int,
2231
+ default=32,
2232
+ help="Max combined source length (chars, whitespace ignored) for a merged "
2233
+ "pair, to keep on-screen lines readable (default: 32)",
2234
+ )
2235
+ p.add_argument(
2236
+ "--merge-fragment-chars",
2237
+ type=int,
2238
+ default=12,
2239
+ help="Only merge when at least one cue is this short (a fragment); keeps two "
2240
+ "full sentences from being glued together (default: 12)",
2241
+ )
2242
+
2243
+ p.add_argument("--llama-server", default=str(DEFAULT_LLAMA_SERVER))
2244
+ p.add_argument("--model", default=str(DEFAULT_MODEL))
2245
+ p.add_argument("--mmproj", default=str(DEFAULT_MMPROJ))
2246
+ p.add_argument("--model-draft", default=str(DEFAULT_DRAFT))
2247
+ p.add_argument("--server-url", default="", help="Use an already-running server instead of spawning one")
2248
+ p.add_argument("--port", type=int, default=0, help="Server port (0=auto)")
2249
+ p.add_argument("--ngl", type=int, default=999)
2250
+ p.add_argument("--ctx", type=int, default=8192)
2251
+ p.add_argument(
2252
+ "--temp",
2253
+ type=float,
2254
+ default=0.3,
2255
+ help="Sampling temperature for translation (default: 0.3 for consistency)",
2256
+ )
2257
+ p.add_argument("--no-mtp", action="store_true", help="Disable MTP speculative decoding")
2258
+ return p
2259
+
2260
+
2261
+ def main(argv: list[str] | None = None) -> int:
2262
+ # Vietnamese log lines (speaker registry, etc.) need a UTF-8 console on Windows.
2263
+ for stream in (sys.stdout, sys.stderr):
2264
+ try:
2265
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
2266
+ except (AttributeError, ValueError):
2267
+ pass
2268
+ parser = build_parser()
2269
+ args = parser.parse_args(argv)
2270
+ if args.port == 0:
2271
+ args.port = None
2272
+ if not args.server_url:
2273
+ args.server_url = None
2274
+ run_pipeline(args)
2275
+ return 0
2276
+
2277
+
2278
+ if __name__ == "__main__":
2279
+ raise SystemExit(main())
huggingface/README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - vi
5
+ - en
6
+ tags:
7
+ - subtitles
8
+ - srt
9
+ - translation
10
+ - gemma4
11
+ - vision
12
+ - llama.cpp
13
+ pipeline_tag: translation
14
+ library_name: gemma-srt-translate
15
+ ---
16
+
17
+ # Gemma SRT Translate
18
+
19
+ Dịch và sửa phụ đề SRT bằng **Gemma 4 12B vision** (llama-server + MTP).
20
+ Repo này chứa **script + notebook Colab**; model GGUF tải từ [`unsloth/gemma-4-12B-it-qat-GGUF`](https://huggingface.co/unsloth/gemma-4-12B-it-qat-GGUF).
21
+
22
+ ## Cấu trúc repo
23
+
24
+ ```
25
+ YOUR_USERNAME/gemma-srt-translate/
26
+ ├── README.md ← file này
27
+ ├── config.yaml ← cấu hình mặc định
28
+ ├── translate_srt.py ← pipeline chính
29
+ ├── diarize_audio.py ← Pass 0: phân tích giọng nói (tùy chọn)
30
+ ├── requirements-diarize.txt ← dependency cho Pass 0
31
+ ├── colab/
32
+ │ └── GemmaSRT_Colab.ipynb ← chạy trên Google Colab (L4 24GB)
33
+ └── scripts/
34
+ ├── download_models.py ← tải GGUF từ Unsloth
35
+ └── build_llama_server.sh ← build llama-server Linux (Colab)
36
+ ```
37
+
38
+ ## Chạy trên Google Colab (khuyến nghị L4 24GB)
39
+
40
+ Repo **Private** → **không** mở được bằng nút Open in Colab trên HF (lỗi 401).
41
+
42
+ **Cách mở:** tải [colab/GemmaSRT_Colab.ipynb](https://huggingface.co/STBack23/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb) → Colab **File → Upload notebook** → dán **`HF_TOKEN`** vào cell cấu hình → lưu copy lên Drive `Gemma/` để dùng lại.
43
+
44
+ Repo này nằm trên **Hugging Face**, không phải GitHub — link `colab.research.google.com/github/...` sẽ lỗi 404.
45
+
46
+ **Cách mở (chọn một):**
47
+
48
+ 1. **Nút Open in Colab trên HF** (khuyến nghị):
49
+ [colab/GemmaSRT_Colab.ipynb](https://huggingface.co/STBack23/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb) → bấm **Open in Colab**
50
+
51
+ 2. **Shortcut HF `/colab`**:
52
+ [https://huggingface.co/STBack23/gemma-srt-translate/colab](https://huggingface.co/STBack23/gemma-srt-translate/colab)
53
+
54
+ 3. **Colab → File → Upload notebook** → tải file `.ipynb` từ HF về rồi upload
55
+
56
+ Sau khi mở notebook:
57
+
58
+ 1. Runtime → **Change runtime type** → GPU **L4** (hoặc T4/A100)
59
+ 2. Chạy tuần tự các cell (lần đầu ~15–20 phút: build llama-server + tải model)
60
+ 3. Upload video + SRT hoặc trỏ vào Google Drive
61
+ 4. Tải file `*.vi.srt` về máy
62
+
63
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://huggingface.co/STBack23/gemma-srt-translate/colab)
64
+
65
+ ## Chạy trên máy local (Windows)
66
+
67
+ Repo HF **không** chứa `llama-server.exe` — dùng bản Windows trong project gốc:
68
+
69
+ ```powershell
70
+ .\run-translate.ps1 -Video "phim.mp4" -InputSrt "phim.srt" -OutputSrt "phim.vi.srt"
71
+ ```
72
+
73
+ Hoặc mở GUI: `GemmaSRT.bat`
74
+
75
+ ## Tải model thủ công
76
+
77
+ ```bash
78
+ pip install huggingface_hub
79
+ python scripts/download_models.py --dest ./models
80
+ ```
81
+
82
+ File tải về (~12–15 GB):
83
+
84
+ | File | Mục đích |
85
+ |------|----------|
86
+ | `gemma-4-12B-it-qat-UD-Q4_K_XL.gguf` | Model chính |
87
+ | `mmproj-F16.gguf` | Vision projector |
88
+ | `mtp-gemma-4-12B-it.gguf` | MTP draft (tăng tốc) |
89
+
90
+ ## Pipeline
91
+
92
+ 1. Parse SRT → gom scene
93
+ 2. **Pass 0** *(tùy chọn)*: phân tích giọng nói (pyannote) → "ai nói câu nào" + giới tính/độ tuổi → gán vào từng cue để xưng hô nhất quán
94
+ 3. **Pass 1**: cắt frame (ffmpeg) → OCR/sửa SRT gốc (vision)
95
+ 4. **Pass 2**: dịch theo ngữ cảnh + xưng hô
96
+ 5. Ghi SRT dịch, SRT đã sửa, báo cáo JSON
97
+
98
+ ### Pass 0 — phân tích giọng nói (diarization)
99
+
100
+ Bật bằng `--diarize` (CLI) hoặc `ENABLE_DIARIZE = True` (Colab). Cần thêm:
101
+
102
+ ```bash
103
+ pip install -r requirements-diarize.txt
104
+ ```
105
+
106
+ Token HF phải bấm **Agree** điều kiện các model gated:
107
+ [community-1](https://hf.co/pyannote/speaker-diarization-community-1) ·
108
+ [3.1](https://hf.co/pyannote/speaker-diarization-3.1) ·
109
+ [segmentation-3.0](https://hf.co/pyannote/segmentation-3.0).
110
+ `--gender-method model` dùng [audeering wav2vec2 age/gender](https://hf.co/audeering/wav2vec2-large-robust-24-ft-age-gender) (~1GB).
111
+
112
+ ## VRAM
113
+
114
+ | GPU | 1 phim | 2 phim song song |
115
+ |-----|--------|------------------|
116
+ | RTX 4060 Ti 16GB | ✅ | ❌ |
117
+ | Colab L4 24GB | ✅ | ❌ (dùng local + Colab = 2 phim độc lập) |
118
+
119
+ ## Cấu hình
120
+
121
+ Chỉnh `config.yaml` hoặc tham số CLI:
122
+
123
+ ```bash
124
+ python translate_srt.py \
125
+ --video phim.mp4 \
126
+ --input-srt phim.srt \
127
+ --output-srt phim.vi.srt \
128
+ --target-lang Vietnamese \
129
+ --skip-correction # bỏ pass OCR (nhanh hơn)
130
+ ```
131
+
132
+ ## Model gốc
133
+
134
+ - [unsloth/gemma-4-12B-it-qat-GGUF](https://huggingface.co/unsloth/gemma-4-12B-it-qat-GGUF)
135
+ - [Gemma 4 license](https://ai.google.dev/gemma/docs/gemma_4_license)
136
+
137
+ ## Upload repo lên Hugging Face
138
+
139
+ Xem [UPLOAD.md](./UPLOAD.md) (trong project gốc: `huggingface/UPLOAD.md`).
huggingface/UPLOAD.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Upload repo lên Hugging Face
2
+
3
+ Hướng dẫn đẩy **code + notebook** lên Hugging Face Hub. Model GGUF **không** cần upload (tải từ Unsloth khi chạy).
4
+
5
+ ## 1. Chuẩn bị
6
+
7
+ ```powershell
8
+ pip install huggingface_hub
9
+ huggingface-cli login
10
+ ```
11
+
12
+ ## 2. Gom file staging
13
+
14
+ Từ thư mục gốc project:
15
+
16
+ ```powershell
17
+ .\huggingface\scripts\prepare_upload.ps1
18
+ ```
19
+
20
+ Tạo thư mục `hf-upload/` với cấu trúc sẵn sàng push.
21
+
22
+ ## 3. Sửa config
23
+
24
+ Mở `hf-upload/config.yaml` và thay:
25
+
26
+ ```yaml
27
+ repo:
28
+ id: "YOUR_USERNAME/gemma-srt-translate"
29
+ ```
30
+
31
+ Cũng sửa link Colab trong `hf-upload/README.md` (tìm `YOUR_USERNAME`).
32
+
33
+ ## 4. Tạo repo trên Hugging Face
34
+
35
+ Trên [huggingface.co/new](https://huggingface.co/new):
36
+
37
+ - **Repository name**: `gemma-srt-translate`
38
+ - **Type**: Model
39
+ - **License**: Apache 2.0
40
+
41
+ Hoặc qua CLI:
42
+
43
+ ```bash
44
+ huggingface-cli repo create gemma-srt-translate --type model
45
+ ```
46
+
47
+ ## 5. Upload
48
+
49
+ ```bash
50
+ cd hf-upload
51
+ huggingface-cli upload YOUR_USERNAME/gemma-srt-translate . --repo-type model
52
+ ```
53
+
54
+ Upload lại sau khi sửa code:
55
+
56
+ ```bash
57
+ huggingface-cli upload YOUR_USERNAME/gemma-srt-translate . --repo-type model --commit-message "Update translate_srt.py"
58
+ ```
59
+
60
+ ## 6. Kiểm tra Colab
61
+
62
+ 1. Mở `https://colab.research.google.com/github/YOUR_USERNAME/gemma-srt-translate/blob/main/colab/GemmaSRT_Colab.ipynb`
63
+ 2. Hoặc upload notebook trực tiếp từ Drive
64
+ 3. Cell đầu tiên: đặt `HF_REPO = "YOUR_USERNAME/gemma-srt-translate"`
65
+
66
+ ## Cấu trúc sau upload
67
+
68
+ ```
69
+ YOUR_USERNAME/gemma-srt-translate/ (repo HF — chỉ code)
70
+ ├── translate_srt.py
71
+ ├── config.yaml
72
+ ├── README.md
73
+ ├── colab/GemmaSRT_Colab.ipynb
74
+ └── scripts/
75
+ ├── download_models.py
76
+ └── build_llama_server.sh
77
+
78
+ unsloth/gemma-4-12B-it-qat-GGUF/ (repo model — tải khi chạy)
79
+ ├── gemma-4-12B-it-qat-UD-Q4_K_XL.gguf
80
+ ├── mmproj-F16.gguf
81
+ └── mtp-gemma-4-12B-it.gguf
82
+ ```
83
+
84
+ ## Workflow song song (local + Colab)
85
+
86
+ | Máy | Việc |
87
+ |-----|------|
88
+ | Local (4060 Ti) | GUI → dịch phim A |
89
+ | Colab L4 | Notebook → dịch phim B |
90
+ | Hugging Face | Tải code nhanh; model từ Unsloth |
91
+
92
+ Hai job **độc lập**, không cần kết nối server giữa local và Colab.
93
+
94
+ ## Lưu ý
95
+
96
+ - **Không** upload file `.gguf` lớn vào repo code (trừ khi bạn muốn mirror riêng).
97
+ - Colab session timeout: phim dài nên mount Drive và cache model (`/content/drive/MyDrive/gemma-srt-cache`).
98
+ - `llama-server` build trên Colab mất ~10–15 phút lần đầu; cache binary trên Drive nếu chạy thường xuyên.
huggingface/config.yaml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cấu hình mặc định cho Gemma SRT Translate (Colab + Hugging Face)
2
+ # Thay YOUR_USERNAME trước khi upload repo.
3
+
4
+ repo:
5
+ id: "STBack23/gemma-srt-translate"
6
+ type: "model" # hoặc "space" nếu deploy Space sau này
7
+
8
+ models:
9
+ source_repo: "unsloth/gemma-4-12B-it-qat-GGUF"
10
+ files:
11
+ - "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf"
12
+ - "mmproj-F16.gguf"
13
+ - "mtp-gemma-4-12B-it.gguf"
14
+
15
+ llama_cpp:
16
+ git_url: "https://github.com/ggml-org/llama.cpp.git"
17
+ cuda_arch: "89" # L4, RTX 4090 (Ada)
18
+ build_target: "llama-server"
19
+
20
+ translate:
21
+ source_lang: "auto"
22
+ target_lang: "Vietnamese"
23
+ chars_per_sec: 22.0
24
+ max_line_chars: 52
25
+ max_lines: 2
26
+ shorten: false
27
+ scene_max_gap: 1.5
28
+ scene_max_cues: 4
29
+ scene_max_dur: 20.0
30
+ ctx: 8192
31
+ ngl: 999
32
+ temp: 0.7
33
+ use_mtp: true
34
+
35
+ paths:
36
+ local_models: "models"
37
+ colab_root: "/content/gemma-srt"
38
+ drive_root: "/content/drive/MyDrive/Gemma"
39
+ drive_cache: "/content/drive/MyDrive/Gemma/Cache"
40
+ drive_phim: "/content/drive/MyDrive/Gemma/Phim"
huggingface/scripts/build_llama_server.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Build llama-server with CUDA for Colab (T4=75, L4=89).
3
+ set -euo pipefail
4
+
5
+ LLAMA_DIR="${1:-/content/llama.cpp}"
6
+ CUDA_ARCH="${2:-75}"
7
+
8
+ export CUDA_HOME="${CUDA_HOME:-/usr/local/cuda}"
9
+ export PATH="${CUDA_HOME}/bin:${PATH}"
10
+ export LD_LIBRARY_PATH="${CUDA_HOME}/lib64:${LD_LIBRARY_PATH:-}"
11
+
12
+ echo "[build] CUDA_HOME=${CUDA_HOME} arch=${CUDA_ARCH}"
13
+
14
+ if [[ ! -d "${LLAMA_DIR}/.git" ]]; then
15
+ git clone --depth 1 https://github.com/ggml-org/llama.cpp.git "${LLAMA_DIR}"
16
+ else
17
+ git -C "${LLAMA_DIR}" fetch origin master
18
+ git -C "${LLAMA_DIR}" reset --hard origin/master
19
+ fi
20
+
21
+ rm -rf "${LLAMA_DIR}/build"
22
+
23
+ cmake -S "${LLAMA_DIR}" -B "${LLAMA_DIR}/build" \
24
+ -DGGML_CUDA=ON \
25
+ -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCH}" \
26
+ -DCMAKE_BUILD_TYPE=Release \
27
+ -DCMAKE_CUDA_COMPILER="${CUDA_HOME}/bin/nvcc"
28
+
29
+ cmake --build "${LLAMA_DIR}/build" --config Release -j "$(nproc)" --target llama-server
30
+
31
+ BIN="${LLAMA_DIR}/build/bin/llama-server"
32
+ if [[ ! -x "${BIN}" ]]; then
33
+ echo "[build] ERROR: ${BIN} not found"
34
+ exit 1
35
+ fi
36
+
37
+ echo "[build] OK: ${BIN}"
38
+ "${BIN}" --version 2>/dev/null || true
huggingface/scripts/download_models.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tải model Gemma 4 QAT + mmproj + MTP từ Hugging Face."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ DEFAULT_SOURCE = "unsloth/gemma-4-12B-it-qat-GGUF"
10
+ DEFAULT_FILES = [
11
+ "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
12
+ "mmproj-F16.gguf",
13
+ "mtp-gemma-4-12B-it.gguf",
14
+ ]
15
+
16
+
17
+ def download(
18
+ dest: Path,
19
+ source_repo: str = DEFAULT_SOURCE,
20
+ files: list[str] | None = None,
21
+ token: str | None = None,
22
+ ) -> dict[str, Path]:
23
+ from huggingface_hub import hf_hub_download
24
+
25
+ dest.mkdir(parents=True, exist_ok=True)
26
+ files = files or DEFAULT_FILES
27
+ result: dict[str, Path] = {}
28
+
29
+ for name in files:
30
+ print(f"[download] {source_repo}/{name} -> {dest}/")
31
+ path = hf_hub_download(
32
+ repo_id=source_repo,
33
+ filename=name,
34
+ local_dir=str(dest),
35
+ local_dir_use_symlinks=False,
36
+ token=token,
37
+ )
38
+ result[name] = Path(path)
39
+ print(f" OK: {path}")
40
+
41
+ return result
42
+
43
+
44
+ def main() -> int:
45
+ p = argparse.ArgumentParser(description="Download Gemma 4 GGUF files for SRT translation")
46
+ p.add_argument(
47
+ "--dest",
48
+ type=Path,
49
+ default=Path("models"),
50
+ help="Output directory (default: ./models)",
51
+ )
52
+ p.add_argument(
53
+ "--source-repo",
54
+ default=DEFAULT_SOURCE,
55
+ help=f"Hugging Face model repo (default: {DEFAULT_SOURCE})",
56
+ )
57
+ p.add_argument(
58
+ "--file",
59
+ action="append",
60
+ dest="files",
61
+ help="Download specific file(s); repeat flag. Default: all 3 core files.",
62
+ )
63
+ p.add_argument("--token", default=None, help="HF token (optional, for gated models)")
64
+ args = p.parse_args()
65
+
66
+ download(args.dest, args.source_repo, args.files, args.token)
67
+ print(f"\nDone. Models in: {args.dest.resolve()}")
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())
huggingface/scripts/ensure_llama_colab.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Restore or build llama-server for Google Colab (CUDA + Drive cache)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import tarfile
10
+ import time
11
+ from pathlib import Path
12
+
13
+ MIN_LLAMA_TGZ_BYTES = 5_000_000
14
+ DRIVE_LLAMA_TGZ = "llama-server-bin.tgz"
15
+ DRIVE_COPY_BLOCK = 4 * 1024 * 1024
16
+ # Gemma 4 draft-MTP merged 2026-06-07 (llama.cpp PR #23398).
17
+ MIN_LLAMA_BUILD_FOR_MTP = 9549
18
+ # Tag b9553 (9e3b928) — verified Gemma 4 draft-mtp on L4; newer master may regress.
19
+ LLAMA_CPP_PIN = "b9553"
20
+
21
+
22
+ def _fmt_size(n: int) -> str:
23
+ if n >= 1_048_576:
24
+ return f"{n / 1_048_576:.1f} MB"
25
+ return f"{n / 1024:.0f} KB"
26
+
27
+
28
+ def _run(cmd, *, check=True, label="", live=False):
29
+ print(f"[llama] $ {' '.join(map(str, cmd))}", flush=True)
30
+ if live:
31
+ proc = subprocess.Popen(
32
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
33
+ )
34
+ assert proc.stdout is not None
35
+ for line in proc.stdout:
36
+ print(line, end="", flush=True)
37
+ proc.wait()
38
+ if check and proc.returncode:
39
+ raise RuntimeError(f"{label or cmd[0]} failed (exit {proc.returncode})")
40
+ return proc
41
+ r = subprocess.run(cmd, capture_output=True, text=True)
42
+ if r.stdout:
43
+ print(r.stdout[-4000:])
44
+ if r.returncode and r.stderr:
45
+ print("--- stderr ---")
46
+ print(r.stderr[-8000:])
47
+ if check and r.returncode:
48
+ raise RuntimeError(f"{label or cmd[0]} failed (exit {r.returncode})")
49
+ return r
50
+
51
+
52
+ def _setup_cuda_env() -> Path | None:
53
+ for cuda in (Path("/usr/local/cuda"), Path("/usr/local/cuda-12.2"), Path("/usr/local/cuda-12.4")):
54
+ nvcc = cuda / "bin" / "nvcc"
55
+ if nvcc.is_file():
56
+ os.environ["CUDA_HOME"] = str(cuda)
57
+ os.environ["PATH"] = f"{cuda / 'bin'}:" + os.environ.get("PATH", "")
58
+ os.environ["LD_LIBRARY_PATH"] = f"{cuda / 'lib64'}:" + os.environ.get("LD_LIBRARY_PATH", "")
59
+ print(f"[llama] CUDA: {cuda} | nvcc OK")
60
+ return nvcc
61
+ return None
62
+
63
+
64
+ def _cuda_arch() -> str:
65
+ try:
66
+ out = subprocess.check_output(
67
+ ["nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader"],
68
+ text=True, timeout=10,
69
+ ).strip().splitlines()[0]
70
+ return out.replace(".", "")
71
+ except Exception:
72
+ return "75"
73
+
74
+
75
+ def _llama_env_for(p: Path) -> dict:
76
+ env = os.environ.copy()
77
+ lib = str(p.parent)
78
+ env["LD_LIBRARY_PATH"] = f"{lib}:{env.get('LD_LIBRARY_PATH', '')}"
79
+ return env
80
+
81
+
82
+ def llama_version_text(p: Path) -> str:
83
+ p = Path(p)
84
+ try:
85
+ r = subprocess.run(
86
+ [str(p), "--version"],
87
+ capture_output=True, text=True, timeout=60, env=_llama_env_for(p),
88
+ )
89
+ except Exception:
90
+ return ""
91
+ return ((r.stdout or "") + (r.stderr or "")).strip()
92
+
93
+
94
+ def llama_build_number(p: Path) -> int | None:
95
+ """Legacy llama.cpp prints ``version: 9535 (hash)``; newer builds may use semver."""
96
+ text = llama_version_text(p)
97
+ m = re.search(r"version:\s*(\d+)", text)
98
+ if not m:
99
+ return None
100
+ n = int(m.group(1))
101
+ # Newer releases use small semver (e.g. version: 1) — not comparable to b9549.
102
+ return n if n >= 1000 else None
103
+
104
+
105
+ def _llama_help_text(p: Path) -> str:
106
+ try:
107
+ r = subprocess.run(
108
+ [str(p), "--help"],
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=120,
112
+ env=_llama_env_for(p),
113
+ )
114
+ except Exception:
115
+ return ""
116
+ return (r.stdout or "") + (r.stderr or "")
117
+
118
+
119
+ def llama_supports_mtp(p: Path) -> bool:
120
+ if not llama_bin_valid(p):
121
+ return False
122
+ if "draft-mtp" in _llama_help_text(p):
123
+ return True
124
+ n = llama_build_number(p)
125
+ return n is not None and n >= MIN_LLAMA_BUILD_FOR_MTP
126
+
127
+
128
+ def llama_bin_valid(p: Path) -> bool:
129
+ """Shared build: llama-server ~17–20 KB + .so cùng thư mục."""
130
+ p = Path(p)
131
+ if not p.is_file() or not os.access(p, os.X_OK):
132
+ return False
133
+ if p.stat().st_size < 8_000:
134
+ return False
135
+ return bool(llama_version_text(p))
136
+
137
+
138
+ def setup_ld_library_path(bin_path: Path) -> None:
139
+ lib = str(bin_path.parent)
140
+ os.environ["LD_LIBRARY_PATH"] = f"{lib}:{os.environ.get('LD_LIBRARY_PATH', '')}"
141
+
142
+
143
+ def _print_bin_info(bin_path: Path, *, require_mtp: bool) -> None:
144
+ ver = llama_version_text(bin_path)
145
+ build = llama_build_number(bin_path)
146
+ mtp = llama_supports_mtp(bin_path)
147
+ print(f"[llama] OK: {bin_path} ({_fmt_size(bin_path.stat().st_size)})")
148
+ if ver:
149
+ print(f"[llama] {ver.splitlines()[0]}")
150
+ if require_mtp:
151
+ if mtp:
152
+ tag = f"build {build}" if build else "draft-mtp in --help"
153
+ print(f"[llama] MTP Gemma 4: supported ({tag})")
154
+ else:
155
+ print("[llama] MTP Gemma 4: NOT supported — will rebuild")
156
+
157
+
158
+ def _purge_invalid_llama_bin(build_dir: Path) -> bool:
159
+ bin_dir = build_dir / "bin"
160
+ if not bin_dir.is_dir():
161
+ return False
162
+ p = bin_dir / "llama-server"
163
+ if p.is_file() and not llama_bin_valid(p):
164
+ print(f"[llama] Xóa binary hỏng (cmake sẽ link lại): {p} ({_fmt_size(p.stat().st_size)})")
165
+ p.unlink(missing_ok=True)
166
+ return True
167
+ return False
168
+
169
+
170
+ def _invalidate_llama_artifacts(llama_dir: Path, drive_cache: Path | None) -> None:
171
+ build_dir = llama_dir / "build"
172
+ if build_dir.exists():
173
+ print(f"[llama] Xóa build cũ: {build_dir}")
174
+ shutil.rmtree(build_dir, ignore_errors=True)
175
+ if drive_cache:
176
+ drive_tgz = drive_cache / DRIVE_LLAMA_TGZ
177
+ if drive_tgz.is_file():
178
+ print(f"[llama] Xóa cache Drive cũ: {drive_tgz}")
179
+ drive_tgz.unlink(missing_ok=True)
180
+ for legacy_name in ("llama-server", "llama-server.tgz"):
181
+ legacy = drive_cache / legacy_name
182
+ if legacy.is_file():
183
+ legacy.unlink(missing_ok=True)
184
+
185
+
186
+ def _drive_copy_file(src: Path, dest: Path, *, label: str = "", min_bytes: int = 1) -> int:
187
+ src = Path(src)
188
+ dest = Path(dest)
189
+ dest.parent.mkdir(parents=True, exist_ok=True)
190
+ sz_src = src.stat().st_size
191
+ if dest.exists():
192
+ dest.unlink()
193
+ tmp = dest.with_name(dest.name + ".part")
194
+ if tmp.exists():
195
+ tmp.unlink()
196
+ with open(src, "rb") as fsrc, open(tmp, "wb") as fdst:
197
+ while True:
198
+ chunk = fsrc.read(DRIVE_COPY_BLOCK)
199
+ if not chunk:
200
+ break
201
+ fdst.write(chunk)
202
+ fdst.flush()
203
+ os.fsync(fdst.fileno())
204
+ sz_tmp = tmp.stat().st_size
205
+ if sz_tmp != sz_src or sz_tmp < min_bytes:
206
+ tmp.unlink(missing_ok=True)
207
+ raise RuntimeError(f"{label}copy thất bại: {sz_tmp} bytes, cần {sz_src}")
208
+ tmp.replace(dest)
209
+ dest.chmod(0o755)
210
+ for _ in range(5):
211
+ sz_dest = dest.stat().st_size
212
+ if sz_dest == sz_src:
213
+ return sz_dest
214
+ time.sleep(1)
215
+ raise RuntimeError(
216
+ f"{label}Drive vẫn sai size sau copy: {dest.stat().st_size} bytes, cần {sz_src}"
217
+ )
218
+
219
+
220
+ def _find_llama_bin(llama_dir: Path, llama_server: Path | None) -> Path | None:
221
+ candidates = []
222
+ if llama_server:
223
+ candidates.append(Path(llama_server))
224
+ candidates.extend([
225
+ llama_dir / "build/bin/llama-server",
226
+ llama_dir / "build/llama-server",
227
+ ])
228
+ seen: set[Path] = set()
229
+ for p in candidates:
230
+ p = p.resolve()
231
+ if p in seen:
232
+ continue
233
+ seen.add(p)
234
+ if llama_bin_valid(p):
235
+ return p
236
+ if p.is_file():
237
+ print(f"[llama] Bỏ qua (không chạy --version): {p} ({_fmt_size(p.stat().st_size)})")
238
+ return None
239
+
240
+
241
+ def _restore_llama_from_drive_tgz(tgz: Path, dest: Path) -> Path:
242
+ local_tgz = Path("/content/llama-server-restore.tgz")
243
+ _drive_copy_file(tgz, local_tgz, label="restore tgz ", min_bytes=MIN_LLAMA_TGZ_BYTES)
244
+ if dest.parent.exists():
245
+ for old in dest.parent.iterdir():
246
+ if old.is_file():
247
+ old.unlink()
248
+ dest.parent.mkdir(parents=True, exist_ok=True)
249
+ with tarfile.open(local_tgz, "r:gz") as tar:
250
+ tar.extractall(path=dest.parent)
251
+ dest.chmod(0o755)
252
+ if not llama_bin_valid(dest):
253
+ raise RuntimeError("Giải nén cache thất bại — thử build lại")
254
+ local_tgz.unlink(missing_ok=True)
255
+ n = sum(1 for f in dest.parent.iterdir() if f.is_file())
256
+ print(f"[llama] restored {n} file từ Drive → {dest.parent}")
257
+ return dest
258
+
259
+
260
+ def _cache_llama_to_drive(bin_path: Path, drive_cache: Path) -> None:
261
+ bin_dir = bin_path.parent
262
+ local_tgz = Path("/content/llama-server-cache.tgz")
263
+ if local_tgz.exists():
264
+ local_tgz.unlink()
265
+ with tarfile.open(local_tgz, "w:gz") as tar:
266
+ for f in sorted(bin_dir.iterdir()):
267
+ if f.is_file():
268
+ tar.add(f, arcname=f.name)
269
+ drive_tgz = drive_cache / DRIVE_LLAMA_TGZ
270
+ sz_tgz = _drive_copy_file(local_tgz, drive_tgz, label="cache tgz ", min_bytes=MIN_LLAMA_TGZ_BYTES)
271
+ local_tgz.unlink(missing_ok=True)
272
+ for legacy_name in ("llama-server", "llama-server.tgz"):
273
+ legacy = drive_cache / legacy_name
274
+ if legacy.is_file():
275
+ legacy.unlink(missing_ok=True)
276
+ build = llama_build_number(bin_path)
277
+ print(
278
+ f"[llama] cached to Drive: {drive_tgz} "
279
+ f"(build {build or '?'}, llama-server {_fmt_size(bin_path.stat().st_size)}, "
280
+ f"tgz {_fmt_size(sz_tgz)}, {sum(1 for f in bin_dir.iterdir() if f.is_file())} files)"
281
+ )
282
+
283
+
284
+ def _sync_llama_source(llama_dir: Path) -> None:
285
+ repo = "https://github.com/ggml-org/llama.cpp.git"
286
+ tag = LLAMA_CPP_PIN
287
+ if llama_dir.exists():
288
+ shutil.rmtree(llama_dir, ignore_errors=True)
289
+ llama_dir.parent.mkdir(parents=True, exist_ok=True)
290
+ _run(
291
+ ["git", "clone", "--depth", "1", "--branch", tag, repo, str(llama_dir)],
292
+ label=f"git clone tag {tag}",
293
+ )
294
+
295
+
296
+ def _build_llama_server(
297
+ llama_dir: Path,
298
+ drive_cache: Path | None,
299
+ *,
300
+ fresh_source: bool = False,
301
+ ) -> Path:
302
+ cuda_arch = _cuda_arch()
303
+ nproc = min(4, os.cpu_count() or 2)
304
+ print(
305
+ f"[llama] Building llama-server (CUDA arch {cuda_arch}, -j {nproc}, "
306
+ f"pin {LLAMA_CPP_PIN}, MTP min build {MIN_LLAMA_BUILD_FOR_MTP})...",
307
+ flush=True,
308
+ )
309
+ print("[llama] Compile L4/T4: thường 20–50 phút. Log in từng dòng — RAM GPU ~0 là bình thường.", flush=True)
310
+
311
+ _run(["apt-get", "-qq", "update"], check=False)
312
+ _run([
313
+ "apt-get", "-qq", "install", "-y",
314
+ "build-essential", "cmake", "git", "pkg-config", "libcurl4-openssl-dev",
315
+ ], label="apt build deps")
316
+
317
+ nvcc = _setup_cuda_env()
318
+ if nvcc is None:
319
+ for pkg in ("cuda-nvcc-12-2", "cuda-nvcc-12-4", "nvidia-cuda-toolkit"):
320
+ r = _run(["apt-get", "-qq", "install", "-y", pkg], check=False, label=pkg)
321
+ if r.returncode == 0:
322
+ nvcc = _setup_cuda_env()
323
+ if nvcc:
324
+ break
325
+ if nvcc is None:
326
+ raise RuntimeError(
327
+ "Không tìm thấy nvcc. Runtime → Change runtime type → GPU (T4/L4), Restart, Run all."
328
+ )
329
+
330
+ if fresh_source:
331
+ _sync_llama_source(llama_dir)
332
+
333
+ build_dir = llama_dir / "build"
334
+ if fresh_source and build_dir.exists():
335
+ shutil.rmtree(build_dir, ignore_errors=True)
336
+
337
+ cache_file = build_dir / "CMakeCache.txt"
338
+ if not cache_file.is_file():
339
+ if build_dir.exists():
340
+ shutil.rmtree(build_dir, ignore_errors=True)
341
+ _run([
342
+ "cmake", "-S", str(llama_dir), "-B", str(build_dir),
343
+ "-DGGML_CUDA=ON",
344
+ f"-DCMAKE_CUDA_ARCHITECTURES={cuda_arch}",
345
+ "-DCMAKE_BUILD_TYPE=Release",
346
+ f"-DCMAKE_CUDA_COMPILER={nvcc}",
347
+ "-DLLAMA_BUILD_TESTS=OFF",
348
+ "-DLLAMA_BUILD_EXAMPLES=OFF",
349
+ ], label="cmake configure")
350
+ else:
351
+ print("[llama] Tiếp tục build cũ (CMakeCache.txt có sẵn)...", flush=True)
352
+
353
+ need_clean = _purge_invalid_llama_bin(build_dir)
354
+ print("[llama] Đang compile llama-server...", flush=True)
355
+ build_cmd = [
356
+ "cmake", "--build", str(build_dir), "--config", "Release",
357
+ "-j", str(nproc), "--target", "llama-server",
358
+ ]
359
+ if need_clean:
360
+ build_cmd.insert(4, "--clean-first")
361
+ print("[llama] --clean-first (xóa stub hỏng, buộc link lại)", flush=True)
362
+ _run(build_cmd, label="cmake build", live=True)
363
+
364
+ bin_path = _find_llama_bin(llama_dir, None)
365
+ if not bin_path:
366
+ raise FileNotFoundError(
367
+ f"Build xong nhưng llama-server không chạy được trong {build_dir}/bin."
368
+ )
369
+ if not llama_supports_mtp(bin_path):
370
+ raise RuntimeError(
371
+ "Build xong nhưng llama-server không hỗ trợ draft-mtp (MTP Gemma 4). "
372
+ "Thử FORCE_LLAMA_REBUILD = True."
373
+ )
374
+ print(f"[llama] Build OK: {bin_path} ({_fmt_size(bin_path.stat().st_size)} + .so cùng thư mục)")
375
+ print(llama_version_text(bin_path).splitlines()[0] if llama_version_text(bin_path) else str(bin_path))
376
+ if drive_cache:
377
+ _cache_llama_to_drive(bin_path, drive_cache)
378
+ return bin_path
379
+
380
+
381
+ def ensure_llama_server(
382
+ *,
383
+ llama_dir: str | Path = "/content/llama.cpp",
384
+ llama_server: str | Path | None = None,
385
+ drive_cache: str | Path | None = None,
386
+ allow_build: bool = True,
387
+ require_mtp: bool = True,
388
+ force_rebuild: bool = False,
389
+ ) -> Path:
390
+ """Restore từ Drive cache hoặc build llama.cpp mới (>= b9549 cho MTP Gemma 4)."""
391
+ llama_dir = Path(llama_dir)
392
+ llama_server = Path(llama_server) if llama_server else llama_dir / "build/bin/llama-server"
393
+ drive_cache = Path(drive_cache) if drive_cache else None
394
+
395
+ if force_rebuild:
396
+ print("[llama] FORCE_LLAMA_REBUILD=True — xóa cache + build lại...")
397
+ _invalidate_llama_artifacts(llama_dir, drive_cache)
398
+ elif require_mtp:
399
+ bin_path = _find_llama_bin(llama_dir, llama_server)
400
+ if bin_path and not llama_supports_mtp(bin_path):
401
+ print("[llama] Binary cũ (không có draft-mtp) — xóa và build lại...")
402
+ _invalidate_llama_artifacts(llama_dir, drive_cache)
403
+
404
+ bin_path = _find_llama_bin(llama_dir, llama_server)
405
+ if bin_path and (not require_mtp or llama_supports_mtp(bin_path)):
406
+ _print_bin_info(bin_path, require_mtp=require_mtp)
407
+ setup_ld_library_path(bin_path)
408
+ return bin_path
409
+
410
+ if drive_cache and not force_rebuild:
411
+ drive_tgz = drive_cache / DRIVE_LLAMA_TGZ
412
+ if drive_tgz.is_file() and drive_tgz.stat().st_size > MIN_LLAMA_TGZ_BYTES:
413
+ dest = llama_dir / "build/bin/llama-server"
414
+ try:
415
+ _restore_llama_from_drive_tgz(drive_tgz, dest)
416
+ if not require_mtp or llama_supports_mtp(dest):
417
+ _print_bin_info(dest, require_mtp=require_mtp)
418
+ setup_ld_library_path(dest)
419
+ return dest
420
+ print("[llama] Cache Drive không hỗ trợ draft-mtp — build lại cho MTP...")
421
+ _invalidate_llama_artifacts(llama_dir, drive_cache)
422
+ except Exception as e:
423
+ print(f"[llama] Cache .tgz hỏng ({e}) — build lại...")
424
+ drive_tgz.unlink(missing_ok=True)
425
+
426
+ for legacy_name in ("llama-server", "llama-server.tgz"):
427
+ legacy = drive_cache / legacy_name
428
+ if legacy.is_file():
429
+ print(f"[llama] Xóa cache cũ: {legacy_name} ({_fmt_size(legacy.stat().st_size)})")
430
+ legacy.unlink(missing_ok=True)
431
+
432
+ if not allow_build:
433
+ raise FileNotFoundError(
434
+ f"Không có llama-server MTP (>= build {MIN_LLAMA_BUILD_FOR_MTP}). "
435
+ f"Đặt allow_build=True hoặc FORCE_LLAMA_REBUILD=True."
436
+ )
437
+
438
+ bin_path = _build_llama_server(llama_dir, drive_cache, fresh_source=True)
439
+ setup_ld_library_path(bin_path)
440
+ _print_bin_info(bin_path, require_mtp=require_mtp)
441
+ return bin_path
huggingface/scripts/prepare_drive.ps1 ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gom code pipeline vào drive-upload/gemma-srt/ để kéo thả lên Google Drive.
2
+ # Notebook (CODE_SOURCE="drive") copy từ Drive/Gemma/gemma-srt/ sang /content.
3
+ # Usage: .\huggingface\scripts\prepare_drive.ps1 [-OutDir .\drive-upload]
4
+
5
+ param(
6
+ [string]$OutDir = ""
7
+ )
8
+
9
+ $ErrorActionPreference = "Stop"
10
+ $Root = Resolve-Path (Join-Path (Join-Path $PSScriptRoot "..") "..")
11
+ if (-not $OutDir) {
12
+ $OutDir = Join-Path $Root "drive-upload"
13
+ }
14
+ $OutDir = [System.IO.Path]::GetFullPath($OutDir)
15
+ $CodeDir = Join-Path $OutDir "gemma-srt"
16
+
17
+ # File map: Src (so với gốc project) -> Dst (so với gemma-srt/)
18
+ $files = @(
19
+ @{ Src = "translate_srt.py"; Dst = "translate_srt.py" },
20
+ @{ Src = "diarize_audio.py"; Dst = "diarize_audio.py" },
21
+ @{ Src = "requirements-diarize.txt"; Dst = "requirements-diarize.txt" },
22
+ @{ Src = "huggingface\config.yaml"; Dst = "config.yaml" },
23
+ @{ Src = "huggingface\scripts\download_models.py"; Dst = "scripts\download_models.py" },
24
+ @{ Src = "huggingface\scripts\build_llama_server.sh"; Dst = "scripts\build_llama_server.sh" },
25
+ @{ Src = "huggingface\scripts\ensure_llama_colab.py"; Dst = "scripts\ensure_llama_colab.py" }
26
+ )
27
+
28
+ # Tạo cây thư mục (không xóa cả thư mục để tránh khóa file đang mở).
29
+ New-Item -ItemType Directory -Path $CodeDir -Force | Out-Null
30
+ New-Item -ItemType Directory -Path (Join-Path $CodeDir "scripts") -Force | Out-Null
31
+
32
+ foreach ($f in $files) {
33
+ $src = Join-Path $Root $f.Src
34
+ $dst = Join-Path $CodeDir $f.Dst
35
+ if (-not (Test-Path $src)) {
36
+ Write-Warning "Missing: $src"
37
+ continue
38
+ }
39
+ Copy-Item $src $dst -Force
40
+ Write-Host " + gemma-srt\$($f.Dst)"
41
+ }
42
+
43
+ Write-Host ""
44
+ Write-Host "Staging ready: $CodeDir"
45
+ Write-Host ""
46
+ Write-Host "Buoc tiep theo:"
47
+ Write-Host " 1. Mo Google Drive -> thu muc Gemma"
48
+ Write-Host " 2. Keo tha CA folder 'gemma-srt' (trong $OutDir) vao Drive\Gemma\"
49
+ Write-Host " => Drive\Gemma\gemma-srt\translate_srt.py ..."
50
+ Write-Host " 3. Tren Colab: Runtime -> Restart session -> Run all"
51
+ Write-Host ""
52
+ Write-Host "Luu y: notebook GemmaSRT_Colab.ipynb upload rieng vao Drive\Gemma\ (Save a copy in Drive)."
huggingface/scripts/prepare_upload.ps1 ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gom file vào thư mục staging trước khi push lên Hugging Face.
2
+ # Usage: .\huggingface\scripts\prepare_upload.ps1 [-OutDir .\hf-upload]
3
+
4
+ param(
5
+ [string]$OutDir = ""
6
+ )
7
+
8
+ $ErrorActionPreference = "Stop"
9
+ $Root = Resolve-Path (Join-Path (Join-Path $PSScriptRoot "..") "..")
10
+ if (-not $OutDir) {
11
+ $OutDir = Join-Path $Root "hf-upload"
12
+ }
13
+ $OutDir = [System.IO.Path]::GetFullPath($OutDir)
14
+
15
+ $files = @(
16
+ @{ Src = "translate_srt.py"; Dst = "translate_srt.py" },
17
+ @{ Src = "diarize_audio.py"; Dst = "diarize_audio.py" },
18
+ @{ Src = "requirements-diarize.txt"; Dst = "requirements-diarize.txt" },
19
+ @{ Src = "colab\GemmaSRT_Colab.ipynb"; Dst = "colab\GemmaSRT_Colab.ipynb" },
20
+ @{ Src = "notebook.ipynb"; Dst = "notebook.ipynb" },
21
+ @{ Src = "huggingface\README.md"; Dst = "README.md" },
22
+ @{ Src = "huggingface\config.yaml"; Dst = "config.yaml" },
23
+ @{ Src = "huggingface\scripts\download_models.py"; Dst = "scripts\download_models.py" },
24
+ @{ Src = "huggingface\scripts\build_llama_server.sh"; Dst = "scripts\build_llama_server.sh" },
25
+ @{ Src = "huggingface\scripts\ensure_llama_colab.py"; Dst = "scripts\ensure_llama_colab.py" }
26
+ )
27
+
28
+ if (Test-Path $OutDir) {
29
+ Remove-Item -Recurse -Force $OutDir
30
+ }
31
+ New-Item -ItemType Directory -Path $OutDir | Out-Null
32
+ New-Item -ItemType Directory -Path (Join-Path $OutDir "scripts") -Force | Out-Null
33
+ New-Item -ItemType Directory -Path (Join-Path $OutDir "colab") -Force | Out-Null
34
+
35
+ foreach ($f in $files) {
36
+ $src = Join-Path $Root $f.Src
37
+ $dst = Join-Path $OutDir $f.Dst
38
+ if (-not (Test-Path $src)) {
39
+ Write-Warning "Missing: $src"
40
+ continue
41
+ }
42
+ Copy-Item $src $dst -Force
43
+ Write-Host " + $($f.Dst)"
44
+ }
45
+
46
+ # HF secret scanner rejects commits with embedded tokens — strip from staging notebook only.
47
+ $nbPath = Join-Path $OutDir "colab\GemmaSRT_Colab.ipynb"
48
+ if (Test-Path $nbPath) {
49
+ python -c @"
50
+ import json, sys
51
+ from pathlib import Path
52
+ p = Path(r'$nbPath')
53
+ nb = json.loads(p.read_text(encoding='utf-8'))
54
+ changed = False
55
+ for cell in nb.get('cells', []):
56
+ if cell.get('cell_type') != 'code':
57
+ continue
58
+ src = cell.get('source', [])
59
+ if isinstance(src, str):
60
+ src = [src]
61
+ new_src = []
62
+ for line in src:
63
+ if line.startswith('HF_TOKEN = \"hf_'):
64
+ new_src.append('HF_TOKEN = \"\"\n')
65
+ changed = True
66
+ else:
67
+ new_src.append(line)
68
+ cell['source'] = new_src
69
+ if changed:
70
+ p.write_text(json.dumps(nb, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
71
+ print(' ~ colab/GemmaSRT_Colab.ipynb (HF_TOKEN stripped for upload)')
72
+ "@
73
+ }
74
+
75
+ Write-Host ""
76
+ Write-Host "Staging ready: $OutDir"
77
+ Write-Host ""
78
+ Write-Host "Next steps:"
79
+ Write-Host " 1. Edit config.yaml -> set repo.id to YOUR_USERNAME/gemma-srt-translate"
80
+ Write-Host " 2. cd $OutDir"
81
+ Write-Host " 3. huggingface-cli login"
82
+ Write-Host " 4. huggingface-cli upload YOUR_USERNAME/gemma-srt-translate . --repo-type model"
83
+ Write-Host ""
84
+ Write-Host "See huggingface/UPLOAD.md for details."
huggingface/scripts/sanitize_nb_token.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strip HF_TOKEN from staging notebook before HF upload."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ p = Path(sys.argv[1])
7
+ nb = json.loads(p.read_text(encoding="utf-8"))
8
+ changed = False
9
+ for cell in nb.get("cells", []):
10
+ if cell.get("cell_type") != "code":
11
+ continue
12
+ src = cell.get("source", [])
13
+ if isinstance(src, str):
14
+ src = [src]
15
+ new_src = []
16
+ for line in src:
17
+ if line.startswith('HF_TOKEN = "hf_'):
18
+ new_src.append('HF_TOKEN = ""\n')
19
+ changed = True
20
+ else:
21
+ new_src.append(line)
22
+ cell["source"] = new_src
23
+ if changed:
24
+ p.write_text(json.dumps(nb, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
25
+ print(f"stripped token in {p}")
26
+ json.loads(p.read_text(encoding="utf-8"))
27
+ print("JSON OK")
launch_ui.ps1 ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Khoi chay Gemma SRT Translate Desktop App (khong hien console)
2
+ $ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
3
+ $pythonw = Join-Path $ProjectRoot ".venv\Scripts\pythonw.exe"
4
+ if (-not (Test-Path $pythonw)) {
5
+ $pythonw = (Get-Command pythonw -ErrorAction SilentlyContinue).Source
6
+ }
7
+ if (-not $pythonw) {
8
+ $pythonw = "pythonw"
9
+ }
10
+ & $pythonw (Join-Path $ProjectRoot "tools\gemma_srt_app.py")
run-gpu.ps1 ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chay Gemma 4 12B tren GPU RTX 4060 Ti
2
+ $llama = Join-Path $PSScriptRoot "tools\llama-cuda\llama-cli.exe"
3
+ $model = Join-Path $PSScriptRoot "models\gemma-4-12B-it-qat-UD-Q4_K_XL.gguf"
4
+
5
+ Set-Location (Split-Path $llama)
6
+
7
+ & $llama `
8
+ -m $model `
9
+ -cnv `
10
+ -ngl 999 `
11
+ -fa on `
12
+ -c 4096 `
13
+ --temp 1.0 `
14
+ --top-p 0.95 `
15
+ --top-k 64 `
16
+ --reasoning off
run-translate.ps1 ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dich + sua SRT bang Gemma 4 vision (llama-server thuong tru + MTP)
2
+ # Cach dung:
3
+ # .\run-translate.ps1 -Video "phim.mp4" -InputSrt "phim.srt" -OutputSrt "phim.vi.srt"
4
+ param(
5
+ [Parameter(Mandatory = $true)][string]$Video,
6
+ [Parameter(Mandatory = $true)][string]$InputSrt,
7
+ [Parameter(Mandatory = $true)][string]$OutputSrt,
8
+ [string]$SourceLang = "auto",
9
+ [string]$TargetLang = "Vietnamese",
10
+ [int]$Limit = 0,
11
+ [switch]$SkipCorrection,
12
+ [switch]$NoMtp
13
+ )
14
+
15
+ $ErrorActionPreference = "Stop"
16
+ $script = Join-Path $PSScriptRoot "translate_srt.py"
17
+
18
+ $args = @(
19
+ $script,
20
+ "--video", $Video,
21
+ "--input-srt", $InputSrt,
22
+ "--output-srt", $OutputSrt,
23
+ "--source-lang", $SourceLang,
24
+ "--target-lang", $TargetLang,
25
+ "--limit", $Limit
26
+ )
27
+ if ($SkipCorrection) { $args += "--skip-correction" }
28
+ if ($NoMtp) { $args += "--no-mtp" }
29
+
30
+ python @args
tools/__pycache__/gemma_srt_app.cpython-310.pyc ADDED
Binary file (21.3 kB). View file
 
tools/gemma_srt_app.py ADDED
@@ -0,0 +1,672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemma SRT Translate Desktop App — giao diện kiểu Omivoice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import queue
8
+ import subprocess
9
+ import sys
10
+ import traceback
11
+ import tkinter as tk
12
+ from pathlib import Path
13
+ from tkinter import filedialog, messagebox, ttk
14
+
15
+ TOOLS_DIR = Path(__file__).resolve().parent
16
+ ROOT = TOOLS_DIR.parent
17
+ sys.path.insert(0, str(ROOT))
18
+ sys.path.insert(0, str(TOOLS_DIR))
19
+ os.chdir(ROOT)
20
+
21
+ import gui_data.sv_ttk as sv_ttk # noqa: E402
22
+ from gui_data.app_size_values import ( # noqa: E402
23
+ CONSOLE_HEIGHT,
24
+ CONTENT_HEIGHT,
25
+ FONT_SIZE_4,
26
+ IMAGE_HEIGHT,
27
+ ImagePath,
28
+ PADDING,
29
+ WIDTH,
30
+ )
31
+ from gui_data.constants import ( # noqa: E402
32
+ APP_TITLE,
33
+ BG_COLOR,
34
+ BTN_BROWSE,
35
+ BTN_CLOSE,
36
+ BTN_OPEN_FOLDER,
37
+ BTN_STOP_SERVER,
38
+ CHECK_DIARIZE,
39
+ CHECK_NO_GENDER,
40
+ CHECK_NO_MTP,
41
+ CHECK_NO_SCENE,
42
+ CHECK_SHORTEN,
43
+ CHECK_SKIP_CORRECTION,
44
+ CONSOLE_BG,
45
+ CONSOLE_FG,
46
+ DONE_SRT,
47
+ ERR_NO_OUTPUT,
48
+ ERR_NO_SRT,
49
+ ERR_NO_VIDEO,
50
+ ERROR_LOG_LABEL,
51
+ FG_COLOR,
52
+ LABEL_CHARS_PER_SEC,
53
+ LABEL_CTX,
54
+ LABEL_DIARIZE_DEVICE,
55
+ LABEL_DRAFT,
56
+ LABEL_GENDER_METHOD,
57
+ LABEL_HF_TOKEN,
58
+ LABEL_LLAMA_SERVER,
59
+ LABEL_LIMIT,
60
+ LABEL_NUM_SPEAKERS,
61
+ LABEL_MAX_LINE,
62
+ LABEL_MMPROJ,
63
+ LABEL_MODEL,
64
+ LABEL_NGL,
65
+ LABEL_OUTPUT,
66
+ LABEL_SCENE_CUES,
67
+ LABEL_SCENE_DUR,
68
+ LABEL_SCENE_GAP,
69
+ LABEL_SERVER_URL,
70
+ LABEL_SOURCE_LANG,
71
+ LABEL_SRT,
72
+ LABEL_TARGET_LANG,
73
+ LABEL_TEMP,
74
+ LABEL_VIDEO,
75
+ LOG_START,
76
+ MAIN_FONT_NAME,
77
+ MSG_BUSY,
78
+ MSG_DONE,
79
+ MSG_ERROR,
80
+ MSG_INVALID_PATH,
81
+ READY_MSG,
82
+ SETTINGS_TITLE,
83
+ START_PROCESSING,
84
+ STOP_PROCESSING,
85
+ STOP_REQUESTED,
86
+ STOP_SERVER_MSG,
87
+ TAB_ADVANCED,
88
+ TAB_TRANSLATE,
89
+ )
90
+ from diarize_audio import DEFAULT_HF_TOKEN # noqa: E402
91
+ from gui_data.make_assets import main as ensure_assets # noqa: E402
92
+ from translate_srt import ( # noqa: E402
93
+ DEFAULT_DRAFT,
94
+ DEFAULT_LLAMA_SERVER,
95
+ DEFAULT_MMPROJ,
96
+ DEFAULT_MODEL,
97
+ run_pipeline,
98
+ )
99
+
100
+ ICON_IMG_PATH = TOOLS_DIR / "gui_data" / "img" / "gemma-srt-icon.ico"
101
+ ICON_PNG_PATH = TOOLS_DIR / "gui_data" / "img" / "gemma-srt-icon.png"
102
+ APP_USER_MODEL_ID = "GemmaSRT.Translate.DesktopApp"
103
+ PAD = {"padx": 10, "pady": 4}
104
+
105
+
106
+ def _set_app_user_model_id() -> None:
107
+ if sys.platform != "win32":
108
+ return
109
+ try:
110
+ import ctypes
111
+
112
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID)
113
+ except Exception: # noqa: BLE001
114
+ pass
115
+
116
+
117
+ def _gpu_label() -> str:
118
+ try:
119
+ out = subprocess.check_output(
120
+ ["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
121
+ text=True,
122
+ timeout=5,
123
+ )
124
+ return out.strip().splitlines()[0]
125
+ except (OSError, subprocess.SubprocessError, IndexError):
126
+ return "GPU/CPU (chưa phát hiện NVIDIA)"
127
+
128
+
129
+ class ThreadSafeConsole(tk.Text):
130
+ def __init__(self, master, **options):
131
+ super().__init__(master, **options)
132
+ self._q: queue.Queue[str | None] = queue.Queue()
133
+ self._poll()
134
+
135
+ def write(self, line: str) -> None:
136
+ self._q.put(line if line.endswith("\n") else line + "\n")
137
+
138
+ def clear(self) -> None:
139
+ self._q.put(None)
140
+
141
+ def _poll(self) -> None:
142
+ self.configure(state=tk.NORMAL)
143
+ try:
144
+ while True:
145
+ line = self._q.get_nowait()
146
+ if line is None:
147
+ self.delete("1.0", tk.END)
148
+ else:
149
+ self.insert(tk.END, str(line))
150
+ self.see(tk.END)
151
+ except queue.Empty:
152
+ pass
153
+ self.configure(state=tk.DISABLED)
154
+ self.after(100, self._poll)
155
+
156
+
157
+ class GemmaSrtApp(tk.Tk):
158
+ def __init__(self) -> None:
159
+ _set_app_user_model_id()
160
+ super().__init__()
161
+ ensure_assets()
162
+
163
+ self._job_thread = None
164
+ self._stop_requested = False
165
+ self._output_manual = False
166
+
167
+ height = IMAGE_HEIGHT + CONTENT_HEIGHT + CONSOLE_HEIGHT + 100
168
+ self.withdraw()
169
+ self.title(APP_TITLE)
170
+ self.geometry(
171
+ f"{WIDTH}x{height}+{int(self.winfo_screenwidth() / 2 - WIDTH / 2)}+"
172
+ f"{int(self.winfo_screenheight() / 2 - height / 2 - 20)}"
173
+ )
174
+ self._apply_window_icon()
175
+ self.minsize(680, 660)
176
+ self.configure(bg=BG_COLOR)
177
+
178
+ style = ttk.Style(self)
179
+ style.map(
180
+ "TCombobox",
181
+ selectbackground=[("focus", "#0c0c0c")],
182
+ selectforeground=[("focus", "white")],
183
+ )
184
+ style.configure("TCombobox", selectbackground="#0c0c0c")
185
+ sv_ttk.set_theme("dark", MAIN_FONT_NAME, 10, fg_color_set=FG_COLOR)
186
+
187
+ img = ImagePath(str(TOOLS_DIR))
188
+ self.logo_img = img.open_image(
189
+ path=img.banner_path, size=(WIDTH, IMAGE_HEIGHT), keep_aspect=False
190
+ )
191
+ self.stop_img = img.stop_img
192
+ self.help_img = img.help_img
193
+
194
+ self.video_var = tk.StringVar()
195
+ self.srt_var = tk.StringVar()
196
+ self.output_var = tk.StringVar()
197
+ self.source_lang_var = tk.StringVar(value="auto")
198
+ self.target_lang_var = tk.StringVar(value="Vietnamese")
199
+ self.limit_var = tk.StringVar(value="0")
200
+ self.chars_per_sec_var = tk.StringVar(value="22")
201
+ self.max_line_chars_var = tk.StringVar(value="52")
202
+
203
+ self.skip_correction_var = tk.BooleanVar(value=False)
204
+ self.no_scene_var = tk.BooleanVar(value=False)
205
+ self.shorten_var = tk.BooleanVar(value=False)
206
+ self.no_mtp_var = tk.BooleanVar(value=False)
207
+ self.diarize_var = tk.BooleanVar(value=False)
208
+ self.no_gender_var = tk.BooleanVar(value=False)
209
+
210
+ self.hf_token_var = tk.StringVar(
211
+ value=os.environ.get("HF_TOKEN", "") or DEFAULT_HF_TOKEN
212
+ )
213
+ self.num_speakers_var = tk.StringVar(value="0")
214
+ self.diarize_device_var = tk.StringVar(value="auto")
215
+ self.gender_method_var = tk.StringVar(value="auto")
216
+
217
+ self.llama_server_var = tk.StringVar(value=str(DEFAULT_LLAMA_SERVER))
218
+ self.model_var = tk.StringVar(value=str(DEFAULT_MODEL))
219
+ self.mmproj_var = tk.StringVar(value=str(DEFAULT_MMPROJ))
220
+ self.draft_var = tk.StringVar(value=str(DEFAULT_DRAFT))
221
+ self.server_url_var = tk.StringVar(value="")
222
+ self.scene_gap_var = tk.StringVar(value="1.5")
223
+ self.scene_cues_var = tk.StringVar(value="4")
224
+ self.scene_dur_var = tk.StringVar(value="20")
225
+ self.temp_var = tk.StringVar(value="0.7")
226
+ self.ngl_var = tk.StringVar(value="999")
227
+ self.ctx_var = tk.StringVar(value="8192")
228
+
229
+ self.action_text_var = tk.StringVar(value=START_PROCESSING)
230
+ self.progress_var = tk.IntVar(value=0)
231
+ self.error_log_var = tk.StringVar(value="")
232
+
233
+ self._build_ui()
234
+ self.deiconify()
235
+ self.command_text.write(READY_MSG.format(gpu=_gpu_label()))
236
+
237
+ def _apply_window_icon(self) -> None:
238
+ if ICON_IMG_PATH.is_file():
239
+ try:
240
+ self.iconbitmap(default=str(ICON_IMG_PATH))
241
+ except tk.TclError:
242
+ pass
243
+ if ICON_PNG_PATH.is_file():
244
+ try:
245
+ self._icon_photo = tk.PhotoImage(file=str(ICON_PNG_PATH))
246
+ self.iconphoto(True, self._icon_photo)
247
+ except tk.TclError:
248
+ pass
249
+
250
+ def _path_row(
251
+ self,
252
+ parent: tk.Widget,
253
+ label: str,
254
+ var: tk.StringVar,
255
+ *,
256
+ browse_cmd,
257
+ open_cmd=None,
258
+ filetypes=None,
259
+ ) -> None:
260
+ row = ttk.Frame(parent)
261
+ row.pack(fill=tk.X, **PAD)
262
+ ttk.Label(row, text=label, width=16).pack(side=tk.LEFT)
263
+ ttk.Entry(row, textvariable=var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4))
264
+ ttk.Button(row, text=BTN_BROWSE, width=4, command=browse_cmd).pack(side=tk.LEFT)
265
+ if open_cmd:
266
+ ttk.Button(row, text=BTN_OPEN_FOLDER, width=5, command=open_cmd).pack(
267
+ side=tk.LEFT, padx=(4, 0)
268
+ )
269
+
270
+ def _labeled_entry(self, parent: tk.Widget, label: str, var: tk.StringVar, width: int = 12) -> None:
271
+ row = ttk.Frame(parent)
272
+ row.pack(fill=tk.X, **PAD)
273
+ ttk.Label(row, text=label, width=16).pack(side=tk.LEFT)
274
+ ttk.Entry(row, textvariable=var, width=width).pack(side=tk.LEFT, fill=tk.X, expand=True)
275
+
276
+ def _build_ui(self) -> None:
277
+ tk.Label(self, image=self.logo_img, bg=BG_COLOR).pack(fill=tk.X)
278
+
279
+ body = ttk.Frame(self)
280
+ body.pack(fill=tk.BOTH, expand=True, padx=PADDING, pady=(0, PADDING))
281
+
282
+ notebook = ttk.Notebook(body, height=CONTENT_HEIGHT)
283
+ notebook.pack(fill=tk.BOTH, expand=True)
284
+
285
+ tab_main = ttk.Frame(notebook)
286
+ tab_adv = ttk.Frame(notebook)
287
+ notebook.add(tab_main, text=f" {TAB_TRANSLATE} ")
288
+ notebook.add(tab_adv, text=f" {TAB_ADVANCED} ")
289
+
290
+ self._path_row(
291
+ tab_main,
292
+ LABEL_VIDEO,
293
+ self.video_var,
294
+ browse_cmd=self._browse_video,
295
+ open_cmd=self._open_video_dir,
296
+ )
297
+ self._path_row(
298
+ tab_main,
299
+ LABEL_SRT,
300
+ self.srt_var,
301
+ browse_cmd=self._browse_srt,
302
+ open_cmd=self._open_srt_dir,
303
+ )
304
+ self._path_row(
305
+ tab_main,
306
+ LABEL_OUTPUT,
307
+ self.output_var,
308
+ browse_cmd=self._browse_output,
309
+ open_cmd=self._open_output_dir,
310
+ )
311
+
312
+ row = ttk.Frame(tab_main)
313
+ row.pack(fill=tk.X, **PAD)
314
+ ttk.Label(row, text=LABEL_SOURCE_LANG, width=16).pack(side=tk.LEFT)
315
+ ttk.Entry(row, textvariable=self.source_lang_var, width=12).pack(
316
+ side=tk.LEFT, padx=(0, 16)
317
+ )
318
+ ttk.Label(row, text=LABEL_TARGET_LANG).pack(side=tk.LEFT)
319
+ ttk.Entry(row, textvariable=self.target_lang_var, width=12).pack(side=tk.LEFT, padx=(4, 0))
320
+
321
+ row2 = ttk.Frame(tab_main)
322
+ row2.pack(fill=tk.X, **PAD)
323
+ ttk.Label(row2, text=LABEL_LIMIT, width=16).pack(side=tk.LEFT)
324
+ ttk.Entry(row2, textvariable=self.limit_var, width=8).pack(side=tk.LEFT, padx=(0, 16))
325
+ ttk.Label(row2, text=LABEL_CHARS_PER_SEC).pack(side=tk.LEFT)
326
+ ttk.Entry(row2, textvariable=self.chars_per_sec_var, width=6).pack(side=tk.LEFT, padx=(4, 16))
327
+ ttk.Label(row2, text=LABEL_MAX_LINE).pack(side=tk.LEFT)
328
+ ttk.Entry(row2, textvariable=self.max_line_chars_var, width=6).pack(side=tk.LEFT, padx=(4, 0))
329
+
330
+ opts = ttk.Frame(tab_main)
331
+ opts.pack(fill=tk.X, **PAD)
332
+ ttk.Checkbutton(opts, text=CHECK_SKIP_CORRECTION, variable=self.skip_correction_var).pack(
333
+ anchor=tk.W
334
+ )
335
+ ttk.Checkbutton(opts, text=CHECK_NO_SCENE, variable=self.no_scene_var).pack(anchor=tk.W)
336
+ ttk.Checkbutton(opts, text=CHECK_SHORTEN, variable=self.shorten_var).pack(anchor=tk.W)
337
+ ttk.Checkbutton(opts, text=CHECK_NO_MTP, variable=self.no_mtp_var).pack(anchor=tk.W)
338
+ ttk.Checkbutton(opts, text=CHECK_DIARIZE, variable=self.diarize_var).pack(anchor=tk.W)
339
+ ttk.Checkbutton(opts, text=CHECK_NO_GENDER, variable=self.no_gender_var).pack(anchor=tk.W)
340
+
341
+ self._path_row(
342
+ tab_adv,
343
+ LABEL_LLAMA_SERVER,
344
+ self.llama_server_var,
345
+ browse_cmd=lambda: self._browse_file(self.llama_server_var, [("EXE", "*.exe"), ("All", "*.*")]),
346
+ )
347
+ self._path_row(
348
+ tab_adv,
349
+ LABEL_MODEL,
350
+ self.model_var,
351
+ browse_cmd=lambda: self._browse_file(self.model_var, [("GGUF", "*.gguf"), ("All", "*.*")]),
352
+ )
353
+ self._path_row(
354
+ tab_adv,
355
+ LABEL_MMPROJ,
356
+ self.mmproj_var,
357
+ browse_cmd=lambda: self._browse_file(self.mmproj_var, [("GGUF", "*.gguf"), ("All", "*.*")]),
358
+ )
359
+ self._path_row(
360
+ tab_adv,
361
+ LABEL_DRAFT,
362
+ self.draft_var,
363
+ browse_cmd=lambda: self._browse_file(self.draft_var, [("GGUF", "*.gguf"), ("All", "*.*")]),
364
+ )
365
+ self._labeled_entry(tab_adv, LABEL_SERVER_URL, self.server_url_var)
366
+ self._labeled_entry(tab_adv, LABEL_HF_TOKEN, self.hf_token_var)
367
+
368
+ row_diar = ttk.Frame(tab_adv)
369
+ row_diar.pack(fill=tk.X, **PAD)
370
+ ttk.Label(row_diar, text=LABEL_NUM_SPEAKERS, width=16).pack(side=tk.LEFT)
371
+ ttk.Entry(row_diar, textvariable=self.num_speakers_var, width=6).pack(
372
+ side=tk.LEFT, padx=(0, 12)
373
+ )
374
+ ttk.Label(row_diar, text=LABEL_DIARIZE_DEVICE).pack(side=tk.LEFT)
375
+ ttk.Combobox(
376
+ row_diar,
377
+ textvariable=self.diarize_device_var,
378
+ values=["auto", "cuda", "cpu"],
379
+ width=6,
380
+ state="readonly",
381
+ ).pack(side=tk.LEFT, padx=(4, 0))
382
+
383
+ row_gm = ttk.Frame(tab_adv)
384
+ row_gm.pack(fill=tk.X, **PAD)
385
+ ttk.Label(row_gm, text=LABEL_GENDER_METHOD, width=16).pack(side=tk.LEFT)
386
+ ttk.Combobox(
387
+ row_gm,
388
+ textvariable=self.gender_method_var,
389
+ values=["auto", "model", "pitch"],
390
+ width=8,
391
+ state="readonly",
392
+ ).pack(side=tk.LEFT, padx=(0, 0))
393
+
394
+ row3 = ttk.Frame(tab_adv)
395
+ row3.pack(fill=tk.X, **PAD)
396
+ ttk.Label(row3, text=LABEL_SCENE_GAP, width=16).pack(side=tk.LEFT)
397
+ ttk.Entry(row3, textvariable=self.scene_gap_var, width=6).pack(side=tk.LEFT, padx=(0, 12))
398
+ ttk.Label(row3, text=LABEL_SCENE_CUES).pack(side=tk.LEFT)
399
+ ttk.Entry(row3, textvariable=self.scene_cues_var, width=6).pack(side=tk.LEFT, padx=(4, 12))
400
+ ttk.Label(row3, text=LABEL_SCENE_DUR).pack(side=tk.LEFT)
401
+ ttk.Entry(row3, textvariable=self.scene_dur_var, width=6).pack(side=tk.LEFT, padx=(4, 0))
402
+
403
+ row4 = ttk.Frame(tab_adv)
404
+ row4.pack(fill=tk.X, **PAD)
405
+ ttk.Label(row4, text=LABEL_TEMP, width=16).pack(side=tk.LEFT)
406
+ ttk.Entry(row4, textvariable=self.temp_var, width=6).pack(side=tk.LEFT, padx=(0, 12))
407
+ ttk.Label(row4, text=LABEL_NGL).pack(side=tk.LEFT)
408
+ ttk.Entry(row4, textvariable=self.ngl_var, width=6).pack(side=tk.LEFT, padx=(4, 12))
409
+ ttk.Label(row4, text=LABEL_CTX).pack(side=tk.LEFT)
410
+ ttk.Entry(row4, textvariable=self.ctx_var, width=8).pack(side=tk.LEFT, padx=(4, 0))
411
+
412
+ foot = ttk.Frame(self)
413
+ foot.pack(fill=tk.X, padx=PADDING, pady=(0, 4))
414
+
415
+ self.action_btn = ttk.Button(
416
+ foot, textvariable=self.action_text_var, command=self.start_processing
417
+ )
418
+ self.action_btn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6))
419
+ ttk.Button(foot, image=self.stop_img, width=3, command=self.request_stop).pack(
420
+ side=tk.LEFT, padx=2
421
+ )
422
+ ttk.Button(foot, image=self.help_img, width=3, command=self.open_error_log).pack(
423
+ side=tk.LEFT, padx=2
424
+ )
425
+ ttk.Button(foot, text=BTN_STOP_SERVER, command=self._note_stop_server).pack(side=tk.RIGHT)
426
+
427
+ console_wrap = tk.Frame(
428
+ self, highlightbackground="#101012", highlightthickness=2, bg=BG_COLOR
429
+ )
430
+ console_wrap.pack(fill=tk.BOTH, expand=False, padx=PADDING, pady=(0, 4))
431
+ self.command_text = ThreadSafeConsole(
432
+ console_wrap,
433
+ height=6,
434
+ background=CONSOLE_BG,
435
+ fg=CONSOLE_FG,
436
+ font=(MAIN_FONT_NAME, FONT_SIZE_4),
437
+ borderwidth=0,
438
+ )
439
+ self.command_text.pack(fill=tk.BOTH, expand=True)
440
+
441
+ self.progressbar = ttk.Progressbar(self, variable=self.progress_var, maximum=100)
442
+ self.progressbar.pack(fill=tk.X, padx=PADDING, pady=(0, PADDING))
443
+
444
+ # ── File dialogs ────────────────────────────────────────────
445
+
446
+ def _suggest_output_path(self) -> str | None:
447
+ """Gợi ý file SRT ra cùng thư mục với video (ưu tiên) hoặc SRT nguồn."""
448
+ video = self.video_var.get().strip()
449
+ srt = self.srt_var.get().strip()
450
+ if video:
451
+ p = Path(video)
452
+ return str(p.with_name(p.stem + ".vi.srt"))
453
+ if srt:
454
+ p = Path(srt)
455
+ stem = p.stem
456
+ if stem.endswith(".vi") or stem.endswith("_vi"):
457
+ name = f"{stem}.srt"
458
+ else:
459
+ name = f"{stem}.vi.srt"
460
+ return str(p.with_name(name))
461
+ return None
462
+
463
+ def _sync_output_path(self) -> None:
464
+ if self._output_manual:
465
+ return
466
+ suggested = self._suggest_output_path()
467
+ if suggested:
468
+ self.output_var.set(suggested)
469
+
470
+ def _browse_file(self, var: tk.StringVar, filetypes) -> None:
471
+ path = filedialog.askopenfilename(filetypes=filetypes)
472
+ if path:
473
+ var.set(path)
474
+
475
+ def _browse_video(self) -> None:
476
+ path = filedialog.askopenfilename(
477
+ filetypes=[("Video", "*.mp4 *.mkv *.avi *.mov"), ("Tất cả", "*.*")]
478
+ )
479
+ if path:
480
+ self.video_var.set(path)
481
+ if not self.srt_var.get().strip():
482
+ srt_guess = Path(path).with_suffix(".srt")
483
+ if srt_guess.is_file():
484
+ self.srt_var.set(str(srt_guess))
485
+ self._sync_output_path()
486
+
487
+ def _browse_srt(self) -> None:
488
+ path = filedialog.askopenfilename(
489
+ filetypes=[("SRT", "*.srt *.txt"), ("Tất cả", "*.*")]
490
+ )
491
+ if path:
492
+ self.srt_var.set(path)
493
+ self._sync_output_path()
494
+
495
+ def _browse_output(self) -> None:
496
+ path = filedialog.asksaveasfilename(
497
+ defaultextension=".srt",
498
+ filetypes=[("SRT", "*.srt"), ("Tất cả", "*.*")],
499
+ )
500
+ if path:
501
+ self._output_manual = True
502
+ self.output_var.set(path)
503
+
504
+ def _open_path(self, path: str) -> None:
505
+ if path and os.path.isdir(path):
506
+ subprocess.Popen(["explorer", path])
507
+ elif path and os.path.isfile(path):
508
+ subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
509
+ else:
510
+ messagebox.showwarning(MSG_ERROR, MSG_INVALID_PATH)
511
+
512
+ def _open_video_dir(self) -> None:
513
+ p = self.video_var.get().strip()
514
+ self._open_path(str(Path(p).parent) if p else "")
515
+
516
+ def _open_srt_dir(self) -> None:
517
+ p = self.srt_var.get().strip()
518
+ self._open_path(str(Path(p).parent) if p else "")
519
+
520
+ def _open_output_dir(self) -> None:
521
+ p = self.output_var.get().strip()
522
+ self._open_path(str(Path(p).parent) if p else "")
523
+
524
+ def open_error_log(self) -> None:
525
+ win = tk.Toplevel(self)
526
+ win.title(SETTINGS_TITLE)
527
+ win.configure(bg=BG_COLOR)
528
+ win.geometry("520x360")
529
+ win.resizable(False, False)
530
+ ttk.Label(win, text=ERROR_LOG_LABEL, background=BG_COLOR, foreground=FG_COLOR).pack(
531
+ anchor="w", padx=10, pady=(10, 4)
532
+ )
533
+ log = tk.Text(win, bg=CONSOLE_BG, fg=CONSOLE_FG, height=12, relief=tk.FLAT)
534
+ log.pack(fill=tk.BOTH, expand=True, padx=10, pady=4)
535
+ log.insert("1.0", self.error_log_var.get())
536
+ ttk.Button(win, text=BTN_CLOSE, command=win.destroy).pack(pady=8)
537
+
538
+ def _note_stop_server(self) -> None:
539
+ self.command_text.write(STOP_SERVER_MSG)
540
+
541
+ # ── Processing ──────────────────────────────────────────────
542
+
543
+ def request_stop(self) -> None:
544
+ self._stop_requested = True
545
+ self.command_text.write(STOP_REQUESTED)
546
+
547
+ def set_busy(self, busy: bool) -> None:
548
+ if busy:
549
+ self.action_text_var.set(STOP_PROCESSING)
550
+ self.action_btn.configure(state=tk.DISABLED)
551
+ self.progress_var.set(0)
552
+ self._animate_progress()
553
+ else:
554
+ self.action_text_var.set(START_PROCESSING)
555
+ self.action_btn.configure(state=tk.NORMAL)
556
+ self.progress_var.set(100)
557
+
558
+ def _animate_progress(self) -> None:
559
+ if self.action_text_var.get() == STOP_PROCESSING:
560
+ self.progress_var.set((self.progress_var.get() + 2) % 100)
561
+ self.after(250, self._animate_progress)
562
+
563
+ def _build_args(self) -> argparse.Namespace:
564
+ return argparse.Namespace(
565
+ video=self.video_var.get().strip(),
566
+ input_srt=self.srt_var.get().strip(),
567
+ output_srt=self.output_var.get().strip(),
568
+ source_lang=self.source_lang_var.get().strip() or "auto",
569
+ target_lang=self.target_lang_var.get().strip() or "Vietnamese",
570
+ limit=int(self.limit_var.get().strip() or "0"),
571
+ chars_per_sec=float(self.chars_per_sec_var.get().strip() or "22"),
572
+ max_line_chars=int(self.max_line_chars_var.get().strip() or "52"),
573
+ max_lines=2,
574
+ skip_correction=self.skip_correction_var.get(),
575
+ no_scene_image=self.no_scene_var.get(),
576
+ scene_frames=3,
577
+ no_scene_vision=False,
578
+ shorten=self.shorten_var.get(),
579
+ shorten_slack=8,
580
+ merge_pairs=True,
581
+ merge_max_gap=0.12,
582
+ merge_max_duration=10.0,
583
+ merge_max_chars=32,
584
+ merge_fragment_chars=12,
585
+ no_mtp=self.no_mtp_var.get(),
586
+ diarize=self.diarize_var.get(),
587
+ no_gender=self.no_gender_var.get(),
588
+ gender_method=self.gender_method_var.get().strip() or "auto",
589
+ hf_token=self.hf_token_var.get().strip(),
590
+ num_speakers=int(self.num_speakers_var.get().strip() or "0"),
591
+ min_speakers=0,
592
+ max_speakers=0,
593
+ diarize_device=self.diarize_device_var.get().strip() or "auto",
594
+ llama_server=self.llama_server_var.get().strip(),
595
+ model=self.model_var.get().strip(),
596
+ mmproj=self.mmproj_var.get().strip(),
597
+ model_draft=self.draft_var.get().strip(),
598
+ server_url=self.server_url_var.get().strip() or None,
599
+ scene_max_gap=float(self.scene_gap_var.get().strip() or "1.5"),
600
+ scene_max_cues=int(self.scene_cues_var.get().strip() or "4"),
601
+ scene_max_dur=float(self.scene_dur_var.get().strip() or "20"),
602
+ temp=float(self.temp_var.get().strip() or "0.7"),
603
+ ngl=int(self.ngl_var.get().strip() or "999"),
604
+ ctx=int(self.ctx_var.get().strip() or "8192"),
605
+ port=None,
606
+ report="",
607
+ )
608
+
609
+ def start_processing(self) -> None:
610
+ if self._job_thread and self._job_thread.is_alive():
611
+ messagebox.showwarning(APP_TITLE, MSG_BUSY)
612
+ return
613
+
614
+ video = self.video_var.get().strip()
615
+ srt = self.srt_var.get().strip()
616
+ output = self.output_var.get().strip()
617
+ if not video:
618
+ messagebox.showwarning(MSG_ERROR, ERR_NO_VIDEO)
619
+ return
620
+ if not srt:
621
+ messagebox.showwarning(MSG_ERROR, ERR_NO_SRT)
622
+ return
623
+ if not output:
624
+ messagebox.showwarning(MSG_ERROR, ERR_NO_OUTPUT)
625
+ return
626
+
627
+ import threading
628
+
629
+ def wrapper() -> None:
630
+ self._stop_requested = False
631
+ self.after(0, lambda: self.set_busy(True))
632
+ try:
633
+ args = self._build_args()
634
+ args.log_fn = lambda msg: self.after(0, lambda m=msg: self.command_text.write(m))
635
+ args.stop_check = lambda: self._stop_requested
636
+
637
+ def progress(current: int, total: int, _phase: str) -> None:
638
+ pct = int(current / max(total, 1) * 100)
639
+ self.after(0, lambda p=pct: self.progress_var.set(p))
640
+
641
+ args.progress_fn = progress
642
+
643
+ self.after(
644
+ 0,
645
+ lambda: self.command_text.write(
646
+ LOG_START.format(video=Path(video).name)
647
+ ),
648
+ )
649
+ run_pipeline(args)
650
+ self.after(
651
+ 0,
652
+ lambda: messagebox.showinfo(MSG_DONE, DONE_SRT.format(path=output)),
653
+ )
654
+ except Exception as exc:
655
+ tb = traceback.format_exc()
656
+ self.error_log_var.set(tb)
657
+ self.after(0, lambda: self.command_text.write(tb))
658
+ self.after(0, lambda: messagebox.showerror(MSG_ERROR, str(exc)))
659
+ finally:
660
+ self.after(0, lambda: self.set_busy(False))
661
+
662
+ self._job_thread = threading.Thread(target=wrapper, daemon=True)
663
+ self._job_thread.start()
664
+
665
+
666
+ def main() -> None:
667
+ app = GemmaSrtApp()
668
+ app.mainloop()
669
+
670
+
671
+ if __name__ == "__main__":
672
+ main()
tools/gui_data/__init__.py ADDED
File without changes
tools/gui_data/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (136 Bytes). View file
 
tools/gui_data/__pycache__/app_size_values.cpython-310.pyc ADDED
Binary file (1.79 kB). View file
 
tools/gui_data/__pycache__/constants.cpython-310.pyc ADDED
Binary file (2.74 kB). View file
 
tools/gui_data/__pycache__/make_assets.cpython-310.pyc ADDED
Binary file (6.44 kB). View file
 
tools/gui_data/app_size_values.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kích thước layout Gemma SRT app."""
2
+
3
+ import os
4
+ import platform
5
+
6
+ from PIL import Image, ImageTk
7
+
8
+ OPERATING_SYSTEM = platform.system()
9
+
10
+ WIDTH = 680
11
+ IMAGE_HEIGHT = 140
12
+ CONTENT_HEIGHT = 360
13
+ CONSOLE_HEIGHT = 120
14
+ PADDING = 8
15
+
16
+ FONT_SIZE_4 = 11
17
+
18
+ if OPERATING_SYSTEM == "Darwin":
19
+ FONT_SIZE_4 = 14
20
+
21
+ image_scale_1 = 20
22
+
23
+
24
+ class ImagePath:
25
+ def __init__(self, base_path: str):
26
+ img_path = os.path.join(base_path, "gui_data", "img")
27
+ self.banner_path = os.path.join(img_path, "gemma-srt-banner.png")
28
+
29
+ def load(name: str, size: tuple[int, int] | None = None):
30
+ path = os.path.join(img_path, name)
31
+ return self.open_image(path, size=size)
32
+
33
+ self.stop_img = load("stop.png", (image_scale_1, image_scale_1))
34
+ self.help_img = load("help.png", (image_scale_1, image_scale_1))
35
+
36
+ def open_image(
37
+ self,
38
+ path: str,
39
+ size: tuple[int, int] | None = None,
40
+ keep_aspect: bool = True,
41
+ ) -> ImageTk.PhotoImage:
42
+ img = Image.open(path).convert(mode="RGBA")
43
+ ratio = img.height / img.width
44
+ if size is not None:
45
+ w, h = int(size[0]), int(size[1])
46
+ if keep_aspect:
47
+ img = img.resize((w, int(w * ratio)), Image.Resampling.LANCZOS)
48
+ else:
49
+ img = img.resize((w, h), Image.Resampling.LANCZOS)
50
+ return ImageTk.PhotoImage(img)
tools/gui_data/constants.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hằng số giao diện Gemma SRT Translate (tiếng Việt)."""
2
+
3
+ BG_COLOR = "#0e0e0f"
4
+ FG_COLOR = "#13849f"
5
+ CONSOLE_BG = "#0c0c0d"
6
+ CONSOLE_FG = "#898b8e"
7
+
8
+ MAIN_FONT_NAME = "Segoe UI"
9
+
10
+ APP_TITLE = "Gemma SRT Translate"
11
+
12
+ START_PROCESSING = "Bắt đầu dịch"
13
+ STOP_PROCESSING = "Đang dịch..."
14
+
15
+ TAB_TRANSLATE = "Dịch SRT"
16
+ TAB_ADVANCED = "Nâng cao"
17
+
18
+ LABEL_VIDEO = "File video"
19
+ LABEL_SRT = "File SRT"
20
+ LABEL_OUTPUT = "File SRT ra"
21
+ LABEL_SOURCE_LANG = "Ngôn ngữ nguồn"
22
+ LABEL_TARGET_LANG = "Ngôn ngữ đích"
23
+ LABEL_LIMIT = "Giới hạn cue (0=tất cả)"
24
+ LABEL_CHARS_PER_SEC = "Ký tự/giây"
25
+ LABEL_MAX_LINE = "Ký tự/dòng tối đa"
26
+
27
+ CHECK_SKIP_CORRECTION = "Bỏ qua OCR/sửa SRT (nhanh hơn)"
28
+ CHECK_NO_SCENE = "Không dùng ảnh cảnh khi dịch"
29
+ CHECK_SHORTEN = "Rút gọn cue vượt budget nhiều (Pass 2b)"
30
+ CHECK_NO_MTP = "Tắt MTP (speculative decoding)"
31
+ CHECK_DIARIZE = "Phân tích giọng nói nhân vật (pyannote)"
32
+ CHECK_NO_GENDER = "Bỏ đoán giới tính (chỉ tách người nói)"
33
+
34
+ LABEL_HF_TOKEN = "HF token (pyannote)"
35
+ LABEL_NUM_SPEAKERS = "Số nhân vật (0=tự động)"
36
+ LABEL_DIARIZE_DEVICE = "Thiết bị diarization"
37
+ LABEL_GENDER_METHOD = "Phân tích tuổi/giới"
38
+
39
+ BTN_BROWSE = "..."
40
+ BTN_OPEN_FOLDER = "Mở"
41
+ BTN_STOP_SERVER = "Dừng server"
42
+ BTN_CLOSE = "Đóng"
43
+
44
+ SETTINGS_TITLE = "Nhật ký lỗi"
45
+ ERROR_LOG_LABEL = "Nhật ký lỗi"
46
+
47
+ READY_MSG = "Gemma SRT Translate sẵn sàng — {gpu}"
48
+ STOP_REQUESTED = "Đã yêu cầu dừng (áp dụng sau scene hiện tại)."
49
+ STOP_SERVER_MSG = "Server llama sẽ dừng khi job kết thúc."
50
+
51
+ MSG_BUSY = "Đang xử lý, vui lòng đợi."
52
+ MSG_ERROR = "Lỗi"
53
+ MSG_DONE = "Hoàn tất"
54
+ MSG_INVALID_PATH = "Không tìm thấy đường dẫn."
55
+
56
+ ERR_NO_VIDEO = "Chọn file video."
57
+ ERR_NO_SRT = "Chọn file SRT."
58
+ ERR_NO_OUTPUT = "Chọn file SRT đầu ra."
59
+
60
+ LOG_START = "=== Dịch SRT | {video} ==="
61
+ DONE_SRT = "Dịch SRT hoàn tất.\n{path}"
62
+
63
+ LABEL_LLAMA_SERVER = "llama-server"
64
+ LABEL_MODEL = "Model GGUF"
65
+ LABEL_MMPROJ = "mmproj"
66
+ LABEL_DRAFT = "Model draft (MTP)"
67
+ LABEL_SERVER_URL = "Server URL (tùy chọn)"
68
+ LABEL_SCENE_GAP = "Scene max gap (s)"
69
+ LABEL_SCENE_CUES = "Scene max cues"
70
+ LABEL_SCENE_DUR = "Scene max dur (s)"
71
+ LABEL_TEMP = "Temperature"
72
+ LABEL_NGL = "GPU layers (-ngl)"
73
+ LABEL_CTX = "Context size"