capcut-agent/capcut_agent/correct.py
hehihoho3@gmail.com bf1b387d6d chore: git 저장소 초기화 (기존 코드 스냅샷)
컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다.
.gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와
비밀키(.gemini_key)를 제외했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:36:05 +09:00

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