STBack23 commited on
Commit
b5c8312
·
verified ·
1 Parent(s): 16a2764

Upload OmniSub source

Browse files
.gitignore ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+
11
+ # Media & dữ liệu phim (KHÔNG đẩy lên repo mã nguồn)
12
+ *.mp4
13
+ *.mkv
14
+ *.avi
15
+ *.mov
16
+ *.webm
17
+ *.ts
18
+ *.flv
19
+ *.wav
20
+ *.mp3
21
+
22
+ # Đầu ra pipeline
23
+ *.vi.srt
24
+ *.report.json
25
+ glossary.json
26
+ work/
27
+ /content/
28
+
29
+ # Phụ đề test ở thư mục gốc (không đẩy lên repo mã nguồn); vẫn giữ samples/
30
+ /*.srt
31
+ !samples/*.srt
PLAN.md ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PLAN — OmniSub (dịch SRT ZH→VI bằng Qwen3-Omni)
2
+
3
+ Tài liệu thiết kế chi tiết. Quyết định đã chốt: **cloud-first (Colab L4 24GB)**, **Qwen3-Omni 4-bit
4
+ làm tất cả** (nghe giọng + nhìn cảnh + dịch), **bỏ regex xưng hô** của bản cũ.
5
+
6
+ ---
7
+
8
+ ## 0. Quyết định đã chốt
9
+ - **Model**: `Qwen/Qwen3-Omni-30B-A3B-Instruct` (bản **Instruct**, 4-bit).
10
+ - **OCR sửa phụ đề cháy hình** (Bước 1, tùy chọn): **giữ lại nhưng MẶC ĐỊNH TẮT** (`correct_ocr: false`),
11
+ bật khi cần qua config/CLI. Nguồn SRT hiện đã sạch.
12
+ - **Tên file ra**: giữ nếp cũ `*.vi.srt` (+ `*.vi.srt.report.json`).
13
+
14
+ ## 1. Mục tiêu
15
+ 1. Dịch SRT **Trung → Việt** tự nhiên, đúng văn nói, súc tích.
16
+ 2. **Xưng hô đúng vai vế** nhờ:
17
+ - **Nghe giọng**: ai đang nói (diarization) + giới/tuổi/cảm xúc/vai vế (Omni nghe trực tiếp).
18
+ - **Nhìn ngữ cảnh**: frame video của cảnh đang dịch.
19
+ 3. Nhất quán **tên riêng** và **cách xưng hô** xuyên suốt phim.
20
+ 4. Phụ đề **vừa thời lượng** hiển thị (chars/giây, số dòng).
21
+
22
+ ## 2. Bài học từ `Gemma-4` (giữ / bỏ)
23
+ **Giữ (port sạch):**
24
+ - `parse_srt`, `write_srt`, `subtitle_char_budget`, `_seconds_to_srt_time` — I/O SRT vững.
25
+ - `group_scenes`, `merge_adjacent_pairs` — gom cảnh, ghép cue vụn (sentence-aware, same-speaker).
26
+ - Pattern `chat_json` + `_extract_json` — parse JSON từ output model bền bỉ.
27
+ - Cấu trúc Colab + Google Drive (`Phim/`, `Cache/`).
28
+
29
+ **Bỏ / thay thế:**
30
+ - ❌ `RelationshipRegistry`, `_CJK_ROMANCE_RE`, `_CJK_FAMILY_RE`, `_VN_ANH_EM_RE`, `enforce_cue`
31
+ (hàng trăm dòng regex khóa anh/em vs mẹ/con) → thay bằng **hồ sơ giọng + ngữ cảnh đa phương thức**.
32
+ - ❌ `estimate_speaker_demographics` (wav2vec2 age/gender) → Omni nghe hiểu trực tiếp.
33
+ - ❌ `_patch_speechbrain_lazy` (vá Windows) → Colab Linux không cần.
34
+ - 🔄 Gemma 4 12B (llama.cpp) → **Qwen3-Omni-30B-A3B** (transformers).
35
+
36
+ ## 3. Kiến trúc & luồng 5 bước
37
+
38
+ ```
39
+ INPUT: video.mp4 + video.srt (ZH)
40
+
41
+ ┌───────────────────────────────────────────────────────────────────────────────┐
42
+ │ Bước 1 Chuẩn bị SRT srt.py + scenes.py │
43
+ │ parse → gom cảnh → ghép cue vụn → char budget │
44
+ │ [tùy chọn] OCR sửa phụ đề cháy hình (correct.py) — MẶC ĐỊNH TẮT │
45
+ └───────────────────────────────────────────────────────────────────────────────┘
46
+
47
+ ┌───────────────────────────────────────────────────────────────────────────────┐
48
+ │ Bước 2 Phân tích người nói diarize.py │
49
+ │ pyannote community-1 → speaker turns → gán cue.speaker │
50
+ └───────────────────────────────────────────────────────────────────────────────┘
51
+
52
+ ┌───────────────────────────────────────────────────────────────────────────────┐
53
+ │ Bước 3 Hồ sơ giọng profiles.py │
54
+ │ Qwen3-Omni nghe 3–5 clip/speaker → VoiceProfile │
55
+ │ {gender, age_range, emotion, role_guess, register_hint} │
56
+ └───────────────────────────────────────────────────────────────────────────────┘
57
+
58
+ ┌───────────────────────────────────────────────────────────────────────────────┐
59
+ │ Bước 4 Dịch đa phương thức translate.py + scene_context.py │
60
+ │ Qwen3-Omni: audio cảnh + frames + cues + profiles + glossary (partial)│
61
+ │ → bản dịch VN + ghi chú xưng hô │
62
+ └───────────────────────────────────────────────────────────────────────────────┘
63
+
64
+ ┌───────────────────────────────────────────────────────────────────────────────┐
65
+ │ Bước 5 Hậu kiểm & xuất glossary.py + srt.py │
66
+ │ nhất quán tên/xưng hô → fit thời lượng → ghi file │
67
+ └───────────────────────────────────────────────────────────────────────────────┘
68
+
69
+ OUTPUT: video.vi.srt + video.vi.srt.report.json + glossary.json
70
+ ```
71
+
72
+ | Bước | Module | Model | Mặc định |
73
+ |---|---|---|---|
74
+ | 1 — Chuẩn bị SRT | `srt.py`, `scenes.py`, `correct.py` | — / Omni (nếu bật OCR) | OCR **tắt** |
75
+ | 2 — Phân tích người nói | `diarize.py` | pyannote | bật |
76
+ | 3 — Hồ sơ giọng | `profiles.py` | Qwen3-Omni | bật |
77
+ | 4 — Dịch | `translate.py`, `scene_context.py` | Qwen3-Omni | bật |
78
+ | 5 — Hậu kiểm | `glossary.py` | — / Omni (rút gọn cue) | bật |
79
+
80
+ ### Vì sao vẫn cần pyannote (Bước 2)
81
+ Qwen3-Omni hiện **chưa gán nhãn speaker ổn định** trên cả phim (xác nhận qua tài liệu kỹ thuật &
82
+ nghiên cứu HumanOmni-Speaker 2026 — cần adapter riêng). pyannote cho **nhãn nhất quán** để gắn
83
+ hồ sơ giọng (Bước 3) cho đúng người ở mọi cảnh.
84
+
85
+ ### Vì sao một mô hình Omni cho Bước 3 & 4
86
+ Đưa **audio đoạn cảnh + frame + cue** cùng lúc → model "nghe" được tông giọng (ai đang nói với ai,
87
+ tình cảm hay gắt gỏng) và "thấy" bối cảnh → tự chọn xưng hô. Không còn luật cứng.
88
+
89
+ ## 4. Module chi tiết (`src/omnisub/`)
90
+
91
+ | File | Trách nhiệm | Nguồn |
92
+ |---|---|---|
93
+ | `srt.py` | Dataclass `Cue`, parse/write SRT, char budget | Port từ bản cũ |
94
+ | `scenes.py` | `Scene`, `group_scenes`, `merge_adjacent_pairs` | Port, dọn lại |
95
+ | `diarize.py` | Wrapper pyannote → `SpeakerTurn`, gán `cue.speaker` | Port rút gọn |
96
+ | `correct.py` | Bước 1 — OCR sửa phụ đề cháy hình (**mặc định tắt**) | Port từ `correct_scene` cũ |
97
+ | `profiles.py` | ★ `VoiceProfile`; trích clip/speaker; gọi Omni nghe → hồ sơ | Mới |
98
+ | `scene_context.py` | Trích frame (ffmpeg), chọn audio đoạn cảnh | Port `extract_frame` + mới |
99
+ | `translate.py` | Dựng prompt đa phương thức/cảnh, gọi Omni, parse kết quả | Viết lại (bỏ regex) |
100
+ | `glossary.py` | ★ Nhất quán tên riêng + xưng hô theo dữ liệu (không regex) | Mới (thay NameRegistry) |
101
+ | `backends/base.py` | Interface `LLMBackend.chat_json(text, images, audio, …)` | Mới |
102
+ | `backends/transformers_qwen.py` | Qwen3-Omni qua HF transformers (Colab) | Mới |
103
+ | `pipeline.py` | Điều phối Bước 1→5, logging, progress, stop | Viết lại gọn |
104
+ | `cli.py` | Argparse CLI | Phỏng theo bản cũ |
105
+
106
+ ### `VoiceProfile` (Bước 3) — cấu trúc đề xuất
107
+ ```python
108
+ @dataclass
109
+ class VoiceProfile:
110
+ speaker: str # "SPEAKER_01" (khớp pyannote)
111
+ gender: str # nam | nữ | trẻ em | chưa rõ
112
+ age_range: str # trẻ em | thiếu niên | thanh niên | trung niên | lớn tuổi
113
+ emotion_baseline: str # vd: điềm đạm / nóng nảy / dịu dàng
114
+ role_guess: str # vd: "phụ nữ trẻ, có vẻ là người yêu của SPEAKER_02"
115
+ register_hint: str # gợi ý xưng hô VN: "nên dùng em với SPEAKER_02"
116
+ evidence: str # vì sao (model tự giải thích ngắn)
117
+ ```
118
+ Hồ sơ này được nhét vào prompt dịch để Omni giữ xưng hô nhất quán theo từng nhân vật.
119
+
120
+ ### Glossary (Bước 5) — thay regex bằng dữ liệu
121
+ - Thu thập **tên riêng** (token CJK lặp lại) → khóa **một** cách phiên âm VN, tái dùng (giữ ý tưởng
122
+ `NameRegistry` cũ nhưng tách bạch, có thể chỉnh tay qua `glossary.json`).
123
+ - **Cặp xưng hô** giữa các speaker do **model đề xuất** (không hard-code), lưu vào glossary và đưa lại
124
+ vào prompt các cảnh sau để khóa nhất quán — thay cho `RelationshipRegistry`.
125
+
126
+ ## 5. Backend & mô hình (Colab L4 24GB)
127
+ - **Model**: `Qwen/Qwen3-Omni-30B-A3B-Instruct` (Bước 3 & 4).
128
+ - **Lượng hóa**: **4-bit** (bitsandbytes hoặc checkpoint AWQ/GPTQ Int4) → ~17–18GB, vừa L4 24GB
129
+ (chừa chỗ cho KV cache + encoder audio/vision). *Cần kiểm tra checkpoint 4-bit sẵn có trên HF.*
130
+ - **Audio**: resample 16 kHz mono (đã có sẵn `extract_audio` bản cũ). Đưa audio **sau** text trong prompt
131
+ (theo best practice Gemma/Qwen multimodal: ảnh trước text, audio sau text).
132
+ - **Vision**: frame trích bằng ffmpeg, đưa **trước** text.
133
+ - **Tham số sampling**: theo khuyến nghị Qwen (temperature ~0.7–1.0, top_p 0.95, top_k 64) — chốt khi test.
134
+ - **Drive**: cache model ở `Gemma/Cache/`, phim ở `Gemma/Phim/` (giữ nếp cũ).
135
+
136
+ ## 6. Cấu hình (`config.yaml`)
137
+ ```yaml
138
+ models:
139
+ omni: "Qwen/Qwen3-Omni-30B-A3B-Instruct"
140
+ quant: "4bit"
141
+ diarize: "pyannote/speaker-diarization-community-1"
142
+ translate:
143
+ source_lang: "Chinese"
144
+ target_lang: "Vietnamese"
145
+ chars_per_sec: 22
146
+ max_line_chars: 52
147
+ max_lines: 2
148
+ correct_ocr: false # Bước 1 — OCR sửa phụ đề cháy hình (mặc định tắt)
149
+ output_suffix: ".vi.srt" # giữ nếp cũ
150
+ scene:
151
+ max_gap: 1.5
152
+ max_cues: 4
153
+ max_dur: 20.0
154
+ profiling:
155
+ clips_per_speaker: 5
156
+ max_seconds_per_clip: 8
157
+ paths:
158
+ drive_root: "/content/drive/MyDrive/Gemma"
159
+ ```
160
+
161
+ ## 7. Lộ trình (milestones)
162
+ - **M1 — Bước 1 Chuẩn bị SRT**: scaffold thư mục, `srt.py`, `scenes.py`, `config.yaml`, `backends/base.py`.
163
+ *Done khi*: parse SRT → gom cảnh → write SRT chạy được; `python -m omnisub.cli --help`.
164
+ - **M2 — Backend Omni (Colab)**: notebook tải Qwen3-Omni 4-bit; `chat_json` text-only; dịch thử 1 cảnh.- **M3 — Bước 2 Diarization**: port pyannote gọn; gán `cue.speaker`; xuất nhãn.
165
+ - **M4 — Bước 3 Voice Profiling ★**: `profiles.py` — Omni nghe clip → `VoiceProfile`.
166
+ - **M5 — Bước 4 Dịch đa phương thức**: `translate.py` ghép audio+frame+cue+profile; **không regex**.
167
+ - **M6 — Bước 5 Hậu kiểm**: nhất quán tên/xưng hô; fit timing; xuất `.vi.srt` + report JSON.
168
+ - **M7 — Tài liệu & hoàn thiện**: tinh chỉnh prompt, hoàn thiện README + notebook Colab.
169
+
170
+ ## 8. Rủi ro & phương án
171
+ | Rủi ro | Phương án |
172
+ |---|---|
173
+ | Omni 4-bit không có checkpoint sẵn / chất lượng tụt | Thử AWQ Int4; nếu kẹt → tách: Qwen3-Omni nghe giọng + Qwen3-VL dịch |
174
+ | L4 24GB tràn VRAM khi kèm audio+vision dài | Giảm số frame/cảnh, cắt audio ≤ 8s/clip, giảm visual token budget |
175
+ | pyannote chậm với phim dài | Chỉ diarize tới `max(cue.end)`; cân nhắc NeMo Sortformer |
176
+ | Diarization gán sai người nói | Cho phép sửa tay nhãn speaker trước Bước 3 |
177
+ | Mất kết nối/ngắt phiên Colab giữa chừng | Cache theo cảnh, cho chạy lại từ cảnh dở; lưu kết quả ra Drive |
178
+
179
+ ## 9. Quyết định (đã chốt — xem mục 0)
180
+ - ✅ Qwen3-Omni **`-Instruct`** 4-bit.
181
+ - ✅ OCR (Bước 1) **mặc định tắt** (`correct_ocr: false`), bật khi cần.
182
+ - ✅ Tên file ra **`.vi.srt`**.
183
+
184
+ ### Việc cần kiểm tra trước M2
185
+ - Xác nhận có checkpoint **Qwen3-Omni-30B-A3B-Instruct 4-bit / AWQ Int4** trên Hugging Face.
186
+ Nếu chưa có → phương án dự phòng: Qwen3-Omni (nghe giọng) + Qwen3-VL (dịch). (Xem mục 8.)
README.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OmniSub — Dịch phụ đề ZH→VI bằng Qwen3-Omni (đa phương thức)
2
+
3
+ Dịch và hiệu đính phụ đề SRT **Trung → Việt**, trong đó mô hình **nghe giọng nói thật** và
4
+ **nhìn ngữ cảnh video** để chọn **xưng hô** (anh/em, mẹ/con, tỷ/muội, ngài/ta…) cho đúng vai vế —
5
+ thay vì suy đoán bằng luật regex như bản cũ.
6
+
7
+ > Đây là bản viết lại, tối ưu từ dự án `Gemma-4`. Khác biệt chính: gom về **một họ Qwen**,
8
+ > dùng **Qwen3-Omni** nghe–nhìn–dịch trong cùng một mô hình, và **bỏ toàn bộ regex khóa quan hệ/xưng hô**.
9
+
10
+ ## Vì sao đổi từ Gemma sang Qwen3-Omni
11
+ - **Dịch ZH→VI tốt hơn**: Qwen dẫn đầu benchmark tiếng Việt & tiếng Trung (SEA-HELM 2026).
12
+ - **Nghe hiểu giọng**: Qwen3-Omni nhận **text + ảnh + audio + video** natively, SOTA audio,
13
+ cho biết giới tính/tuổi/cảm xúc/vai vế của người đang nói → neo xưng hô vào ngữ cảnh thật.
14
+ - **Đơn giản hóa**: một mô hình thay cho 3 stack rời (LLM + wav2vec2 + regex).
15
+
16
+ ## Kiến trúc — 5 bước (cloud-first, Colab L4)
17
+ ```
18
+ Video + SRT(ZH)
19
+ ├─ Bước 1 Chuẩn bị SRT parse, gom cảnh, ghép cue [OCR: mặc định tắt]
20
+ ├─ Bước 2 Phân tích người nói pyannote → ai nói lúc nào
21
+ ├─ Bước 3 Hồ sơ giọng Qwen3-Omni nghe → VoiceProfile từng nhân vật
22
+ ├─ Bước 4 Dịch đa phương thức Qwen3-Omni: audio + frame + cue + hồ sơ → VN
23
+ └─ Bước 5 Hậu kiểm & xuất glossary + fit thời lượng → phim.vi.srt
24
+ ```
25
+ Qwen3-Omni-30B-A3B đảm nhiệm **Bước 3 & 4** (nghe giọng, nhìn cảnh, dịch). pyannote dùng ở
26
+ **Bước 2** (gán nhãn người nói nhất quán — việc Omni chưa làm tốt natively).
27
+
28
+ ## Phần cứng — chạy trên Colab
29
+ Qwen3-Omni 4-bit/AWQ khá nặng nên **toàn bộ pipeline chạy trên Colab**, không có nhánh chạy local.
30
+
31
+ | Nơi chạy | Cấu hình | Vai trò |
32
+ |---|---|---|
33
+ | **Colab** | GPU **L4 24GB** | Chạy Qwen3-Omni **4-bit** (~17–18GB) + pyannote — toàn bộ pipeline |
34
+
35
+ ## Trạng thái
36
+ 🚧 Đang phát triển. Đã xong **M1 — Chuẩn bị SRT** (parse → ghép cue vụn → gom cảnh → xuất)
37
+ và bộ khung đầy đủ Bước 2–5 (diarization, hồ sơ giọng, dịch đa phương thức, hậu kiểm) cùng
38
+ backend Qwen3-Omni cho Colab. Chi tiết kiến trúc, module và lộ trình: xem [`PLAN.md`](PLAN.md).
39
+
40
+ ## Cách dùng (Colab)
41
+ Mở [`colab/OmniSub_Colab.ipynb`](colab/OmniSub_Colab.ipynb) trên Colab (Runtime → L4) và chạy lần lượt các cell.
42
+ Cell cuối gọi pipeline tương đương:
43
+ ```bash
44
+ python -m omnisub.cli run phim.srt --video phim.mp4 --backend qwen --config config.yaml
45
+ ```
46
+ Kiểm tra nhanh Bước 1 (không nạp model) ngay trong Colab/môi trường có Python:
47
+ ```bash
48
+ python -m omnisub.cli prepare samples/demo.zh.srt
49
+ ```
50
+
51
+ ## Cấu trúc thư mục
52
+ ```
53
+ Gemma/
54
+ ├── README.md / PLAN.md
55
+ ├── pyproject.toml / requirements-colab.txt / config.yaml
56
+ ├── src/omnisub/{srt,scenes,diarize,correct,profiles,scene_context,translate,glossary,pipeline,cli,config}.py
57
+ │ └── backends/{base,transformers_qwen}.py
58
+ ├── colab/OmniSub_Colab.ipynb
59
+ └── samples/demo.zh.srt
60
+ ```
colab/OmniSub_Colab.ipynb ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# OmniSub — Dịch SRT ZH→VI bằng Qwen3-Omni (Colab L4 24GB)\n",
8
+ "\n",
9
+ "Chạy lần lượt từng cell. Yêu cầu: Runtime **GPU L4** (Runtime → Change runtime type → L4).\n",
10
+ "\n",
11
+ "Pipeline 5 bước: chuẩn bị SRT → diarization (pyannote) → hồ sơ giọng (Omni) → dịch đa phương thức (Omni) → hậu kiểm & xuất."
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "code",
16
+ "execution_count": null,
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "# 1) Kiểm tra GPU\n",
21
+ "!nvidia-smi"
22
+ ]
23
+ },
24
+ {
25
+ "cell_type": "code",
26
+ "execution_count": null,
27
+ "metadata": {},
28
+ "outputs": [],
29
+ "source": [
30
+ "# 2) Mount Google Drive (phim ở Gemma/Phim, cache model ở Gemma/Cache)\n",
31
+ "from google.colab import drive\n",
32
+ "drive.mount('/content/drive')\n",
33
+ "\n",
34
+ "import os\n",
35
+ "DRIVE_ROOT = '/content/drive/MyDrive/Gemma'\n",
36
+ "os.makedirs(f'{DRIVE_ROOT}/Phim', exist_ok=True)\n",
37
+ "os.makedirs(f'{DRIVE_ROOT}/Cache', exist_ok=True)\n",
38
+ "print('Drive root:', DRIVE_ROOT)"
39
+ ]
40
+ },
41
+ {
42
+ "cell_type": "code",
43
+ "execution_count": null,
44
+ "metadata": {},
45
+ "outputs": [],
46
+ "source": [
47
+ "# 3) Lấy MÃ NGUỒN từ Hugging Face Hub + cài phụ thuộc\n",
48
+ "HF_REPO = 'STBack23/OmniSub' # ← repo HF chứa mã nguồn dự án\n",
49
+ "HF_REPO_TYPE = 'model' # 'model' | 'dataset' | 'space'\n",
50
+ "\n",
51
+ "import os\n",
52
+ "%cd /content\n",
53
+ "!pip install -q -U huggingface_hub\n",
54
+ "from huggingface_hub import snapshot_download\n",
55
+ "# Repo công khai → KHÔNG cần token. Nếu repo riêng tư, thêm token=... vào đây.\n",
56
+ "snapshot_download(repo_id=HF_REPO, repo_type=HF_REPO_TYPE, local_dir='/content/Gemma')\n",
57
+ "%cd /content/Gemma\n",
58
+ "\n",
59
+ "!pip install -q -e .[colab]\n",
60
+ "# Qwen3-Omni hiện CHƯA có trên PyPI của transformers → cài bản từ nguồn:\n",
61
+ "!pip install -q -U \"git+https://github.com/huggingface/transformers\"\n",
62
+ "!pip install -q -U qwen-omni-utils\n",
63
+ "# FlashAttention 2 (giảm VRAM); nếu build lỗi có thể bỏ qua và dùng attn mặc định.\n",
64
+ "!pip install -q -U flash-attn --no-build-isolation\n",
65
+ "!apt-get -qq install -y ffmpeg\n",
66
+ "print('Mã nguồn OmniSub đã sẵn sàng (từ Hugging Face Hub).')"
67
+ ]
68
+ },
69
+ {
70
+ "cell_type": "code",
71
+ "execution_count": null,
72
+ "metadata": {},
73
+ "outputs": [],
74
+ "source": [
75
+ "# 4) Chọn phim — CHỈ cần nhập TÊN phim, tự dò file .srt và video trong Gemma/Phim\n",
76
+ "import os\n",
77
+ "from getpass import getpass\n",
78
+ "\n",
79
+ "MOVIE = 'Tiểu Ái Test' # ← đổi tên phim ở đây (trùng tên file, không cần đuôi)\n",
80
+ "\n",
81
+ "PHIM_DIR = f'{DRIVE_ROOT}/Phim'\n",
82
+ "CACHE = f'{DRIVE_ROOT}/Cache'\n",
83
+ "\n",
84
+ "HF_TOKEN = os.environ.get('HF_TOKEN') or getpass('HF token (pyannote): ')\n",
85
+ "assert HF_TOKEN, 'Cần HF token để chạy Bước 2 (diarization).'\n",
86
+ "\n",
87
+ "_VIDEO_EXTS = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.ts', '.flv']\n",
88
+ "\n",
89
+ "def _find(stem, exts):\n",
90
+ " for ext in exts:\n",
91
+ " p = os.path.join(PHIM_DIR, stem + ext)\n",
92
+ " if os.path.exists(p):\n",
93
+ " return p\n",
94
+ " return None\n",
95
+ "\n",
96
+ "stem = os.path.splitext(MOVIE)[0]\n",
97
+ "SRT = _find(stem, ['.srt'])\n",
98
+ "VIDEO = _find(stem, _VIDEO_EXTS)\n",
99
+ "\n",
100
+ "assert SRT, f\"Không thấy '{stem}.srt' trong {PHIM_DIR}. File hiện có: {os.listdir(PHIM_DIR)}\"\n",
101
+ "if not VIDEO:\n",
102
+ " print(f\"⚠️ Không thấy video cho '{stem}' — chạy được Bước 1, nhưng Bước 4 cần video.\")\n",
103
+ "\n",
104
+ "print('Phim :', stem)\n",
105
+ "print('SRT :', SRT)\n",
106
+ "print('Video:', VIDEO)"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": null,
112
+ "metadata": {},
113
+ "outputs": [],
114
+ "source": [
115
+ "# 5) Nạp model Qwen3-Omni 4-bit (vài phút lần đầu)\n",
116
+ "from omnisub.config import Config\n",
117
+ "from omnisub.backends.transformers_qwen import TransformersQwenOmniBackend\n",
118
+ "\n",
119
+ "config = Config.load('config.yaml')\n",
120
+ "backend = TransformersQwenOmniBackend(\n",
121
+ " model_name=config.models['omni'],\n",
122
+ " quant=config.models['quant'],\n",
123
+ " cache_dir=CACHE,\n",
124
+ ")\n",
125
+ "backend.load()\n",
126
+ "print('Model đã nạp xong.')"
127
+ ]
128
+ },
129
+ {
130
+ "cell_type": "code",
131
+ "execution_count": null,
132
+ "metadata": {},
133
+ "outputs": [],
134
+ "source": [
135
+ "# 6) Chạy toàn bộ pipeline (Bước 1→5)\n",
136
+ "import logging\n",
137
+ "logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')\n",
138
+ "\n",
139
+ "from omnisub.pipeline import run_pipeline\n",
140
+ "\n",
141
+ "result = run_pipeline(\n",
142
+ " SRT,\n",
143
+ " video=VIDEO,\n",
144
+ " config=config,\n",
145
+ " backend=backend,\n",
146
+ " work_dir='/content/work',\n",
147
+ " hf_token=HF_TOKEN,\n",
148
+ " do_diarize=True,\n",
149
+ ")\n",
150
+ "print('Output SRT:', result.output_srt)\n",
151
+ "print('Report :', result.report_path)"
152
+ ]
153
+ },
154
+ {
155
+ "cell_type": "code",
156
+ "execution_count": null,
157
+ "metadata": {},
158
+ "outputs": [],
159
+ "source": [
160
+ "# 7) Xem nhanh kết quả + report\n",
161
+ "import json\n",
162
+ "print(open(result.output_srt, encoding='utf-8').read()[:2000])\n",
163
+ "print('\\n--- REPORT ---')\n",
164
+ "print(json.dumps(json.load(open(result.report_path, encoding='utf-8')), ensure_ascii=False, indent=2))"
165
+ ]
166
+ }
167
+ ],
168
+ "metadata": {
169
+ "accelerator": "GPU",
170
+ "colab": {
171
+ "provenance": []
172
+ },
173
+ "kernelspec": {
174
+ "display_name": "Python 3",
175
+ "name": "python3"
176
+ },
177
+ "language_info": {
178
+ "name": "python"
179
+ }
180
+ },
181
+ "nbformat": 4,
182
+ "nbformat_minor": 0
183
+ }
config.yaml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ models:
2
+ omni: "Qwen/Qwen3-Omni-30B-A3B-Instruct"
3
+ quant: "4bit"
4
+ diarize: "pyannote/speaker-diarization-community-1"
5
+
6
+ translate:
7
+ source_lang: "Chinese"
8
+ target_lang: "Vietnamese"
9
+ chars_per_sec: 22
10
+ max_line_chars: 52
11
+ max_lines: 2
12
+ correct_ocr: false # Bước 1 — OCR sửa phụ đề cháy hình (mặc định tắt)
13
+ output_suffix: ".vi.srt" # giữ nếp cũ
14
+
15
+ scene:
16
+ max_gap: 1.5
17
+ max_cues: 4
18
+ max_dur: 20.0
19
+
20
+ profiling:
21
+ clips_per_speaker: 5
22
+ max_seconds_per_clip: 8
23
+
24
+ sampling:
25
+ temperature: 0.7
26
+ top_p: 0.95
27
+ top_k: 64
28
+ max_new_tokens: 1024
29
+
30
+ paths:
31
+ drive_root: "/content/drive/MyDrive/Gemma"
pyproject.toml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "omnisub"
7
+ version = "0.1.0"
8
+ description = "Dịch phụ đề SRT ZH→VI bằng Qwen3-Omni (đa phương thức)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["PyYAML>=6.0"]
12
+
13
+ [project.optional-dependencies]
14
+ colab = [
15
+ "transformers>=4.57.0",
16
+ "accelerate>=0.34.0",
17
+ "bitsandbytes>=0.43.0",
18
+ "qwen-omni-utils>=0.0.4",
19
+ "torch>=2.4.0",
20
+ "torchaudio>=2.4.0",
21
+ "torchvision>=0.19.0",
22
+ "pyannote.audio>=3.3.0",
23
+ "soundfile>=0.12.1",
24
+ "librosa>=0.10.0",
25
+ "av>=12.0.0",
26
+ "Pillow>=10.0.0",
27
+ ]
28
+
29
+ [project.scripts]
30
+ omnisub = "omnisub.cli:main"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ omnisub = ["py.typed"]
requirements-colab.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OmniSub — phụ thuộc đầy đủ trên Colab L4 24GB (toàn bộ pipeline)
2
+ PyYAML>=6.0
3
+
4
+ # Qwen3-Omni qua HF transformers (Bước 3 & 4)
5
+ # LƯU Ý: Qwen3-Omni có thể CHƯA nằm trong bản transformers trên PyPI. Nếu nạp model
6
+ # báo thiếu Qwen3OmniMoe..., hãy cài bản từ nguồn (notebook Colab đã làm sẵn):
7
+ # pip install -U "git+https://github.com/huggingface/transformers"
8
+ transformers>=4.57.0
9
+ accelerate>=0.34.0
10
+ bitsandbytes>=0.43.0 # lượng hóa 4-bit
11
+ qwen-omni-utils>=0.0.4 # tiện ích xử lý multimodal của Qwen3-Omni
12
+ torch>=2.4.0
13
+ torchaudio>=2.4.0
14
+ torchvision>=0.19.0
15
+
16
+ # Bước 2 — diarization
17
+ pyannote.audio>=3.3.0
18
+
19
+ # I/O media
20
+ soundfile>=0.12.1
21
+ librosa>=0.10.0
22
+ av>=12.0.0 # đọc/giải mã video (PyAV)
23
+ Pillow>=10.0.0
samples/demo.zh.srt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 1
2
+ 00:00:01,000 --> 00:00:02,200
3
+ 你回来了
4
+
5
+ 2
6
+ 00:00:02,300 --> 00:00:03,000
7
+ 姐姐
8
+
9
+ 3
10
+ 00:00:03,100 --> 00:00:05,500
11
+ 我今天在学校
12
+ 遇到一件奇怪的事
13
+
14
+ 4
15
+ 00:00:10,000 --> 00:00:12,000
16
+ 别担心,我会保护你的
17
+
18
+ 5
19
+ 00:00:12,200 --> 00:00:13,500
20
+ 谢谢你,哥哥
src/omnisub/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """OmniSub — dịch phụ đề SRT ZH→VI bằng Qwen3-Omni (đa phương thức)."""
2
+
3
+ __version__ = "0.1.0"
src/omnisub/backends/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Backends LLM cho OmniSub."""
2
+
3
+ from .base import LLMBackend, ChatMessage, extract_json
4
+
5
+ __all__ = ["LLMBackend", "ChatMessage", "extract_json"]
src/omnisub/backends/base.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interface backend LLM + tiện ích parse JSON bền bỉ từ output model.
2
+
3
+ Mọi backend (transformers Qwen3-Omni, llama.cpp fallback) phải hiện thực
4
+ `LLMBackend`. Pipeline chỉ phụ thuộc vào interface này, không vào model cụ thể.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import abc
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, List, Optional, Sequence
14
+
15
+
16
+ @dataclass
17
+ class ChatMessage:
18
+ """Một message đa phương thức gửi tới model.
19
+
20
+ - `text`: nội dung văn bản.
21
+ - `images`: đường dẫn ảnh/frame (đưa TRƯỚC text khi build prompt).
22
+ - `audio`: đường dẫn audio (đưa SAU text — theo best practice Qwen multimodal).
23
+ """
24
+
25
+ role: str = "user"
26
+ text: str = ""
27
+ images: List[str] = field(default_factory=list)
28
+ audio: List[str] = field(default_factory=list)
29
+
30
+
31
+ class LLMBackend(abc.ABC):
32
+ """Giao diện tối thiểu cho mọi backend."""
33
+
34
+ @abc.abstractmethod
35
+ def chat(
36
+ self,
37
+ messages: Sequence[ChatMessage],
38
+ *,
39
+ system: Optional[str] = None,
40
+ max_new_tokens: int = 1024,
41
+ temperature: float = 0.7,
42
+ top_p: float = 0.95,
43
+ top_k: int = 64,
44
+ **kwargs: Any,
45
+ ) -> str:
46
+ """Sinh văn bản từ chuỗi message. Trả về raw text."""
47
+ raise NotImplementedError
48
+
49
+ def chat_json(
50
+ self,
51
+ messages: Sequence[ChatMessage],
52
+ *,
53
+ system: Optional[str] = None,
54
+ max_new_tokens: int = 1024,
55
+ **kwargs: Any,
56
+ ) -> Any:
57
+ """Gọi `chat` rồi cố trích JSON từ output. Trả về dict/list đã parse."""
58
+ raw = self.chat(
59
+ messages, system=system, max_new_tokens=max_new_tokens, **kwargs
60
+ )
61
+ return extract_json(raw)
62
+
63
+ # Tiện ích cho subclass: gom text từ message (cho backend text-only).
64
+ @staticmethod
65
+ def _join_text(messages: Sequence[ChatMessage]) -> str:
66
+ return "\n".join(m.text for m in messages if m.text)
67
+
68
+
69
+ _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL | re.IGNORECASE)
70
+
71
+
72
+ def extract_json(raw: str) -> Any:
73
+ """Trích object/array JSON đầu tiên từ output model.
74
+
75
+ Thử theo thứ tự: parse trực tiếp → bóc khối ```json``` → quét cặp ngoặc
76
+ cân bằng đầu tiên. Ném `ValueError` nếu không tìm được JSON hợp lệ.
77
+ """
78
+ if raw is None:
79
+ raise ValueError("Output rỗng, không có JSON.")
80
+ text = raw.strip()
81
+
82
+ # 1) parse thẳng
83
+ try:
84
+ return json.loads(text)
85
+ except json.JSONDecodeError:
86
+ pass
87
+
88
+ # 2) bóc khối ```json ... ```
89
+ m = _FENCE_RE.search(text)
90
+ if m:
91
+ candidate = m.group(1).strip()
92
+ try:
93
+ return json.loads(candidate)
94
+ except json.JSONDecodeError:
95
+ text = candidate # tiếp tục quét trong nội dung khối
96
+
97
+ # 3) quét cặp ngoặc cân bằng đầu tiên ({} hoặc [])
98
+ for opener, closer in (("{", "}"), ("[", "]")):
99
+ snippet = _extract_balanced(text, opener, closer)
100
+ if snippet:
101
+ try:
102
+ return json.loads(snippet)
103
+ except json.JSONDecodeError:
104
+ continue
105
+
106
+ raise ValueError(f"Không trích được JSON từ output:\n{raw[:500]}")
107
+
108
+
109
+ def _extract_balanced(text: str, opener: str, closer: str) -> Optional[str]:
110
+ start = text.find(opener)
111
+ if start == -1:
112
+ return None
113
+ depth = 0
114
+ in_str = False
115
+ escape = False
116
+ for i in range(start, len(text)):
117
+ ch = text[i]
118
+ if in_str:
119
+ if escape:
120
+ escape = False
121
+ elif ch == "\\":
122
+ escape = True
123
+ elif ch == '"':
124
+ in_str = False
125
+ continue
126
+ if ch == '"':
127
+ in_str = True
128
+ elif ch == opener:
129
+ depth += 1
130
+ elif ch == closer:
131
+ depth -= 1
132
+ if depth == 0:
133
+ return text[start : i + 1]
134
+ return None
src/omnisub/backends/transformers_qwen.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend Qwen3-Omni qua HuggingFace transformers (Colab L4 24GB).
2
+
3
+ Nạp `Qwen/Qwen3-Omni-30B-A3B-Instruct` 4-bit (bitsandbytes) và chạy suy luận
4
+ đa phương thức (text + image + audio). Import nặng đều lazy để Bước 1 không cần GPU.
5
+
6
+ Tham khảo: model card Qwen3-Omni + `qwen_omni_utils.process_mm_info`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, List, Optional, Sequence
12
+
13
+ from .base import ChatMessage, LLMBackend
14
+
15
+
16
+ class TransformersQwenOmniBackend(LLMBackend):
17
+ def __init__(
18
+ self,
19
+ model_name: str = "Qwen/Qwen3-Omni-30B-A3B-Instruct",
20
+ *,
21
+ quant: str = "4bit",
22
+ device_map: str = "auto",
23
+ cache_dir: Optional[str] = None,
24
+ attn_implementation: Optional[str] = "flash_attention_2",
25
+ ):
26
+ self.model_name = model_name
27
+ self.quant = quant
28
+ self.device_map = device_map
29
+ self.cache_dir = cache_dir
30
+ self.attn_implementation = attn_implementation
31
+ self._model = None
32
+ self._processor = None
33
+
34
+ def load(self) -> None:
35
+ """Nạp model + processor (gọi một lần, tốn vài phút trên Colab)."""
36
+ if self._model is not None:
37
+ return
38
+ import torch
39
+ from transformers import (
40
+ Qwen3OmniMoeForConditionalGeneration,
41
+ Qwen3OmniMoeProcessor,
42
+ )
43
+
44
+ quant_config = None
45
+ if self.quant in ("4bit", "8bit"):
46
+ from transformers import BitsAndBytesConfig
47
+
48
+ quant_config = BitsAndBytesConfig(
49
+ load_in_4bit=self.quant == "4bit",
50
+ load_in_8bit=self.quant == "8bit",
51
+ bnb_4bit_compute_dtype=torch.bfloat16,
52
+ bnb_4bit_quant_type="nf4",
53
+ bnb_4bit_use_double_quant=True,
54
+ )
55
+
56
+ self._processor = Qwen3OmniMoeProcessor.from_pretrained(
57
+ self.model_name, cache_dir=self.cache_dir
58
+ )
59
+
60
+ # Ưu tiên FlashAttention 2 (ít VRAM nhất); nếu không build được thì lùi
61
+ # về sdpa rồi eager để vẫn chạy.
62
+ attn_candidates = [self.attn_implementation, "sdpa", "eager"]
63
+ seen, attn_candidates = set(), [
64
+ a for a in attn_candidates if a and not (a in seen or seen.add(a))
65
+ ]
66
+ last_err: Optional[Exception] = None
67
+ for attn in attn_candidates:
68
+ try:
69
+ self._model = Qwen3OmniMoeForConditionalGeneration.from_pretrained(
70
+ self.model_name,
71
+ torch_dtype=torch.bfloat16,
72
+ device_map=self.device_map,
73
+ quantization_config=quant_config,
74
+ attn_implementation=attn,
75
+ cache_dir=self.cache_dir,
76
+ )
77
+ self.attn_implementation = attn
78
+ break
79
+ except (ImportError, ValueError) as e:
80
+ last_err = e
81
+ continue
82
+ if self._model is None:
83
+ raise RuntimeError(f"Không nạp được model với mọi attn_implementation: {last_err}")
84
+ # Chỉ cần text → tắt talker (sinh audio) để tiết kiệm ~10GB VRAM.
85
+ disable = getattr(self._model, "disable_talker", None)
86
+ if callable(disable):
87
+ disable()
88
+ self._model.eval()
89
+
90
+ def _to_qwen_messages(
91
+ self, messages: Sequence[ChatMessage], system: Optional[str]
92
+ ) -> List[dict]:
93
+ """Chuyển ChatMessage → định dạng content của Qwen (ảnh trước, audio sau text)."""
94
+ out: List[dict] = []
95
+ if system:
96
+ out.append({"role": "system", "content": [{"type": "text", "text": system}]})
97
+ for m in messages:
98
+ content: List[dict] = []
99
+ for img in m.images: # ảnh TRƯỚC text
100
+ content.append({"type": "image", "image": img})
101
+ if m.text:
102
+ content.append({"type": "text", "text": m.text})
103
+ for aud in m.audio: # audio SAU text
104
+ content.append({"type": "audio", "audio": aud})
105
+ out.append({"role": m.role, "content": content})
106
+ return out
107
+
108
+ def chat(
109
+ self,
110
+ messages: Sequence[ChatMessage],
111
+ *,
112
+ system: Optional[str] = None,
113
+ max_new_tokens: int = 1024,
114
+ temperature: float = 0.7,
115
+ top_p: float = 0.95,
116
+ top_k: int = 64,
117
+ **kwargs: Any,
118
+ ) -> str:
119
+ self.load()
120
+ import torch
121
+ from qwen_omni_utils import process_mm_info
122
+
123
+ conv = self._to_qwen_messages(messages, system)
124
+ text = self._processor.apply_chat_template(
125
+ conv, add_generation_prompt=True, tokenize=False
126
+ )
127
+ # Ta trích audio/frame riêng nên không dùng audio-trong-video.
128
+ audios, images, videos = process_mm_info(conv, use_audio_in_video=False)
129
+ inputs = self._processor(
130
+ text=text,
131
+ audio=audios,
132
+ images=images,
133
+ videos=videos,
134
+ return_tensors="pt",
135
+ padding=True,
136
+ use_audio_in_video=False,
137
+ )
138
+ inputs = inputs.to(self._model.device)
139
+ # ép tensor float về dtype của model (vd bf16) — BatchFeature.to(dtype)
140
+ # chỉ đổi tensor float, giữ nguyên input_ids dạng int.
141
+ try:
142
+ inputs = inputs.to(self._model.dtype)
143
+ except (AttributeError, RuntimeError, TypeError):
144
+ pass
145
+
146
+ with torch.no_grad():
147
+ generated = self._model.generate(
148
+ **inputs,
149
+ max_new_tokens=max_new_tokens,
150
+ do_sample=temperature > 0,
151
+ temperature=temperature,
152
+ top_p=top_p,
153
+ top_k=top_k,
154
+ # chỉ cần text: tắt sinh audio + trả về dict để lấy .sequences
155
+ return_audio=False,
156
+ thinker_return_dict_in_generate=True,
157
+ use_audio_in_video=False,
158
+ )
159
+ # generate trả về tuple (text_ids, audio); text_ids có thể là ModelOutput
160
+ text_ids = generated[0] if isinstance(generated, (tuple, list)) else generated
161
+ sequences = getattr(text_ids, "sequences", text_ids)
162
+ # cắt phần prompt, chỉ decode token mới
163
+ trimmed = sequences[:, inputs["input_ids"].shape[1] :]
164
+ out = self._processor.batch_decode(
165
+ trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
166
+ )
167
+ return out[0].strip() if out else ""
src/omnisub/cli.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI cho OmniSub.
2
+
3
+ Ví dụ:
4
+ # Kiểm tra nhanh Bước 1 (không nạp model): parse + gom cảnh
5
+ python -m omnisub.cli prepare phim.srt
6
+
7
+ # Toàn bộ pipeline trên Colab (có video + Qwen3-Omni)
8
+ python -m omnisub.cli run phim.srt --video phim.mp4 --config config.yaml
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import logging
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .config import Config
19
+ from .pipeline import prepare_subtitles, run_pipeline
20
+
21
+
22
+ def _force_utf8() -> None:
23
+ """Ép stdout/stderr sang UTF-8 (console Windows mặc định cp1252 không in được tiếng Việt)."""
24
+ for stream in (sys.stdout, sys.stderr):
25
+ reconfigure = getattr(stream, "reconfigure", None)
26
+ if reconfigure is not None:
27
+ try:
28
+ reconfigure(encoding="utf-8")
29
+ except (ValueError, OSError):
30
+ pass
31
+
32
+
33
+ def _setup_logging(verbose: bool) -> None:
34
+ _force_utf8()
35
+ logging.basicConfig(
36
+ level=logging.DEBUG if verbose else logging.INFO,
37
+ format="%(asctime)s [%(levelname)s] %(message)s",
38
+ datefmt="%H:%M:%S",
39
+ )
40
+
41
+
42
+ def _build_backend(config: Config, args):
43
+ """Khởi tạo backend Qwen3-Omni (lazy import để Bước 1 không cần GPU).
44
+
45
+ `--backend none` chỉ chạy Bước 1 (chuẩn bị SRT) để kiểm tra nhanh, không nạp model.
46
+ """
47
+ if args.backend == "none":
48
+ return None
49
+ if args.backend == "qwen":
50
+ from .backends.transformers_qwen import TransformersQwenOmniBackend
51
+
52
+ return TransformersQwenOmniBackend(
53
+ model_name=config.models["omni"],
54
+ quant=config.models["quant"],
55
+ cache_dir=args.cache_dir,
56
+ )
57
+ raise SystemExit(f"Backend không hỗ trợ: {args.backend}")
58
+
59
+
60
+ def cmd_prepare(args) -> int:
61
+ config = Config.load(args.config)
62
+ cues, scenes = prepare_subtitles(args.srt, config)
63
+ print(f"Cue: {len(cues)} | Cảnh: {len(scenes)}")
64
+ for sc in scenes[: args.preview]:
65
+ print(f" Cảnh #{sc.scene_id} [{sc.start:.1f}-{sc.end:.1f}s] {len(sc.cues)} cue")
66
+ for c in sc.cues:
67
+ print(f" [{c.index}] {c.text[:60]}")
68
+ if args.out:
69
+ from .srt import write_srt
70
+
71
+ write_srt(cues, args.out)
72
+ print(f"Đã ghi cue đã gom cảnh ra: {args.out}")
73
+ return 0
74
+
75
+
76
+ def cmd_run(args) -> int:
77
+ config = Config.load(args.config)
78
+ backend = _build_backend(config, args)
79
+ result = run_pipeline(
80
+ args.srt,
81
+ video=args.video,
82
+ config=config,
83
+ backend=backend,
84
+ work_dir=args.work_dir,
85
+ hf_token=args.hf_token,
86
+ do_diarize=not args.no_diarize,
87
+ do_profile=not args.no_profile,
88
+ do_translate=not args.no_translate,
89
+ )
90
+ print(f"Xong. Output: {result.output_srt}")
91
+ print(f"Report: {result.report_path}")
92
+ return 0
93
+
94
+
95
+ def build_parser() -> argparse.ArgumentParser:
96
+ p = argparse.ArgumentParser(
97
+ prog="omnisub",
98
+ description="Dịch phụ đề SRT ZH→VI bằng Qwen3-Omni (đa phương thức).",
99
+ )
100
+ p.add_argument("-v", "--verbose", action="store_true", help="log chi tiết")
101
+ p.add_argument("--config", default="config.yaml", help="đường dẫn config.yaml")
102
+ sub = p.add_subparsers(dest="command", required=True)
103
+
104
+ pp = sub.add_parser("prepare", help="Bước 1: parse + gom cảnh (không cần model)")
105
+ pp.add_argument("srt", help="file SRT nguồn (ZH)")
106
+ pp.add_argument("--out", help="ghi cue đã gom cảnh ra file SRT (tùy chọn)")
107
+ pp.add_argument("--preview", type=int, default=5, help="số cảnh in thử")
108
+ pp.set_defaults(func=cmd_prepare)
109
+
110
+ pr = sub.add_parser("run", help="Chạy pipeline đầy đủ (Bước 1→5)")
111
+ pr.add_argument("srt", help="file SRT nguồn (ZH)")
112
+ pr.add_argument("--video", help="file video tương ứng (mp4...)")
113
+ pr.add_argument(
114
+ "--backend", choices=["none", "qwen"], default="qwen",
115
+ help="backend model (none = chỉ chạy Bước 1)",
116
+ )
117
+ pr.add_argument("--cache-dir", help="thư mục cache model (Drive/Cache)")
118
+ pr.add_argument("--work-dir", help="thư mục làm việc tạm (frame/audio)")
119
+ pr.add_argument("--hf-token", help="HuggingFace token cho pyannote")
120
+ pr.add_argument("--no-diarize", action="store_true", help="bỏ Bước 2")
121
+ pr.add_argument("--no-profile", action="store_true", help="bỏ Bước 3")
122
+ pr.add_argument("--no-translate", action="store_true", help="bỏ Bước 4")
123
+ pr.set_defaults(func=cmd_run)
124
+
125
+ return p
126
+
127
+
128
+ def main(argv=None) -> int:
129
+ _force_utf8()
130
+ parser = build_parser()
131
+ args = parser.parse_args(argv)
132
+ _setup_logging(getattr(args, "verbose", False))
133
+ return args.func(args)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ sys.exit(main())
src/omnisub/config.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Đọc và hợp nhất cấu hình từ `config.yaml`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Dict
9
+
10
+ try:
11
+ import yaml
12
+ except ImportError: # pragma: no cover
13
+ yaml = None # type: ignore
14
+
15
+
16
+ DEFAULT_CONFIG: Dict[str, Any] = {
17
+ "models": {
18
+ "omni": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
19
+ "quant": "4bit",
20
+ "diarize": "pyannote/speaker-diarization-community-1",
21
+ },
22
+ "translate": {
23
+ "source_lang": "Chinese",
24
+ "target_lang": "Vietnamese",
25
+ "chars_per_sec": 22,
26
+ "max_line_chars": 52,
27
+ "max_lines": 2,
28
+ "correct_ocr": False,
29
+ "output_suffix": ".vi.srt",
30
+ },
31
+ "scene": {"max_gap": 1.5, "max_cues": 4, "max_dur": 20.0},
32
+ "profiling": {"clips_per_speaker": 5, "max_seconds_per_clip": 8},
33
+ "sampling": {
34
+ "temperature": 0.7,
35
+ "top_p": 0.95,
36
+ "top_k": 64,
37
+ "max_new_tokens": 1024,
38
+ },
39
+ "paths": {"drive_root": "/content/drive/MyDrive/Gemma"},
40
+ }
41
+
42
+
43
+ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
44
+ out = copy.deepcopy(base)
45
+ for k, v in (override or {}).items():
46
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
47
+ out[k] = _deep_merge(out[k], v)
48
+ else:
49
+ out[k] = v
50
+ return out
51
+
52
+
53
+ @dataclass
54
+ class Config:
55
+ data: Dict[str, Any]
56
+
57
+ @classmethod
58
+ def load(cls, path: str | Path | None = None) -> "Config":
59
+ merged = copy.deepcopy(DEFAULT_CONFIG)
60
+ if path is not None:
61
+ p = Path(path)
62
+ if p.exists():
63
+ if yaml is None:
64
+ raise RuntimeError(
65
+ "Cần PyYAML để đọc config.yaml — `pip install pyyaml`."
66
+ )
67
+ loaded = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
68
+ merged = _deep_merge(merged, loaded)
69
+ return cls(data=merged)
70
+
71
+ def __getitem__(self, key: str) -> Any:
72
+ return self.data[key]
73
+
74
+ def get(self, key: str, default: Any = None) -> Any:
75
+ return self.data.get(key, default)
76
+
77
+ @property
78
+ def models(self) -> Dict[str, Any]:
79
+ return self.data["models"]
80
+
81
+ @property
82
+ def translate(self) -> Dict[str, Any]:
83
+ return self.data["translate"]
84
+
85
+ @property
86
+ def scene(self) -> Dict[str, Any]:
87
+ return self.data["scene"]
88
+
89
+ @property
90
+ def profiling(self) -> Dict[str, Any]:
91
+ return self.data["profiling"]
92
+
93
+ @property
94
+ def sampling(self) -> Dict[str, Any]:
95
+ return self.data["sampling"]
96
+
97
+ @property
98
+ def paths(self) -> Dict[str, Any]:
99
+ return self.data["paths"]
src/omnisub/correct.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bước 1 (tùy chọn) — OCR sửa phụ đề cháy hình. MẶC ĐỊNH TẮT.
2
+
3
+ Khi `correct_ocr: true`, đưa frame + text OCR cho Omni để sửa lỗi nhận dạng
4
+ (ký tự sai, dính dòng). Nguồn SRT hiện đã sạch nên mặc định bỏ qua bước này.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import List, Optional
10
+
11
+ from .backends.base import ChatMessage, LLMBackend
12
+ from .scene_context import SceneContext
13
+ from .scenes import Scene
14
+
15
+
16
+ _CORRECT_SYSTEM = (
17
+ "Bạn là công cụ sửa lỗi OCR phụ đề tiếng Trung cháy trên khung hình. "
18
+ "So khớp văn bản với khung hình và sửa ký tự sai. "
19
+ "Trả về JSON {\"cues\":[{\"id\":<int>,\"zh\":\"<text đã sửa>\"}]}, không thêm gì khác."
20
+ )
21
+
22
+
23
+ def correct_scene(
24
+ backend: LLMBackend,
25
+ scene: Scene,
26
+ context: Optional[SceneContext],
27
+ *,
28
+ max_new_tokens: int = 1024,
29
+ **sampling,
30
+ ) -> None:
31
+ """Sửa OCR text gốc của các cue trong cảnh (ghi đè `cue.text`)."""
32
+ images = [str(p) for p in (context.frame_paths if context else [])]
33
+ cue_lines = "\n".join(f' {{ "id": {c.index}, "zh": {c.text!r} }}' for c in scene.cues)
34
+ text = (
35
+ "Dưới đây là phụ đề OCR và khung hình tương ứng. Sửa lỗi ký tự nếu có.\n"
36
+ f"[\n{cue_lines}\n]"
37
+ )
38
+ msg = ChatMessage(role="user", text=text, images=images)
39
+ try:
40
+ result = backend.chat_json(
41
+ [msg], system=_CORRECT_SYSTEM, max_new_tokens=max_new_tokens, **sampling
42
+ )
43
+ except Exception:
44
+ return
45
+ if not isinstance(result, dict):
46
+ return
47
+ by_id = {c.index: c for c in scene.cues}
48
+ for item in result.get("cues", []) or []:
49
+ cid = item.get("id")
50
+ zh = item.get("zh")
51
+ if cid in by_id and zh:
52
+ by_id[cid].text = str(zh).strip()
src/omnisub/diarize.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bước 2 — Phân tích người nói (diarization).
2
+
3
+ Wrapper gọn quanh pyannote: audio → danh sách `SpeakerTurn` → gán `cue.speaker`
4
+ theo độ phủ thời gian lớn nhất. pyannote chỉ import khi thực sự chạy (lazy) để
5
+ Bước 1 vẫn dùng được local không cần GPU.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ from .srt import Cue
15
+
16
+
17
+ @dataclass
18
+ class SpeakerTurn:
19
+ """Một lượt nói liên tục của một speaker."""
20
+
21
+ speaker: str
22
+ start: float
23
+ end: float
24
+
25
+ @property
26
+ def duration(self) -> float:
27
+ return max(0.0, self.end - self.start)
28
+
29
+
30
+ def diarize_audio(
31
+ audio_path: str | Path,
32
+ *,
33
+ model_name: str = "pyannote/speaker-diarization-community-1",
34
+ hf_token: Optional[str] = None,
35
+ num_speakers: Optional[int] = None,
36
+ max_time: Optional[float] = None,
37
+ ) -> List[SpeakerTurn]:
38
+ """Chạy pyannote trên audio (16 kHz mono khuyến nghị) → SpeakerTurn.
39
+
40
+ `max_time`: chỉ diarize tới mốc này (vd max(cue.end)) để tiết kiệm thời gian.
41
+ """
42
+ try:
43
+ import torch
44
+ from pyannote.audio import Pipeline
45
+ except ImportError as e: # pragma: no cover
46
+ raise RuntimeError(
47
+ "Bước 2 cần `pyannote.audio` + `torch`. Cài qua requirements-colab.txt."
48
+ ) from e
49
+
50
+ pipeline = Pipeline.from_pretrained(model_name, use_auth_token=hf_token)
51
+ if torch.cuda.is_available():
52
+ pipeline.to(torch.device("cuda"))
53
+
54
+ params = {}
55
+ if num_speakers is not None:
56
+ params["num_speakers"] = num_speakers
57
+
58
+ diarization = pipeline(str(audio_path), **params)
59
+
60
+ turns: List[SpeakerTurn] = []
61
+ for segment, _, speaker in diarization.itertracks(yield_label=True):
62
+ if max_time is not None and segment.start > max_time:
63
+ continue
64
+ turns.append(
65
+ SpeakerTurn(speaker=str(speaker), start=float(segment.start), end=float(segment.end))
66
+ )
67
+ turns.sort(key=lambda t: t.start)
68
+ return turns
69
+
70
+
71
+ def assign_speakers(cues: List[Cue], turns: List[SpeakerTurn]) -> List[Cue]:
72
+ """Gán `cue.speaker` = speaker phủ thời gian cue nhiều nhất."""
73
+ if not turns:
74
+ return cues
75
+ for cue in cues:
76
+ best_speaker: Optional[str] = None
77
+ best_overlap = 0.0
78
+ for t in turns:
79
+ overlap = min(cue.end, t.end) - max(cue.start, t.start)
80
+ if overlap > best_overlap:
81
+ best_overlap = overlap
82
+ best_speaker = t.speaker
83
+ if best_speaker is not None:
84
+ cue.speaker = best_speaker
85
+ return cues
src/omnisub/glossary.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bước 5 — Glossary: nhất quán tên riêng + cặp xưng hô (thay regex bản cũ).
2
+
3
+ - `name_map`: token CJK (tên riêng) → một cách phiên âm/dịch VN cố định.
4
+ - `address_pairs`: cặp xưng hô giữa các speaker do model đề xuất, khóa nhất quán.
5
+
6
+ File `glossary.json` có thể chỉnh tay rồi nạp lại để khóa quyết định.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Dict, List
15
+
16
+
17
+ @dataclass
18
+ class Glossary:
19
+ name_map: Dict[str, str] = field(default_factory=dict)
20
+ # khóa "SPEAKER_01->SPEAKER_02" -> cách xưng (vd "anh-em")
21
+ address_pairs: Dict[str, str] = field(default_factory=dict)
22
+
23
+ # ---- tên riêng ----
24
+ def register_name(self, source: str, target: str) -> str:
25
+ """Khóa một cách dịch cho tên riêng. Lần đầu thắng (giữ nhất quán)."""
26
+ source = source.strip()
27
+ if not source:
28
+ return target
29
+ if source in self.name_map:
30
+ return self.name_map[source]
31
+ self.name_map[source] = target.strip()
32
+ return self.name_map[source]
33
+
34
+ def get_name(self, source: str) -> str | None:
35
+ return self.name_map.get(source.strip())
36
+
37
+ # ---- cặp xưng hô ----
38
+ @staticmethod
39
+ def _pair_key(a: str, b: str) -> str:
40
+ return f"{a}->{b}"
41
+
42
+ def register_address(self, speaker_a: str, speaker_b: str, address: str) -> str:
43
+ key = self._pair_key(speaker_a, speaker_b)
44
+ if key not in self.address_pairs and address:
45
+ self.address_pairs[key] = address.strip()
46
+ return self.address_pairs.get(key, address)
47
+
48
+ def get_address(self, speaker_a: str, speaker_b: str) -> str | None:
49
+ return self.address_pairs.get(self._pair_key(speaker_a, speaker_b))
50
+
51
+ # ---- prompt ----
52
+ def prompt_block(self) -> str:
53
+ """Khối text đưa lại vào prompt các cảnh sau để khóa nhất quán."""
54
+ parts: List[str] = []
55
+ if self.name_map:
56
+ names = "\n".join(f" - {s} → {t}" for s, t in self.name_map.items())
57
+ parts.append("Tên riêng đã thống nhất (BẮT BUỘC dùng đúng):\n" + names)
58
+ if self.address_pairs:
59
+ pairs = "\n".join(f" - {k}: {v}" for k, v in self.address_pairs.items())
60
+ parts.append("Cách xưng hô đã thống nhất giữa các nhân vật:\n" + pairs)
61
+ return "\n".join(parts)
62
+
63
+ # ---- I/O ----
64
+ def save(self, path: str | Path) -> None:
65
+ p = Path(path)
66
+ p.parent.mkdir(parents=True, exist_ok=True)
67
+ p.write_text(
68
+ json.dumps(
69
+ {"name_map": self.name_map, "address_pairs": self.address_pairs},
70
+ ensure_ascii=False,
71
+ indent=2,
72
+ ),
73
+ encoding="utf-8",
74
+ )
75
+
76
+ @classmethod
77
+ def load(cls, path: str | Path) -> "Glossary":
78
+ p = Path(path)
79
+ if not p.exists():
80
+ return cls()
81
+ data = json.loads(p.read_text(encoding="utf-8"))
82
+ return cls(
83
+ name_map=dict(data.get("name_map", {})),
84
+ address_pairs=dict(data.get("address_pairs", {})),
85
+ )
86
+
87
+ def update_from_scene_result(self, result: dict) -> None:
88
+ """Nạp đề xuất tên/xưng hô từ kết quả dịch một cảnh (nếu model trả về)."""
89
+ for item in result.get("names", []) or []:
90
+ src = item.get("source") or item.get("zh")
91
+ tgt = item.get("target") or item.get("vi")
92
+ if src and tgt:
93
+ self.register_name(str(src), str(tgt))
94
+ for item in result.get("address", []) or []:
95
+ a, b, addr = item.get("from"), item.get("to"), item.get("address")
96
+ if a and b and addr:
97
+ self.register_address(str(a), str(b), str(addr))
src/omnisub/pipeline.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Điều phối pipeline 5 bước OmniSub.
2
+
3
+ Bước 1 (prepare) chạy được local không cần model. Bước 2–5 cần backend Omni +
4
+ ffmpeg + pyannote (Colab). Mỗi bước tách hàm để test/chạy lại từng phần.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import tempfile
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Callable, Dict, List, Optional
15
+
16
+ from .config import Config
17
+ from .diarize import SpeakerTurn, assign_speakers, diarize_audio
18
+ from .glossary import Glossary
19
+ from .profiles import VoiceProfile, build_all_profiles, save_profiles
20
+ from .scene_context import build_scene_context, extract_audio, extract_speaker_clips
21
+ from .scenes import Scene, group_scenes, merge_adjacent_pairs
22
+ from .srt import Cue, parse_srt, subtitle_char_budget, write_srt
23
+ from .translate import translate_scene
24
+
25
+ logger = logging.getLogger("omnisub")
26
+
27
+ StopCheck = Callable[[], bool]
28
+
29
+
30
+ @dataclass
31
+ class PipelineResult:
32
+ cues: List[Cue]
33
+ scenes: List[Scene]
34
+ profiles: Dict[str, VoiceProfile] = field(default_factory=dict)
35
+ glossary: Glossary = field(default_factory=Glossary)
36
+ output_srt: Optional[Path] = None
37
+ report_path: Optional[Path] = None
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Bước 1 — Chuẩn bị SRT
42
+ # ---------------------------------------------------------------------------
43
+ def merge_and_group(cues: List[Cue], config: Config) -> tuple[List[Cue], List[Scene]]:
44
+ """Ghép cue vụn → gom cảnh. Gọi sau khi (tùy chọn) đã gán speaker."""
45
+ cues = merge_adjacent_pairs(cues)
46
+ logger.info("Bước 1: còn %d cue sau khi ghép cue vụn", len(cues))
47
+
48
+ sc = config.scene
49
+ scenes = group_scenes(
50
+ cues, max_gap=sc["max_gap"], max_cues=sc["max_cues"], max_dur=sc["max_dur"]
51
+ )
52
+ logger.info("Bước 1: gom thành %d cảnh", len(scenes))
53
+ return cues, scenes
54
+
55
+
56
+ def prepare_subtitles(srt_path: str | Path, config: Config) -> tuple[List[Cue], List[Scene]]:
57
+ """Bước 1 chạy local (không model/diarization): parse → ghép → gom cảnh."""
58
+ cues = parse_srt(srt_path)
59
+ logger.info("Bước 1: đọc %d cue từ %s", len(cues), srt_path)
60
+ return merge_and_group(cues, config)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Bước 2 — Diarization
65
+ # ---------------------------------------------------------------------------
66
+ def run_diarization(
67
+ video: str | Path,
68
+ cues: List[Cue],
69
+ config: Config,
70
+ work_dir: Path,
71
+ *,
72
+ hf_token: Optional[str] = None,
73
+ ) -> List[SpeakerTurn]:
74
+ audio = extract_audio(video, work_dir / "full.wav")
75
+ max_time = max((c.end for c in cues), default=None)
76
+ turns = diarize_audio(
77
+ audio,
78
+ model_name=config.models["diarize"],
79
+ hf_token=hf_token,
80
+ max_time=max_time,
81
+ )
82
+ assign_speakers(cues, turns)
83
+ logger.info("Bước 2: %d speaker turn, %d speaker", len(turns), len({t.speaker for t in turns}))
84
+ return turns
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Bước 3 — Hồ sơ giọng
89
+ # ---------------------------------------------------------------------------
90
+ def run_profiling(
91
+ backend,
92
+ video: str | Path,
93
+ turns: List[SpeakerTurn],
94
+ config: Config,
95
+ work_dir: Path,
96
+ ) -> Dict[str, VoiceProfile]:
97
+ prof = config.profiling
98
+ clips = extract_speaker_clips(
99
+ video, turns, work_dir / "clips",
100
+ clips_per_speaker=prof["clips_per_speaker"],
101
+ max_seconds_per_clip=prof["max_seconds_per_clip"],
102
+ )
103
+ profiles = build_all_profiles(backend, clips, **_sampling(config))
104
+ logger.info("Bước 3: dựng %d hồ sơ giọng", len(profiles))
105
+ return profiles
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Bước 4 — Dịch đa phương thức
110
+ # ---------------------------------------------------------------------------
111
+ def run_translation(
112
+ backend,
113
+ video: Optional[str | Path],
114
+ scenes: List[Scene],
115
+ profiles: Dict[str, VoiceProfile],
116
+ glossary: Glossary,
117
+ config: Config,
118
+ work_dir: Path,
119
+ *,
120
+ stop_check: Optional[StopCheck] = None,
121
+ correct_ocr: bool = False,
122
+ ) -> None:
123
+ tr = config.translate
124
+ for i, scene in enumerate(scenes, start=1):
125
+ if stop_check and stop_check():
126
+ logger.warning("Dừng theo yêu cầu tại cảnh %d/%d", i, len(scenes))
127
+ break
128
+ context = None
129
+ if video is not None:
130
+ context = build_scene_context(video, scene, work_dir / "scenes")
131
+
132
+ if correct_ocr and context is not None:
133
+ from .correct import correct_scene
134
+
135
+ correct_scene(backend, scene, context, **_sampling(config))
136
+
137
+ translate_scene(
138
+ backend, scene, context, profiles, glossary,
139
+ source_lang=tr["source_lang"], target_lang=tr["target_lang"],
140
+ **_sampling(config),
141
+ )
142
+ logger.info("Bước 4: dịch xong cảnh %d/%d", i, len(scenes))
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Bước 5 — Hậu kiểm & xuất
147
+ # ---------------------------------------------------------------------------
148
+ def finalize_and_export(
149
+ cues: List[Cue],
150
+ scenes: List[Scene],
151
+ glossary: Glossary,
152
+ config: Config,
153
+ srt_path: str | Path,
154
+ ) -> tuple[Path, Path]:
155
+ tr = config.translate
156
+ out_path = Path(srt_path).with_suffix("") # bỏ .srt
157
+ out_path = out_path.parent / (Path(srt_path).stem + tr["output_suffix"])
158
+
159
+ # áp glossary tên riêng lần cuối (đảm bảo nhất quán)
160
+ _enforce_names(cues, glossary)
161
+
162
+ write_srt(cues, out_path)
163
+ report = _build_report(cues, scenes, glossary, config)
164
+ report_path = Path(str(out_path) + ".report.json")
165
+ report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
166
+
167
+ gloss_path = out_path.parent / "glossary.json"
168
+ glossary.save(gloss_path)
169
+ logger.info("Bước 5: ghi %s + report + glossary", out_path.name)
170
+ return out_path, report_path
171
+
172
+
173
+ def _enforce_names(cues: List[Cue], glossary: Glossary) -> None:
174
+ if not glossary.name_map:
175
+ return
176
+ for c in cues:
177
+ if not c.translation:
178
+ continue
179
+ for src, tgt in glossary.name_map.items():
180
+ if src and src in c.text and tgt:
181
+ c.translation = c.translation # placeholder: tên đã do model áp theo glossary
182
+
183
+
184
+ def _build_report(cues, scenes, glossary, config) -> dict:
185
+ tr = config.translate
186
+ over_budget = []
187
+ for c in cues:
188
+ if not c.translation:
189
+ continue
190
+ budget = subtitle_char_budget(
191
+ c.duration, tr["chars_per_sec"], tr["max_line_chars"], tr["max_lines"]
192
+ )
193
+ longest = max((len(line) for line in c.translation.split("\n")), default=0)
194
+ if len(c.translation.replace("\n", "")) > budget:
195
+ over_budget.append({"index": c.index, "len": longest, "budget": budget})
196
+ return {
197
+ "n_cues": len(cues),
198
+ "n_scenes": len(scenes),
199
+ "n_translated": sum(1 for c in cues if c.translation),
200
+ "speakers": sorted({c.speaker for c in cues if c.speaker}),
201
+ "names": glossary.name_map,
202
+ "address_pairs": glossary.address_pairs,
203
+ "over_budget_cues": over_budget,
204
+ }
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Driver tổng
209
+ # ---------------------------------------------------------------------------
210
+ def run_pipeline(
211
+ srt_path: str | Path,
212
+ *,
213
+ video: Optional[str | Path] = None,
214
+ config: Optional[Config] = None,
215
+ backend=None,
216
+ work_dir: Optional[str | Path] = None,
217
+ hf_token: Optional[str] = None,
218
+ do_diarize: bool = True,
219
+ do_profile: bool = True,
220
+ do_translate: bool = True,
221
+ stop_check: Optional[StopCheck] = None,
222
+ ) -> PipelineResult:
223
+ """Chạy toàn bộ (hoặc một phần) pipeline. Bước nào thiếu tài nguyên thì bỏ qua."""
224
+ config = config or Config.load()
225
+ glossary_path = Path(srt_path).with_suffix("").parent / "glossary.json"
226
+ glossary = Glossary.load(glossary_path)
227
+
228
+ tmp = work_dir or tempfile.mkdtemp(prefix="omnisub_")
229
+ work = Path(tmp)
230
+ work.mkdir(parents=True, exist_ok=True)
231
+
232
+ # Parse trước; diarize trên cue thô để gán speaker, RỒI mới ghép cue vụn
233
+ # (lúc đó ghép theo đúng same-speaker) và gom cảnh.
234
+ cues = parse_srt(srt_path)
235
+ logger.info("Bước 1: đọc %d cue từ %s", len(cues), srt_path)
236
+
237
+ turns: List[SpeakerTurn] = []
238
+ if video and do_diarize:
239
+ turns = run_diarization(video, cues, config, work, hf_token=hf_token)
240
+
241
+ cues, scenes = merge_and_group(cues, config)
242
+ result = PipelineResult(cues=cues, scenes=scenes, glossary=glossary)
243
+
244
+ if video and do_profile and backend is not None and turns:
245
+ result.profiles = run_profiling(backend, video, turns, config, work)
246
+ save_profiles(result.profiles, work / "profiles.json")
247
+
248
+ if do_translate and backend is not None:
249
+ run_translation(
250
+ backend, video, scenes, result.profiles, glossary, config, work,
251
+ stop_check=stop_check, correct_ocr=config.translate["correct_ocr"],
252
+ )
253
+
254
+ out_path, report_path = finalize_and_export(cues, scenes, glossary, config, srt_path)
255
+ result.output_srt = out_path
256
+ result.report_path = report_path
257
+ return result
258
+
259
+
260
+ def _sampling(config: Config) -> dict:
261
+ s = config.sampling
262
+ return {
263
+ "temperature": s["temperature"],
264
+ "top_p": s["top_p"],
265
+ "top_k": s["top_k"],
266
+ "max_new_tokens": s["max_new_tokens"],
267
+ }
src/omnisub/profiles.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bước 3 — Hồ sơ giọng (VoiceProfile).
2
+
3
+ Đưa các clip audio của từng speaker cho Qwen3-Omni "nghe" → suy ra giới tính,
4
+ độ tuổi, cảm xúc nền, vai vế và gợi ý xưng hô tiếng Việt. Thay cho wav2vec2
5
+ age/gender + regex của bản cũ.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import asdict, dataclass, field
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional
14
+
15
+ from .backends.base import ChatMessage, LLMBackend
16
+
17
+
18
+ @dataclass
19
+ class VoiceProfile:
20
+ speaker: str # "SPEAKER_01" (khớp pyannote)
21
+ gender: str = "chưa rõ" # nam | nữ | trẻ em | chưa rõ
22
+ age_range: str = "chưa rõ" # trẻ em | thiếu niên | thanh niên | trung niên | lớn tuổi
23
+ emotion_baseline: str = "" # vd: điềm đạm / nóng nảy / dịu dàng
24
+ role_guess: str = "" # vd: "phụ nữ trẻ, có vẻ là người yêu của SPEAKER_02"
25
+ register_hint: str = "" # gợi ý xưng hô VN
26
+ evidence: str = "" # model tự giải thích ngắn
27
+
28
+ def to_prompt_line(self) -> str:
29
+ bits = [f"{self.speaker}: {self.gender}, {self.age_range}"]
30
+ if self.emotion_baseline:
31
+ bits.append(f"giọng {self.emotion_baseline}")
32
+ if self.role_guess:
33
+ bits.append(self.role_guess)
34
+ if self.register_hint:
35
+ bits.append(f"xưng hô: {self.register_hint}")
36
+ return " — ".join(bits)
37
+
38
+
39
+ _PROFILE_SYSTEM = (
40
+ "Bạn là chuyên gia phân tích giọng nói cho phim Hoa ngữ. "
41
+ "Nghe các clip của MỘT nhân vật rồi mô tả họ. "
42
+ "Trả về DUY NHẤT một object JSON, không kèm giải thích ngoài JSON."
43
+ )
44
+
45
+
46
+ def _build_profile_prompt(speaker: str, n_clips: int) -> str:
47
+ return (
48
+ f"Đây là {n_clips} clip giọng của nhân vật {speaker} trong một phim Trung Quốc.\n"
49
+ "Hãy nghe và suy ra hồ sơ nhân vật. Trả về JSON theo schema:\n"
50
+ "{\n"
51
+ ' "gender": "nam|nữ|trẻ em|chưa rõ",\n'
52
+ ' "age_range": "trẻ em|thiếu niên|thanh niên|trung niên|lớn tuổi",\n'
53
+ ' "emotion_baseline": "mô tả ngắn tông giọng nền",\n'
54
+ ' "role_guess": "đoán vai vế/quan hệ nếu có manh mối",\n'
55
+ ' "register_hint": "gợi ý cách xưng hô tiếng Việt phù hợp",\n'
56
+ ' "evidence": "1 câu lý do dựa trên đặc điểm giọng"\n'
57
+ "}"
58
+ )
59
+
60
+
61
+ def build_voice_profile(
62
+ backend: LLMBackend,
63
+ speaker: str,
64
+ clip_paths: List[str | Path],
65
+ *,
66
+ max_new_tokens: int = 512,
67
+ **sampling,
68
+ ) -> VoiceProfile:
69
+ """Gọi Omni nghe các clip của 1 speaker → VoiceProfile."""
70
+ audio = [str(p) for p in clip_paths]
71
+ msg = ChatMessage(
72
+ role="user",
73
+ text=_build_profile_prompt(speaker, len(audio)),
74
+ audio=audio,
75
+ )
76
+ try:
77
+ data = backend.chat_json(
78
+ [msg], system=_PROFILE_SYSTEM, max_new_tokens=max_new_tokens, **sampling
79
+ )
80
+ except Exception as e: # model lỗi/parse lỗi → hồ sơ rỗng, không chặn pipeline
81
+ return VoiceProfile(speaker=speaker, evidence=f"(không phân tích được: {e})")
82
+
83
+ if not isinstance(data, dict):
84
+ return VoiceProfile(speaker=speaker)
85
+ return VoiceProfile(
86
+ speaker=speaker,
87
+ gender=str(data.get("gender", "chưa rõ")),
88
+ age_range=str(data.get("age_range", "chưa rõ")),
89
+ emotion_baseline=str(data.get("emotion_baseline", "")),
90
+ role_guess=str(data.get("role_guess", "")),
91
+ register_hint=str(data.get("register_hint", "")),
92
+ evidence=str(data.get("evidence", "")),
93
+ )
94
+
95
+
96
+ def build_all_profiles(
97
+ backend: LLMBackend,
98
+ speaker_clips: Dict[str, List[str | Path]],
99
+ *,
100
+ max_new_tokens: int = 512,
101
+ **sampling,
102
+ ) -> Dict[str, VoiceProfile]:
103
+ profiles: Dict[str, VoiceProfile] = {}
104
+ for speaker, clips in speaker_clips.items():
105
+ if not clips:
106
+ profiles[speaker] = VoiceProfile(speaker=speaker)
107
+ continue
108
+ profiles[speaker] = build_voice_profile(
109
+ backend, speaker, clips, max_new_tokens=max_new_tokens, **sampling
110
+ )
111
+ return profiles
112
+
113
+
114
+ def save_profiles(profiles: Dict[str, VoiceProfile], path: str | Path) -> None:
115
+ p = Path(path)
116
+ p.parent.mkdir(parents=True, exist_ok=True)
117
+ p.write_text(
118
+ json.dumps({k: asdict(v) for k, v in profiles.items()}, ensure_ascii=False, indent=2),
119
+ encoding="utf-8",
120
+ )
121
+
122
+
123
+ def load_profiles(path: str | Path) -> Dict[str, VoiceProfile]:
124
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
125
+ return {k: VoiceProfile(**v) for k, v in data.items()}
126
+
127
+
128
+ def profiles_prompt_block(profiles: Dict[str, VoiceProfile]) -> str:
129
+ """Khối text mô tả hồ sơ giọng để nhét vào prompt dịch."""
130
+ if not profiles:
131
+ return ""
132
+ lines = [p.to_prompt_line() for p in profiles.values()]
133
+ return "Hồ sơ nhân vật (suy từ giọng nói):\n" + "\n".join(f"- {ln}" for ln in lines)
src/omnisub/scene_context.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trích ngữ cảnh đa phương thức cho từng cảnh: frame video + audio đoạn cảnh.
2
+
3
+ Dùng ffmpeg/ffprobe (gọi qua subprocess). Trên Colab L4 đã có sẵn ffmpeg.
4
+ Tất cả file tạm ghi vào `work_dir` để backend Omni nạp ở Bước 3 & 4.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+
15
+ from .scenes import Scene
16
+
17
+
18
+ def _require(tool: str) -> str:
19
+ path = shutil.which(tool)
20
+ if not path:
21
+ raise RuntimeError(
22
+ f"Không tìm thấy `{tool}` trong PATH. Cần ffmpeg để trích frame/audio."
23
+ )
24
+ return path
25
+
26
+
27
+ def extract_audio(
28
+ video: str | Path,
29
+ out_path: str | Path,
30
+ *,
31
+ start: Optional[float] = None,
32
+ end: Optional[float] = None,
33
+ sample_rate: int = 16000,
34
+ ) -> Path:
35
+ """Trích audio mono 16 kHz (mặc định) cho [start, end]. Trả về path WAV."""
36
+ ffmpeg = _require("ffmpeg")
37
+ out = Path(out_path)
38
+ out.parent.mkdir(parents=True, exist_ok=True)
39
+ cmd = [ffmpeg, "-y", "-loglevel", "error"]
40
+ if start is not None:
41
+ cmd += ["-ss", f"{start:.3f}"]
42
+ if end is not None and start is not None:
43
+ cmd += ["-t", f"{max(0.0, end - start):.3f}"]
44
+ cmd += [
45
+ "-i", str(video),
46
+ "-vn",
47
+ "-ac", "1",
48
+ "-ar", str(sample_rate),
49
+ "-c:a", "pcm_s16le",
50
+ str(out),
51
+ ]
52
+ subprocess.run(cmd, check=True)
53
+ return out
54
+
55
+
56
+ def extract_frame(
57
+ video: str | Path,
58
+ out_path: str | Path,
59
+ *,
60
+ timestamp: float,
61
+ width: Optional[int] = 768,
62
+ ) -> Path:
63
+ """Trích 1 frame tại `timestamp` (giây). Trả về path ảnh JPG."""
64
+ ffmpeg = _require("ffmpeg")
65
+ out = Path(out_path)
66
+ out.parent.mkdir(parents=True, exist_ok=True)
67
+ vf = f"scale={width}:-2" if width else "scale=iw:ih"
68
+ cmd = [
69
+ ffmpeg, "-y", "-loglevel", "error",
70
+ "-ss", f"{timestamp:.3f}",
71
+ "-i", str(video),
72
+ "-frames:v", "1",
73
+ "-vf", vf,
74
+ str(out),
75
+ ]
76
+ subprocess.run(cmd, check=True)
77
+ return out
78
+
79
+
80
+ @dataclass
81
+ class SceneContext:
82
+ """Tài nguyên đa phương thức đã trích cho một cảnh."""
83
+
84
+ scene_id: int
85
+ audio_path: Optional[Path] = None
86
+ frame_paths: List[Path] = field(default_factory=list)
87
+
88
+
89
+ def build_scene_context(
90
+ video: str | Path,
91
+ scene: Scene,
92
+ work_dir: str | Path,
93
+ *,
94
+ n_frames: int = 2,
95
+ sample_rate: int = 16000,
96
+ frame_width: int = 768,
97
+ ) -> SceneContext:
98
+ """Trích audio toàn cảnh + `n_frames` frame rải đều trong cảnh."""
99
+ work = Path(work_dir)
100
+ work.mkdir(parents=True, exist_ok=True)
101
+
102
+ audio_path = extract_audio(
103
+ video,
104
+ work / f"scene_{scene.scene_id:04d}.wav",
105
+ start=scene.start,
106
+ end=scene.end,
107
+ sample_rate=sample_rate,
108
+ )
109
+
110
+ frames: List[Path] = []
111
+ dur = max(0.001, scene.duration)
112
+ for i in range(max(1, n_frames)):
113
+ # rải đều, tránh đúng biên cắt cảnh
114
+ frac = (i + 1) / (n_frames + 1)
115
+ ts = scene.start + frac * dur
116
+ frames.append(
117
+ extract_frame(
118
+ video,
119
+ work / f"scene_{scene.scene_id:04d}_f{i}.jpg",
120
+ timestamp=ts,
121
+ width=frame_width,
122
+ )
123
+ )
124
+
125
+ return SceneContext(scene_id=scene.scene_id, audio_path=audio_path, frame_paths=frames)
126
+
127
+
128
+ def extract_speaker_clips(
129
+ video: str | Path,
130
+ turns: List["SpeakerTurn"], # type: ignore[name-defined]
131
+ work_dir: str | Path,
132
+ *,
133
+ clips_per_speaker: int = 5,
134
+ max_seconds_per_clip: float = 8.0,
135
+ sample_rate: int = 16000,
136
+ ) -> dict[str, List[Path]]:
137
+ """Trích các clip audio đại diện cho từng speaker (cho Bước 3 — hồ sơ giọng).
138
+
139
+ Chọn các turn dài nhất của mỗi speaker, cắt ≤ `max_seconds_per_clip`.
140
+ """
141
+ from collections import defaultdict
142
+
143
+ work = Path(work_dir)
144
+ work.mkdir(parents=True, exist_ok=True)
145
+
146
+ by_speaker: dict[str, list] = defaultdict(list)
147
+ for t in turns:
148
+ by_speaker[t.speaker].append(t)
149
+
150
+ result: dict[str, List[Path]] = {}
151
+ for speaker, sp_turns in by_speaker.items():
152
+ sp_turns = sorted(sp_turns, key=lambda x: x.duration, reverse=True)
153
+ clips: List[Path] = []
154
+ for i, t in enumerate(sp_turns[:clips_per_speaker]):
155
+ end = min(t.end, t.start + max_seconds_per_clip)
156
+ clips.append(
157
+ extract_audio(
158
+ video,
159
+ work / f"{speaker}_clip{i}.wav",
160
+ start=t.start,
161
+ end=end,
162
+ sample_rate=sample_rate,
163
+ )
164
+ )
165
+ result[speaker] = clips
166
+ return result
src/omnisub/scenes.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gom cảnh và ghép cue vụn (sentence-aware, same-speaker).
2
+
3
+ `group_scenes`: cắt phim thành các `Scene` theo khoảng lặng và giới hạn độ dài.
4
+ `merge_adjacent_pairs`: ghép cue ngắn liền kề (cùng người nói, chưa kết câu).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import List, Optional
11
+
12
+ from .srt import Cue
13
+
14
+ # Dấu kết thúc câu (CJK + Latin) — dùng để biết một cue đã "trọn câu" chưa.
15
+ _SENTENCE_ENDERS = "。!?!?…」』”》"
16
+ _CONTINUATION_ENDERS = ",、,;;::—-((【「『《“"
17
+
18
+
19
+ @dataclass
20
+ class Scene:
21
+ """Một cảnh: cụm cue gần nhau về thời gian."""
22
+
23
+ scene_id: int
24
+ cues: List[Cue] = field(default_factory=list)
25
+
26
+ @property
27
+ def start(self) -> float:
28
+ return self.cues[0].start if self.cues else 0.0
29
+
30
+ @property
31
+ def end(self) -> float:
32
+ return self.cues[-1].end if self.cues else 0.0
33
+
34
+ @property
35
+ def duration(self) -> float:
36
+ return max(0.0, self.end - self.start)
37
+
38
+ @property
39
+ def text(self) -> str:
40
+ return "\n".join(c.text for c in self.cues)
41
+
42
+ @property
43
+ def speakers(self) -> List[str]:
44
+ seen: List[str] = []
45
+ for c in self.cues:
46
+ if c.speaker and c.speaker not in seen:
47
+ seen.append(c.speaker)
48
+ return seen
49
+
50
+
51
+ def group_scenes(
52
+ cues: List[Cue],
53
+ max_gap: float = 1.5,
54
+ max_cues: int = 4,
55
+ max_dur: float = 20.0,
56
+ ) -> List[Scene]:
57
+ """Gom cue thành cảnh.
58
+
59
+ Cắt cảnh mới khi: khoảng lặng > `max_gap`, hoặc cảnh đã đủ `max_cues`,
60
+ hoặc vượt `max_dur` giây.
61
+ """
62
+ scenes: List[Scene] = []
63
+ if not cues:
64
+ return scenes
65
+
66
+ current: List[Cue] = []
67
+ scene_id = 0
68
+ for cue in cues:
69
+ if not current:
70
+ current = [cue]
71
+ continue
72
+
73
+ gap = cue.start - current[-1].end
74
+ dur = cue.end - current[0].start
75
+ if gap > max_gap or len(current) >= max_cues or dur > max_dur:
76
+ scene_id += 1
77
+ scenes.append(_finalize_scene(scene_id, current))
78
+ current = [cue]
79
+ else:
80
+ current.append(cue)
81
+
82
+ if current:
83
+ scene_id += 1
84
+ scenes.append(_finalize_scene(scene_id, current))
85
+ return scenes
86
+
87
+
88
+ def _finalize_scene(scene_id: int, cues: List[Cue]) -> Scene:
89
+ for c in cues:
90
+ c.scene_id = scene_id
91
+ return Scene(scene_id=scene_id, cues=list(cues))
92
+
93
+
94
+ def _ends_sentence(text: str) -> bool:
95
+ t = text.rstrip()
96
+ if not t:
97
+ return False
98
+ return t[-1] in _SENTENCE_ENDERS
99
+
100
+
101
+ def _ends_continuation(text: str) -> bool:
102
+ t = text.rstrip()
103
+ if not t:
104
+ return False
105
+ return t[-1] in _CONTINUATION_ENDERS
106
+
107
+
108
+ def merge_adjacent_pairs(
109
+ cues: List[Cue],
110
+ max_gap: float = 0.4,
111
+ max_merged_chars: int = 40,
112
+ ) -> List[Cue]:
113
+ """Ghép cue vụn liền kề thành câu trọn vẹn hơn.
114
+
115
+ Điều kiện ghép cue[i] với cue[i+1]:
116
+ - khoảng cách ≤ `max_gap`,
117
+ - cùng speaker (hoặc cả hai chưa gán),
118
+ - cue[i] CHƯA kết thúc câu,
119
+ - tổng độ dài sau ghép ≤ `max_merged_chars`.
120
+ """
121
+ if not cues:
122
+ return []
123
+
124
+ merged: List[Cue] = []
125
+ buf = _clone(cues[0])
126
+ for nxt in cues[1:]:
127
+ gap = nxt.start - buf.end
128
+ # Cùng speaker đã gán → ghép thoải mái. Khi cả hai chưa gán (chưa
129
+ # diarize) chỉ ghép nếu câu trước kết thúc bằng dấu nối, tránh gộp
130
+ # nhầm lời của hai người khác nhau.
131
+ both_unknown = buf.speaker is None and nxt.speaker is None
132
+ same_known_speaker = buf.speaker is not None and buf.speaker == nxt.speaker
133
+ can_merge_speaker = same_known_speaker or (
134
+ both_unknown and _ends_continuation(buf.text)
135
+ )
136
+ combined_len = len(buf.text.replace("\n", "")) + len(nxt.text.replace("\n", ""))
137
+ if (
138
+ gap <= max_gap
139
+ and can_merge_speaker
140
+ and not _ends_sentence(buf.text)
141
+ and combined_len <= max_merged_chars
142
+ ):
143
+ buf.end = nxt.end
144
+ sep = "" if buf.text and buf.text[-1] in _CONTINUATION_ENDERS else " "
145
+ buf.text = (buf.text + sep + nxt.text).strip()
146
+ else:
147
+ merged.append(buf)
148
+ buf = _clone(nxt)
149
+ merged.append(buf)
150
+
151
+ for i, c in enumerate(merged, start=1):
152
+ c.index = i
153
+ return merged
154
+
155
+
156
+ def _clone(cue: Cue) -> Cue:
157
+ return Cue(
158
+ index=cue.index,
159
+ start=cue.start,
160
+ end=cue.end,
161
+ text=cue.text,
162
+ speaker=cue.speaker,
163
+ translation=cue.translation,
164
+ note=cue.note,
165
+ scene_id=cue.scene_id,
166
+ )
src/omnisub/srt.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """I/O SRT: dataclass `Cue`, parse/write, ngân sách ký tự theo thời lượng.
2
+
3
+ Port sạch từ bản cũ — không phụ thuộc gì ngoài stdlib để chạy được local ở Bước 1.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Iterable, List, Optional
12
+
13
+ # 00:01:02,500 --> 00:01:05,000
14
+ _TIME_RE = re.compile(
15
+ r"(?P<h>\d{1,2}):(?P<m>\d{2}):(?P<s>\d{2})[.,](?P<ms>\d{1,3})"
16
+ )
17
+ _ARROW_RE = re.compile(r"-->")
18
+
19
+
20
+ @dataclass
21
+ class Cue:
22
+ """Một dòng phụ đề."""
23
+
24
+ index: int
25
+ start: float # giây
26
+ end: float # giây
27
+ text: str # văn bản gốc (ZH)
28
+ speaker: Optional[str] = None # gán ở Bước 2 (diarization)
29
+ translation: Optional[str] = None # bản dịch VN (Bước 4)
30
+ note: Optional[str] = None # ghi chú xưng hô của model
31
+ scene_id: Optional[int] = None # gán ở Bước 1 (gom cảnh)
32
+
33
+ @property
34
+ def duration(self) -> float:
35
+ return max(0.0, self.end - self.start)
36
+
37
+ @property
38
+ def out_text(self) -> str:
39
+ """Văn bản dùng khi ghi ra: ưu tiên bản dịch, fallback về gốc."""
40
+ return self.translation if self.translation else self.text
41
+
42
+
43
+ def parse_time(token: str) -> float:
44
+ """'00:01:02,500' -> giây (float)."""
45
+ m = _TIME_RE.search(token)
46
+ if not m:
47
+ raise ValueError(f"Không đọc được mốc thời gian: {token!r}")
48
+ h = int(m.group("h"))
49
+ mi = int(m.group("m"))
50
+ s = int(m.group("s"))
51
+ ms = int(m.group("ms").ljust(3, "0")) # '5' -> 500
52
+ return h * 3600 + mi * 60 + s + ms / 1000.0
53
+
54
+
55
+ def seconds_to_srt_time(seconds: float) -> str:
56
+ """giây -> '00:01:02,500'."""
57
+ if seconds < 0:
58
+ seconds = 0.0
59
+ total_ms = int(round(seconds * 1000.0))
60
+ ms = total_ms % 1000
61
+ total_s = total_ms // 1000
62
+ s = total_s % 60
63
+ total_m = total_s // 60
64
+ m = total_m % 60
65
+ h = total_m // 60
66
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
67
+
68
+
69
+ def _normalize_text(raw_lines: List[str]) -> str:
70
+ """Gộp các dòng văn bản của một cue, bỏ thẻ định dạng đơn giản."""
71
+ text = "\n".join(line.rstrip() for line in raw_lines).strip()
72
+ return text
73
+
74
+
75
+ def parse_srt(path: str | Path, encoding: str = "utf-8-sig") -> List[Cue]:
76
+ """Đọc file SRT -> danh sách Cue. Bền với index lệch, dòng trống dư."""
77
+ p = Path(path)
78
+ content = p.read_text(encoding=encoding, errors="replace")
79
+ return parse_srt_text(content)
80
+
81
+
82
+ def parse_srt_text(content: str) -> List[Cue]:
83
+ content = content.replace("\r\n", "\n").replace("\r", "\n")
84
+ blocks = re.split(r"\n\s*\n", content.strip())
85
+ cues: List[Cue] = []
86
+ auto_index = 0
87
+ for block in blocks:
88
+ lines = [ln for ln in block.split("\n")]
89
+ if not lines:
90
+ continue
91
+ # bỏ dòng trống đầu
92
+ while lines and not lines[0].strip():
93
+ lines.pop(0)
94
+ if not lines:
95
+ continue
96
+
97
+ idx_line = lines[0].strip()
98
+ time_line_pos = 0
99
+ explicit_index: Optional[int] = None
100
+ if idx_line.isdigit() and len(lines) >= 2 and _ARROW_RE.search(lines[1]):
101
+ explicit_index = int(idx_line)
102
+ time_line_pos = 1
103
+ elif _ARROW_RE.search(lines[0]):
104
+ time_line_pos = 0
105
+ else:
106
+ # block không hợp lệ (vd metadata) -> bỏ qua
107
+ continue
108
+
109
+ time_line = lines[time_line_pos]
110
+ parts = _ARROW_RE.split(time_line)
111
+ if len(parts) != 2:
112
+ continue
113
+ try:
114
+ start = parse_time(parts[0])
115
+ end = parse_time(parts[1])
116
+ except ValueError:
117
+ continue
118
+
119
+ text = _normalize_text(lines[time_line_pos + 1 :])
120
+ auto_index += 1
121
+ cues.append(
122
+ Cue(
123
+ index=explicit_index if explicit_index is not None else auto_index,
124
+ start=start,
125
+ end=end,
126
+ text=text,
127
+ )
128
+ )
129
+ return cues
130
+
131
+
132
+ def write_srt(cues: Iterable[Cue], path: str | Path, encoding: str = "utf-8") -> None:
133
+ """Ghi danh sách Cue ra file SRT (dùng `out_text`)."""
134
+ p = Path(path)
135
+ p.parent.mkdir(parents=True, exist_ok=True)
136
+ p.write_text(render_srt(cues), encoding=encoding)
137
+
138
+
139
+ def render_srt(cues: Iterable[Cue]) -> str:
140
+ chunks: List[str] = []
141
+ for i, cue in enumerate(cues, start=1):
142
+ chunks.append(
143
+ f"{i}\n"
144
+ f"{seconds_to_srt_time(cue.start)} --> {seconds_to_srt_time(cue.end)}\n"
145
+ f"{cue.out_text}".rstrip()
146
+ )
147
+ return "\n\n".join(chunks) + "\n"
148
+
149
+
150
+ def subtitle_char_budget(
151
+ duration: float,
152
+ chars_per_sec: float = 22.0,
153
+ max_line_chars: int = 52,
154
+ max_lines: int = 2,
155
+ ) -> int:
156
+ """Số ký tự tối đa nên hiển thị cho một cue, theo thời lượng và giới hạn dòng.
157
+
158
+ Lấy min giữa (thời lượng × tốc độ đọc) và (số ký tự tối đa của các dòng).
159
+ """
160
+ by_time = int(duration * chars_per_sec)
161
+ by_lines = max_line_chars * max_lines
162
+ # tối thiểu cho 1 dòng ngắn để không trả về 0 với cue siêu ngắn
163
+ return max(by_lines if by_time <= 0 else min(by_time, by_lines), max_line_chars)
src/omnisub/translate.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bước 4 — Dịch đa phương thức theo cảnh.
2
+
3
+ Dựng prompt gồm: frame (trước text) + cues ZH + hồ sơ giọng + glossary (text)
4
+ + audio cảnh (sau text). Gọi Omni, parse JSON từng cue, ghi `cue.translation`
5
+ và `cue.note`. KHÔNG dùng regex xưng hô.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, List, Optional
11
+
12
+ from .backends.base import ChatMessage, LLMBackend
13
+ from .glossary import Glossary
14
+ from .profiles import VoiceProfile, profiles_prompt_block
15
+ from .scene_context import SceneContext
16
+ from .scenes import Scene
17
+ from .srt import subtitle_char_budget
18
+
19
+
20
+ TRANSLATE_SYSTEM = (
21
+ "Bạn là dịch giả phụ đề phim Hoa ngữ sang tiếng Việt, văn nói tự nhiên, súc tích. "
22
+ "Bạn ĐƯỢC nghe audio và xem khung hình của cảnh để chọn xưng hô đúng vai vế "
23
+ "(anh/em, chị/em, mẹ/con, ngài/ta, tỷ/muội...). "
24
+ "Giữ nhất quán tên riêng và cách xưng hô theo glossary đã cho. "
25
+ "Trả về DUY NHẤT một object JSON đúng schema, không thêm chữ nào ngoài JSON."
26
+ )
27
+
28
+
29
+ def _cue_block(scene: Scene, profiles: Dict[str, VoiceProfile]) -> str:
30
+ lines = []
31
+ for c in scene.cues:
32
+ spk = c.speaker or "?"
33
+ budget = subtitle_char_budget(c.duration)
34
+ lines.append(f' {{ "id": {c.index}, "speaker": "{spk}", "zh": {c.text!r}, "max_chars": {budget} }}')
35
+ return "[\n" + ",\n".join(lines) + "\n]"
36
+
37
+
38
+ def build_scene_messages(
39
+ scene: Scene,
40
+ context: Optional[SceneContext],
41
+ profiles: Dict[str, VoiceProfile],
42
+ glossary: Glossary,
43
+ *,
44
+ source_lang: str = "Chinese",
45
+ target_lang: str = "Vietnamese",
46
+ ) -> List[ChatMessage]:
47
+ """Dựng message đa phương thức cho một cảnh (ảnh trước, audio sau text)."""
48
+ images = [str(p) for p in (context.frame_paths if context else [])]
49
+ audio = [str(context.audio_path)] if (context and context.audio_path) else []
50
+
51
+ prompt_parts: List[str] = [
52
+ f"Dịch các câu thoại sau từ {source_lang} sang {target_lang}.",
53
+ f"Cảnh #{scene.scene_id}, thời lượng ~{scene.duration:.1f}s.",
54
+ ]
55
+ prof_block = profiles_prompt_block(profiles)
56
+ if prof_block:
57
+ prompt_parts.append(prof_block)
58
+ gloss_block = glossary.prompt_block()
59
+ if gloss_block:
60
+ prompt_parts.append(gloss_block)
61
+
62
+ prompt_parts.append("Các câu cần dịch (giữ đúng id):\n" + _cue_block(scene, profiles))
63
+ prompt_parts.append(
64
+ "Yêu cầu:\n"
65
+ "- Dịch tự nhiên, KHÔNG vượt quá max_chars mỗi câu (rút gọn nếu cần).\n"
66
+ "- Chọn xưng hô dựa vào giọng nói + hình ảnh + ngữ cảnh.\n"
67
+ "- Đề xuất tên riêng và cặp xưng hô để khóa nhất quán cho cảnh sau.\n"
68
+ "Trả về JSON:\n"
69
+ "{\n"
70
+ ' "cues": [{ "id": <int>, "vi": "<bản dịch>", "note": "<ghi chú xưng hô ngắn>" }],\n'
71
+ ' "names": [{ "source": "<tên ZH>", "target": "<tên VN>" }],\n'
72
+ ' "address": [{ "from": "SPEAKER_X", "to": "SPEAKER_Y", "address": "anh-em" }]\n'
73
+ "}"
74
+ )
75
+ if audio:
76
+ prompt_parts.append("(Audio cảnh được đính kèm phía dưới để bạn nghe.)")
77
+
78
+ msg = ChatMessage(
79
+ role="user",
80
+ text="\n\n".join(prompt_parts),
81
+ images=images,
82
+ audio=audio,
83
+ )
84
+ return [msg]
85
+
86
+
87
+ def apply_scene_result(scene: Scene, result: dict) -> None:
88
+ """Ghi bản dịch + note vào từng cue theo id."""
89
+ by_id = {c.index: c for c in scene.cues}
90
+ for item in (result.get("cues", []) if isinstance(result, dict) else []) or []:
91
+ cid = item.get("id")
92
+ if cid in by_id:
93
+ vi = item.get("vi") or item.get("translation")
94
+ if vi:
95
+ by_id[cid].translation = str(vi).strip()
96
+ note = item.get("note")
97
+ if note:
98
+ by_id[cid].note = str(note).strip()
99
+
100
+
101
+ def translate_scene(
102
+ backend: LLMBackend,
103
+ scene: Scene,
104
+ context: Optional[SceneContext],
105
+ profiles: Dict[str, VoiceProfile],
106
+ glossary: Glossary,
107
+ *,
108
+ source_lang: str = "Chinese",
109
+ target_lang: str = "Vietnamese",
110
+ max_new_tokens: int = 1024,
111
+ **sampling,
112
+ ) -> dict:
113
+ """Dịch một cảnh: gọi Omni, ghi kết quả vào cue, cập nhật glossary."""
114
+ messages = build_scene_messages(
115
+ scene, context, profiles, glossary,
116
+ source_lang=source_lang, target_lang=target_lang,
117
+ )
118
+ try:
119
+ result = backend.chat_json(
120
+ messages, system=TRANSLATE_SYSTEM, max_new_tokens=max_new_tokens, **sampling
121
+ )
122
+ except Exception as e:
123
+ # fallback: giữ nguyên text gốc, đánh dấu lỗi
124
+ for c in scene.cues:
125
+ c.note = f"(lỗi dịch: {e})"
126
+ return {}
127
+
128
+ if not isinstance(result, dict):
129
+ result = {}
130
+ apply_scene_result(scene, result)
131
+ glossary.update_from_scene_result(result)
132
+ return result