Spaces:
Running on Zero
Running on Zero
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 Download app/core/cloud_ocr.py from hoangtaiii/DRIPPY4: direct link, hf CLI and curl.
- Browser
- Download file 28.6 kB
-
https://huggingface.co/spaces/hoangtaiii/DRIPPY4/resolve/main/app/core/cloud_ocr.py
- Command line
-
hf download hf://spaces/hoangtaiii/DRIPPY4/app/core/cloud_ocr.py
-
curl -L -o cloud_ocr.py https://huggingface.co/spaces/hoangtaiii/DRIPPY4/resolve/main/app/core/cloud_ocr.py
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") | |