컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
5.8 KiB
Python
140 lines
5.8 KiB
Python
"""자막 교정 — 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
|