"""자막 교정 — Google Gemini(무료 티어) 사용. 텍스트만 전송(영상 X). 키 없으면 교정 건너뜀(들리는대로 그대로). 줄 수/순서 보존, 사투리·구어체 유지. """ from __future__ import annotations import base64 import json import os import urllib.error import urllib.request from typing import List, Optional, Tuple class GeminiQuotaError(Exception): """Gemini 무료 한도 초과(429) — Whisper 폴백 신호.""" GEMINI_MODEL = "gemini-2.5-flash" # 무료 티어 지원(2.0-flash는 limit 0) _ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}" _PROMPT = """너는 한국어 영상 자막 교정기다. 아래 자막 줄들을 교정해라. 규칙: - 명백한 오타·오인식, 고유명사(인명/지명/유행어), 띄어쓰기만 고친다. - 사투리·반말·구어체 말투는 그대로 둔다(표준어로 바꾸지 마라). - 외국 문자나 깨진 글자는 들리는 한국어로 자연스럽게 바꾼다. - 줄 수와 순서를 절대 바꾸지 마라. 각 줄을 1:1로 교정. 합치거나 나누지 마라. - 도저히 못 고치겠으면 원문 그대로 둔다. 영상 제목(문맥): {title} 자막(줄 순서대로): {lines} 교정된 줄들을 JSON 문자열 배열로만 출력. 입력과 같은 개수.""" def _gemini_key() -> Optional[str]: key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") if key: return key.strip() # 프로젝트 루트의 .gemini_key 파일 폴백 root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) f = os.path.join(root, ".gemini_key") if os.path.isfile(f): return open(f, encoding="utf-8").read().strip() or None return None def correct_captions(texts: List[str], *, title: str = "", model: str = GEMINI_MODEL, key: Optional[str] = None) -> List[str]: """자막 리스트를 Gemini로 교정. 실패/키없음/개수불일치 시 원문 반환(안전).""" key = key or _gemini_key() if not key or not texts: return texts numbered = "\n".join(f"{i+1}. {t}" for i, t in enumerate(texts)) body = { "contents": [{"parts": [{"text": _PROMPT.format(title=title or "(없음)", lines=numbered)}]}], "generationConfig": { "temperature": 0.2, "responseMimeType": "application/json", "responseSchema": {"type": "array", "items": {"type": "string"}}, }, } url = _ENDPOINT.format(model=model, key=key) req = urllib.request.Request( url, data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=60) as resp: data = json.loads(resp.read().decode("utf-8")) out = data["candidates"][0]["content"]["parts"][0]["text"] fixed = json.loads(out) except (urllib.error.URLError, KeyError, IndexError, json.JSONDecodeError, TimeoutError): return texts # 안전: 실패 시 원문 if not isinstance(fixed, list) or len(fixed) != len(texts): return texts # 개수 안 맞으면 매핑 깨지니 원문 return [str(f).strip() or texts[i] for i, f in enumerate(fixed)] def has_gemini_key() -> bool: return bool(_gemini_key()) _ASR_PROMPT = ( "이 한국어 영상 오디오를 받아쓰기 해줘. 말소리가 있는 모든 구간을 빠짐없이.\n" "자막용으로 짧게 나눠라:\n" "- 한 자막은 한국어 12자 내외(최대 14자).\n" "- 의미가 자연스럽게 끊기는 지점(어절·구·절 경계)에서 나눠라. 단어 중간이나 " "어색한 곳(조사 앞 등)에서 끊지 마라. 짧은 한 호흡이 한 자막.\n" "각 자막을 {\"start\":초, \"end\":초, \"text\":\"...\"} 로(시간은 오디오 시작 기준 초, 소수1자리). " "사투리·반말·구어체는 그대로. 웃음/박수 등 비언어는 제외. JSON 배열로만." ) def transcribe_gemini(audio_path: str, *, model: str = GEMINI_MODEL, key: Optional[str] = None) -> List[Tuple[float, float, str]]: """Gemini로 오디오 받아쓰기 → [(start, end, text)] (오디오 시작 기준 초). 429(무료 한도 초과) 시 GeminiQuotaError. 그 외 실패는 RuntimeError. """ key = key or _gemini_key() if not key: raise RuntimeError("Gemini 키 없음") audio_b64 = base64.b64encode(open(audio_path, "rb").read()).decode() body = { "contents": [{"parts": [ {"inline_data": {"mime_type": "audio/mp3", "data": audio_b64}}, {"text": _ASR_PROMPT}, ]}], "generationConfig": { "temperature": 0.1, "responseMimeType": "application/json", "responseSchema": {"type": "array", "items": {"type": "object", "properties": { "start": {"type": "number"}, "end": {"type": "number"}, "text": {"type": "string"}}, "required": ["start", "end", "text"]}}, }, } url = _ENDPOINT.format(model=model, key=key) req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=180) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: if e.code == 429: raise GeminiQuotaError() raise RuntimeError(f"Gemini HTTP {e.code}") out = json.loads(data["candidates"][0]["content"]["parts"][0]["text"]) segs = [] for s in out: try: st, en, tx = float(s["start"]), float(s["end"]), str(s["text"]).strip() except (KeyError, ValueError, TypeError): continue if tx and en > st: segs.append((st, en, tx)) return segs