Upload OmniSub source
Browse files- .gitignore +31 -0
- PLAN.md +186 -0
- README.md +60 -0
- colab/OmniSub_Colab.ipynb +183 -0
- config.yaml +31 -0
- pyproject.toml +36 -0
- requirements-colab.txt +23 -0
- samples/demo.zh.srt +20 -0
- src/omnisub/__init__.py +3 -0
- src/omnisub/backends/__init__.py +5 -0
- src/omnisub/backends/base.py +134 -0
- src/omnisub/backends/transformers_qwen.py +167 -0
- src/omnisub/cli.py +137 -0
- src/omnisub/config.py +99 -0
- src/omnisub/correct.py +52 -0
- src/omnisub/diarize.py +85 -0
- src/omnisub/glossary.py +97 -0
- src/omnisub/pipeline.py +267 -0
- src/omnisub/profiles.py +133 -0
- src/omnisub/scene_context.py +166 -0
- src/omnisub/scenes.py +166 -0
- src/omnisub/srt.py +163 -0
- src/omnisub/translate.py +132 -0
.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
|