DRIPPY4 / app /core /cloud_ocr.py
hoangtaiii's picture
Fix 2p->20s + 20 bugs: pad audio, OCR/ASR coverage gates, TTS cache/placeholder, omni cloud, ASS escape, timeout, preflight, pool locks, SSRF guards
df03342 verified
Raw History Blame Contribute Delete
28.6 kB
"""
app/core/cloud_ocr.py
──────────────────────
High-Performance Parallel Cloud OCR Engine.
Uses ThreadPoolExecutor (8 workers) + Dynamic Model Pinning + 5s fast timeouts.
Processes a 90-second video in ~6-10 seconds instead of 1000s.
"""
import os
import cv2
import json
import base64
import time
import requests
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict, Optional, Tuple, Callable
class CloudOCREngine:
def __init__(
self,
gemini_keys: Optional[List[str]] = None,
openrouter_keys: Optional[List[str]] = None,
groq_keys: Optional[List[str]] = None,
nvidia_keys: Optional[List[str]] = None,
xkiro_keys: Optional[List[str]] = None,
log_fn: Optional[Callable[[str], None]] = None
):
self.log_fn = log_fn or print
self.gemini_keys = gemini_keys or self._load_keys("GEMINI_KEY")
self.openrouter_keys = openrouter_keys or self._load_keys("OPENROUTER_KEY")
self.groq_keys = groq_keys or self._load_keys("GROQ_KEY")
self.nvidia_keys = nvidia_keys or self._load_keys("NVIDIA_KEY")
self.xkiro_keys = xkiro_keys or self._load_keys("XKIRO_KEY")
# 5x5 Vision Matrix — VERIFIED LIVE 2026-09-02 (probe_ocr.py)
# Tất cả model cũ (gemini-1.5/2.0/2.5, llama-3.2-vision-preview, neva, kosmos…) đã DEAD/404/decommissioned.
# Matrix mới chỉ chứa model đã test batch 6 ảnh thực tế và trả về 200 OK.
self.matrix = {
"gemini": [
"gemini-3.1-flash-lite", # ✅ batch6 2.0s - FASTEST, ưu tiên #1
"gemini-flash-latest", # ✅ alias - stable fallback
"gemini-3-flash-preview", # ✅ batch6 3.3s - best quality nhưng thỉnh thoảng timeout
"gemma-4-31b-it", # ✅ batch6 8.2s - chậm nhưng free
"gemma-4-26b-a4b-it", # ✅ Gemma fallback
],
"openrouter": [
"dots-studio/dots-3-note-preview:free", # ✅ long prompt OK, reasoning tốt
"minimax/minimax-m3:free", # ✅ 2.8s BEST nhưng đôi khi cache empty
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", # ✅ 2.4s
"google/gemma-4-26b-a4b-it:free", # ✅ 5/6 entries - có lúc OK
"google/gemma-4-31b-it:free", # ⚠️ 429
],
"groq": [
"qwen/qwen3.8-27b", # ✅ vision, limit 3 images/batch
"qwen/qwen3.6-27b", # ✅ limit 3
"qwen/qwen3.8-27b",
"qwen/qwen3.6-27b",
"qwen/qwen3.8-27b",
],
"nvidia": [
"meta/llama-3.2-11b-vision-instruct", # ✅ DUY NHẤT LIVE, limit 1 image/batch
"meta/llama-3.2-11b-vision-instruct",
"meta/llama-3.2-11b-vision-instruct",
"meta/llama-3.2-11b-vision-instruct",
"meta/llama-3.2-11b-vision-instruct",
],
"xkiro": [
"minimax/minimax-m3:free", # ✅ 6/6 long prompt OK - BEST XKIRO
"mistralai/mistral-small-2603", # ⚠️ 6 empty strings với long prompt - fallback
"qwen/qwen3-vl-plus:free", # ⚠️ [""] empty - last resort
"qwen/qwen3.5-flash:free", # ⚠️ []
"qwen/qwen3.6-plus:free",
]
}
# Image limits per provider (verified): groq=3, nvidia=1, others big
self._image_limits = {
"gemini": 16,
"openrouter": 10,
"groq": 3,
"nvidia": 1,
"xkiro": 10,
}
self._working_provider_model = None
def _log(self, msg: str):
self.log_fn(f"[Cloud OCR] {msg}")
def _load_keys(self, prefix: str) -> List[str]:
keys = []
for k, v in os.environ.items():
if k.startswith(prefix) and v.strip():
keys.append(v.strip())
env_path = Path(__file__).resolve().parents[2] / ".env"
if env_path.exists():
try:
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip("'\"")
if k.startswith(prefix) and v and v not in keys:
keys.append(v)
except Exception:
pass
return keys
def _build_ocr_prompt(self, source_lang: str = "zh") -> str:
"""
Build language-prioritized OCR prompt.
source_lang: 'zh' = Chinese-first, 'en' = English-first.
This is the fix for user request: OCR must prioritize original language.
"""
lang = (source_lang or "zh").lower().strip()
is_zh = lang.startswith("zh") or "trung" in lang
if is_zh:
return (
"You are a high-precision OCR engine for CHINESE video subtitles.\n"
"PRIORITY LANGUAGE: Simplified/Traditional Chinese (zh). Focus MAXIMUM on Chinese characters.\n"
"If the frame is bilingual (Chinese + English), you MUST prioritize and return CHINESE text first; "
"English is secondary and only included if it is part of the Chinese subtitle line.\n"
"RULES:\n"
"1. Transcribe COMPLETE Chinese text accurately without missing any characters at start/middle/end. Chinese punctuation and full sentences are critical.\n"
"2. Preserve brand names, numbers, hashtags inside the line verbatim.\n"
"3. If multiple lines appear in the crop, join them into ONE coherent Chinese sentence with spaces.\n"
"4. Do NOT translate - output ORIGINAL Chinese as seen, not Vietnamese/English.\n"
"5. Output a JSON array of strings: [\"text1\", \"text2\", ...] in exact frame order. Return \"\" for frames with no Chinese subtitle.\n"
"6. OCR must be CHINESE-FIRST: if you see both Chinese and English, output Chinese. Only if frame has ZERO Chinese, you may output English.\n"
)
else:
return (
"You are a high-precision OCR engine for ENGLISH video subtitles.\n"
"PRIORITY LANGUAGE: English (en). Focus MAXIMUM on English text.\n"
"If the frame is bilingual (English + Chinese), you MUST prioritize and return ENGLISH text first.\n"
"RULES:\n"
"1. Transcribe COMPLETE English text accurately without missing any words at start/middle/end.\n"
"2. Preserve brand names, numbers, hashtags, capitalization verbatim.\n"
"3. If multiple lines appear in the crop, join them into ONE coherent English sentence.\n"
"4. Do NOT translate - output ORIGINAL English as seen.\n"
"5. Output a JSON array of strings: [\"text1\", \"text2\", ...] in exact frame order. Return \"\" for frames with no English subtitle.\n"
"6. OCR must be ENGLISH-FIRST: if you see both Chinese and English, output English. Only if frame has ZERO English, you may fallback to Chinese.\n"
)
def scan_video_subtitles_to_srt(
self,
video_path: str,
output_srt_path: str,
blur_region: Optional[Tuple[int, int, int, int]] = None,
sample_fps: float = 2.5,
source_lang: str = "zh"
) -> bool:
video_file = Path(video_path)
if not video_file.exists():
self._log(f"❌ Video not found: {video_path}")
return False
cap = cv2.VideoCapture(str(video_file))
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration_sec = total_frames / fps if fps > 0 else 0
frame_step = max(1, int(fps / sample_fps))
self._log(f"🎞️ Sampling video at {sample_fps} FPS (total ~{int(duration_sec * sample_fps)} frames)...")
sampled_frames = [] # (timestamp_sec, base64_jpeg)
frame_idx = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
if frame_idx % frame_step == 0:
cur_sec = frame_idx / fps
if blur_region and blur_region[2] > 0 and blur_region[3] > 0:
x, y, w, h = blur_region
fh, fw = frame.shape[:2]
# Add 6% safety margin padding to avoid slicing text at edges
pad_x = int(w * 0.06)
pad_y = int(h * 0.08)
x1 = max(0, min(fw - 1, int(x - pad_x)))
y1 = max(0, min(fh - 1, int(y - pad_y)))
x2 = max(x1 + 1, min(fw, int(x + w + pad_x)))
y2 = max(y1 + 1, min(fh, int(y + h + pad_y)))
cropped = frame[y1:y2, x1:x2]
else:
fh, fw = frame.shape[:2]
cropped = frame[int(fh * 0.70):fh, 0:fw]
# ── Preprocess for OCR: upscale small crops (Chinese chars need >800px width for Vision models) ──
# Upscale 2x if crop too small to preserve stroke detail
h_crop, w_crop = cropped.shape[:2]
if h_crop < 220 or w_crop < 700:
scale = max(2.0, 700 / max(1, w_crop), 220 / max(1, h_crop))
scale = min(scale, 3.0)
new_w = int(w_crop * scale)
new_h = int(h_crop * scale)
cropped = cv2.resize(cropped, (new_w, new_h), interpolation=cv2.INTER_CUBIC)
# Light sharpen to enhance text edges after upscale
try:
# Unsharp mask: 1.5*orig - 0.5*blur
blurred = cv2.GaussianBlur(cropped, (0, 0), 1.0)
cropped = cv2.addWeighted(cropped, 1.5, blurred, -0.5, 0)
except Exception:
pass
# High quality JPEG (90) for accurate character recognition
success, buffer = cv2.imencode(".jpg", cropped, [cv2.IMWRITE_JPEG_QUALITY, 90])
if success:
b64_str = base64.b64encode(buffer).decode("utf-8")
sampled_frames.append((cur_sec, b64_str))
frame_idx += 1
cap.release()
if not sampled_frames:
self._log("⚠️ Không trích xuất được frame nào từ video.")
return False
# Language-aware log
lang_tag = "🇨🇳 ZH (Tiếng Trung)" if str(source_lang).lower().startswith("zh") else "🇺🇸 EN (Tiếng Anh)"
self._log(f"🌐 Ngôn ngữ gốc ưu tiên OCR: {lang_tag} -> Prompt sẽ tập trung tối đa vào {source_lang}")
# Parallel batch processing (language-aware)
raw_detections = self._process_parallel_batches(sampled_frames, batch_size=6, max_workers=6, source_lang=source_lang)
if not raw_detections:
self._log("⚠️ Cloud Vision OCR không nhận diện được chữ nào.")
return False
# Sort detections by timestamp
raw_detections.sort(key=lambda x: x[0])
merged_blocks = self._merge_consecutive_detections(raw_detections, max_gap_sec=0.8)
if not merged_blocks:
self._log("⚠️ Cloud Vision OCR không nhận diện được chữ nào.")
return False
# Coverage gate: chống rụng đuôi im lặng (video 2p mà sub chỉ 20s).
# last_end phải phủ gần hết video, nếu <50% thì báo FAIL để pipeline fallback sang ASR.
last_end_sec = max(b["end_ms"] for b in merged_blocks) / 1000.0
coverage = (last_end_sec / duration_sec) if duration_sec > 0 else 1.0
self._log(f"📊 OCR coverage: sub phủ tới {last_end_sec:.1f}s / video {duration_sec:.1f}s ({coverage*100:.0f}%), "
f"{len(merged_blocks)} blocks từ {len(raw_detections)} detections.")
if duration_sec > 0 and coverage < 0.5:
self._log(f"❌ OCR rụng đuôi nặng (chỉ {coverage*100:.0f}% < 50%) -> trả FAIL để pipeline fallback sang Cloud ASR.")
return False
if duration_sec > 0 and coverage < 0.8:
self._log(f"⚠️ OCR thiếu đuôi ({coverage*100:.0f}% < 80%) — video có thể thiếu sub đoạn cuối, kiểm tra lại vùng cắt.")
self._write_srt(merged_blocks, output_srt_path)
self._log(f"✅ Cloud OCR hoàn tất! Xuất {len(merged_blocks)} câu phụ đề trọn vẹn.")
return True
def _process_parallel_batches(
self,
sampled_frames: List[Tuple[float, str]],
batch_size: int = 6,
max_workers: int = 6,
source_lang: str = "zh"
) -> List[Tuple[float, str]]:
batches = [sampled_frames[i : i + batch_size] for i in range(0, len(sampled_frames), batch_size)]
total_batches = len(batches)
self._log(f"⚡ Xử lý song song {total_batches} batches với {max_workers} luồng siêu tốc...")
detections = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_batch = {
executor.submit(self._ocr_single_batch, b, idx, source_lang): idx
for idx, b in enumerate(batches, 1)
}
for future in as_completed(future_to_batch):
b_idx = future_to_batch[future]
try:
res = future.result()
if res:
detections.extend(res)
except Exception as e:
self._log(f"⚠️ Batch {b_idx} error: {e}")
return detections
def _ocr_single_batch(self, batch: List[Tuple[float, str]], batch_idx: int, source_lang: str = "zh") -> List[Tuple[float, str]]:
# 5x5 = 25 combos - try ALL providers/models/keys until success. Language-aware prompt.
# FIX 2026-09-02: groq limit 3 images, nvidia limit 1 image/batch -> auto-split
prompt = self._build_ocr_prompt(source_lang)
def _try_openai_smart(batch, key, model, url, hdr, to, provider_name):
# Auto-split if batch exceeds provider image limit
limit = self._image_limits.get(provider_name, 10)
if len(batch) <= limit:
try:
res = self._ocr_openai_compatible(batch=batch, api_key=key, model=model, base_url=url, headers=hdr, timeout=to, prompt_override=prompt)
if res is not None and len(res) > 0:
self._log(f"✅ Batch {batch_idx} success: {model} ({url.split('/')[2]})")
return res
except Exception as e:
self._log(f"⚠️ {model} fail: {e}")
return None
else:
# Split into sub-batches respecting limit
self._log(f"🔀 {model} limit {limit}, split batch {len(batch)} -> { (len(batch)+limit-1)//limit } sub-batches")
combined = []
for i in range(0, len(batch), limit):
sub = batch[i:i+limit]
try:
res = self._ocr_openai_compatible(batch=sub, api_key=key, model=model, base_url=url, headers=hdr, timeout=to, prompt_override=prompt)
if res is not None and len(res) > 0:
combined.extend(res)
else:
# sub-batch fail -> whole batch fail for this model
return None
except Exception as e:
self._log(f"⚠️ {model} sub-batch fail: {e}")
return None
if len(combined) > 0:
self._log(f"✅ Batch {batch_idx} success (split): {model} ({url.split('/')[2]})")
return combined
return None
# 1. Gemini (5 models x N keys) - timeout 25s (was 12s -> timeout for batch6, 20s still timeout for 3-flash-preview)
if self.gemini_keys:
for g_model in self.matrix["gemini"]:
for key in self.gemini_keys:
try:
res = self._ocr_gemini(batch, key, g_model, timeout=25, prompt_override=prompt)
if res is not None and len(res) > 0:
self._log(f"✅ Batch {batch_idx} success: {g_model} (gemini)")
return res
except Exception as e:
self._log(f"⚠️ {g_model} fail: {e}")
pass
# 2. OpenRouter (5 FREE models x N keys) - timeout 20s
if self.openrouter_keys:
for o_model in self.matrix["openrouter"]:
for key in self.openrouter_keys:
res = _try_openai_smart(batch, key, o_model, "https://openrouter.ai/api/v1/chat/completions", {"HTTP-Referer": "https://trungsangviet.local", "X-Title": "TrungSangViet Cloud OCR"}, 20, "openrouter")
if res:
return res
# 3. XKIRO (ưu tiên trước Groq/NVIDIA vì hỗ trợ batch6 tốt, free, nhanh)
if self.xkiro_keys:
for xk_model in self.matrix["xkiro"]:
for key in self.xkiro_keys:
res = _try_openai_smart(batch, key, xk_model, "https://api.xkiro.com/v1/chat/completions", None, 20, "xkiro")
if res:
return res
# 4. Groq (limit 3 images -> auto split, timeout 15s)
if self.groq_keys:
for gr_model in self.matrix["groq"]:
for key in self.groq_keys:
res = _try_openai_smart(batch, key, gr_model, "https://api.groq.com/openai/v1/chat/completions", None, 15, "groq")
if res:
return res
# 5. NVIDIA NIM (limit 1 image -> split 6 lần, timeout 30s)
if self.nvidia_keys:
for nv_model in self.matrix["nvidia"]:
for key in self.nvidia_keys:
res = _try_openai_smart(batch, key, nv_model, "https://integrate.api.nvidia.com/v1/chat/completions", None, 30, "nvidia")
if res:
return res
self._log(f"❌ Batch {batch_idx} failed all 25 combos (5 providers x 5 models) - kiểm tra lại HF Secrets & model matrix live 2026-09-02")
return []
def _ocr_gemini(self, batch: List[Tuple[float, str]], api_key: str, model: str, timeout: int = 10, prompt_override: Optional[str] = None) -> Optional[List[Tuple[float, str]]]:
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
prompt_text = prompt_override or (
"Extract all visible subtitle text lines from each video frame image in exact sequence.\n"
"RULES:\n"
"1. Transcribe the COMPLETE subtitle text accurately without missing any characters at the beginning, middle, or end.\n"
"2. If multiple text lines appear, join them into a single coherent full sentence.\n"
"3. Preserve all brand names, numbers, hashtags, and words accurately.\n"
"4. Output a JSON array of strings: [\"text1\", \"text2\", ...]. Return \"\" for frames with no subtitles."
)
parts = [{"text": prompt_text}]
for _, b64 in batch:
parts.append({"inline_data": {"mime_type": "image/jpeg", "data": b64}})
payload = {
"contents": [{"parts": parts}],
"generationConfig": {"temperature": 0.0, "response_mime_type": "application/json"}
}
try:
res = requests.post(url, json=payload, timeout=timeout)
except Exception as e:
self._log(f"⚠️ Gemini {model} exception: {e}")
return None
if res.status_code != 200:
self._log(f"⚠️ Gemini {model} HTTP {res.status_code}: {res.text[:200]}")
return None
try:
raw_text = res.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
except Exception as e:
self._log(f"⚠️ Gemini {model} parse fail: {e} | body {res.text[:300]}")
return None
# Robust JSON extraction: Gemini có thể trả về ```json [...]``` hoặc có thoughtSignature
import re as _re
try:
texts = json.loads(raw_text)
except Exception:
# thử extract bracket
m = _re.search(r"\[.*\]", raw_text, _re.DOTALL)
if m:
try:
texts = json.loads(m.group(0))
except Exception as e2:
self._log(f"⚠️ Gemini {model} JSON fail: {e2} | raw {raw_text[:300]}")
return None
else:
self._log(f"⚠️ Gemini {model} JSON fail: no bracket | raw {raw_text[:300]}")
return None
# Handle length mismatch (e.g., gemma returned 5 vs 6)
if len(texts) != len(batch):
self._log(f"⚠️ Gemini {model} length mismatch got {len(texts)} vs batch {len(batch)}, auto-fix")
if len(texts) < len(batch):
texts = list(texts) + [""] * (len(batch) - len(texts))
else:
texts = texts[:len(batch)]
results = []
for (t, _), txt in zip(batch, texts):
clean_txt = str(txt).strip()
if clean_txt:
results.append((t, clean_txt))
return results
def _ocr_openai_compatible(
self,
batch: List[Tuple[float, str]],
api_key: str,
model: str,
base_url: str,
headers: Optional[Dict] = None,
timeout: int = 10,
prompt_override: Optional[str] = None
) -> Optional[List[Tuple[float, str]]]:
req_headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
if headers:
req_headers.update(headers)
prompt_text = prompt_override or (
"Extract all visible subtitle text lines from each video frame image in exact sequence.\n"
"RULES:\n"
"1. Transcribe the COMPLETE subtitle text accurately without missing any characters at the beginning, middle, or end.\n"
"2. If multiple text lines appear, join them into a single coherent full sentence.\n"
"3. Preserve all brand names, numbers, hashtags, and words accurately.\n"
"4. Output JSON array of strings: [\"text1\", \"text2\", ...]. Return \"\" if empty."
)
content_list = [{
"type": "text",
"text": prompt_text
}]
for _, b64 in batch:
content_list.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}
})
payload = {
"model": model,
"messages": [{"role": "user", "content": content_list}],
"temperature": 0.0
}
try:
res = requests.post(base_url, headers=req_headers, json=payload, timeout=timeout)
except Exception as e:
self._log(f"⚠️ {model} exception {base_url.split('/')[2]}: {e}")
return None
if res.status_code != 200:
self._log(f"⚠️ {model} HTTP {res.status_code} {base_url.split('/')[2]}: {res.text[:250]}")
return None
try:
content = res.json()["choices"][0]["message"]["content"]
except Exception as e:
self._log(f"⚠️ {model} parse fail: {e} | body {res.text[:300]}")
return None
import re
m = re.search(r"\[.*\]", content, re.DOTALL)
if m:
texts = json.loads(m.group(0))
else:
data = json.loads(content)
texts = data if isinstance(data, list) else list(data.values())[0]
if len(texts) != len(batch):
self._log(f"⚠️ {model} length mismatch got {len(texts)} vs batch {len(batch)}, auto-fix")
if len(texts) < len(batch):
texts = list(texts) + [""] * (len(batch)-len(texts))
else:
texts = texts[:len(batch)]
results = []
for (t, _), txt in zip(batch, texts):
clean_txt = str(txt).strip()
if clean_txt:
results.append((t, clean_txt))
return results
def _merge_consecutive_detections(self, detections: List[Tuple[float, str]], max_gap_sec: float = 0.8) -> List[Dict]:
if not detections:
return []
blocks = []
current_group = [detections[0]]
for t, text in detections[1:]:
last_t, last_text = current_group[-1]
sim = self._similarity(text, last_text)
is_same = (
text == last_text
or sim >= 0.55
or (text in last_text and len(text) >= 2)
or (last_text in text and len(last_text) >= 2)
)
if is_same and (t - last_t) <= max_gap_sec:
current_group.append((t, text))
else:
# Flush current group with the most complete text version
best_text = self._pick_best_text([txt for _, txt in current_group])
start_t = current_group[0][0]
end_t = max(start_t + 0.6, current_group[-1][0] + 0.4)
if best_text.strip():
blocks.append({
"id": len(blocks) + 1,
"start_ms": int(start_t * 1000),
"end_ms": int(end_t * 1000),
"start_str": self._format_timestamp(start_t),
"end_str": self._format_timestamp(end_t),
"text": best_text.strip()
})
current_group = [(t, text)]
if current_group:
best_text = self._pick_best_text([txt for _, txt in current_group])
start_t = current_group[0][0]
end_t = max(start_t + 0.6, current_group[-1][0] + 0.4)
if best_text.strip():
blocks.append({
"id": len(blocks) + 1,
"start_ms": int(start_t * 1000),
"end_ms": int(end_t * 1000),
"start_str": self._format_timestamp(start_t),
"end_str": self._format_timestamp(end_t),
"text": best_text.strip()
})
return blocks
def _pick_best_text(self, texts: List[str]) -> str:
"""Pick the longest, cleanest, most complete text from a cluster of frame OCR results."""
if not texts:
return ""
clean_texts = [t.strip() for t in texts if t.strip()]
if not clean_texts:
return ""
# Prefer the longest text that provides maximum completeness
clean_texts.sort(key=lambda s: (len(s), -s.count("?")), reverse=True)
return clean_texts[0]
def _similarity(self, s1: str, s2: str) -> float:
import difflib
return difflib.SequenceMatcher(None, s1, s2).ratio()
def _format_timestamp(self, seconds: float) -> str:
ms = int(round(seconds * 1000))
h, rem = divmod(ms, 3600000)
m, rem = divmod(rem, 60000)
s, ms_rem = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms_rem:03d}"
def _write_srt(self, blocks: List[Dict], out_path: str):
out_file = Path(out_path)
out_file.parent.mkdir(parents=True, exist_ok=True)
lines = []
for b in blocks:
lines.append(str(b["id"]))
lines.append(f"{b['start_str']} --> {b['end_str']}")
lines.append(b["text"])
lines.append("")
out_file.write_text("\n".join(lines), encoding="utf-8")