컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
128 lines
4.6 KiB
Python
128 lines
4.6 KiB
Python
"""붙여넣기(JSON) 파서 — LLM이 만든 하이라이트 편집안을 관대하게 읽는다.
|
|
|
|
입력 스키마(JSON 하나):
|
|
{
|
|
"url": "https://www.youtube.com/watch?v=…",
|
|
"title_top": "…", "title_main": "…", "channel": "…", # 선택
|
|
"cuts": [
|
|
{"start": "0:01.0", "end": "0:03.5",
|
|
"bottom": "하단 자막\n두 줄", "effect": "광속하강"},
|
|
…
|
|
]
|
|
}
|
|
|
|
- 배치 시간은 받지 않는다 → 컷을 순서대로 이어붙인 누적 길이로 자동 계산.
|
|
- 자막은 컷에 1:1로 묶여 있어 어긋날 수 없다(SRT 타임코드 불필요).
|
|
- 시간은 관대하게 파싱: "분:초.밀리" / "시:분:초.밀리", 콤마·점 밀리초 모두 허용, 초 단독도 허용.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Dict, List, Tuple
|
|
|
|
|
|
def parse_time(v) -> float:
|
|
"""'M:SS.mmm' / 'H:MM:SS.mmm' / 'SS.mmm' / 숫자 → 초(float). 콤마도 허용."""
|
|
s = str(v).strip().replace(",", ".")
|
|
if not s:
|
|
raise ValueError("빈 시간값")
|
|
if ":" in s:
|
|
parts = [float(x) for x in s.split(":")]
|
|
if len(parts) == 3:
|
|
h, m, sec = parts
|
|
elif len(parts) == 2:
|
|
h, (m, sec) = 0.0, parts
|
|
else:
|
|
raise ValueError(f"시간 형식 오류: {v!r}")
|
|
return h * 3600 + m * 60 + sec
|
|
return float(s)
|
|
|
|
|
|
def split_json_objects(text: str) -> List[str]:
|
|
"""텍스트에서 최상위 {…} JSON 블록들을 순서대로 추출.
|
|
|
|
오팔 출력처럼 JSON 여러 개 사이에 구분선·타이틀 후보·검산표 등 잡문이 섞여
|
|
있어도 중괄호 균형만 맞으면 전부 찾는다. 문자열 안의 중괄호는 무시.
|
|
"""
|
|
out: List[str] = []
|
|
depth, start, in_str, esc = 0, -1, False, False
|
|
for i, ch in enumerate(text or ""):
|
|
if in_str:
|
|
if esc:
|
|
esc = False
|
|
elif ch == "\\":
|
|
esc = True
|
|
elif ch == '"':
|
|
in_str = False
|
|
continue
|
|
if ch == '"':
|
|
if depth > 0:
|
|
in_str = True
|
|
continue
|
|
if ch == "{":
|
|
if depth == 0:
|
|
start = i
|
|
depth += 1
|
|
elif ch == "}" and depth > 0:
|
|
depth -= 1
|
|
if depth == 0 and start != -1:
|
|
out.append(text[start:i + 1])
|
|
start = -1
|
|
return out
|
|
|
|
|
|
def parse_paste(raw) -> Dict:
|
|
"""붙여넣기 텍스트(JSON) → {url, cuts:[(s,e,bottom,effect)], title_top, title_main, channel}.
|
|
|
|
파싱/검증 실패 시 ValueError(사용자에게 그대로 보여줄 한국어 메시지).
|
|
"""
|
|
if isinstance(raw, (dict, list)):
|
|
data = raw
|
|
else:
|
|
text = (raw or "").strip()
|
|
# 흔한 실수: 코드펜스로 감싸서 붙여넣음 → 벗겨줌
|
|
if text.startswith("```"):
|
|
text = text.strip("`")
|
|
nl = text.find("\n")
|
|
if nl != -1:
|
|
text = text[nl + 1:]
|
|
try:
|
|
# strict=False: 자막 안 실제 줄바꿈(제어문자) 허용 → LLM 출력 관대 수용
|
|
data = json.loads(text, strict=False)
|
|
except json.JSONDecodeError as e:
|
|
raise ValueError(f"JSON 형식 오류: {e.msg} (줄 {e.lineno})")
|
|
|
|
if not isinstance(data, dict):
|
|
raise ValueError("최상위는 JSON 객체({…})여야 합니다.")
|
|
|
|
url = str(data.get("url", "")).strip()
|
|
if not (url.startswith("http://") or url.startswith("https://")):
|
|
raise ValueError("url 필드에 올바른 유튜브 주소가 필요합니다.")
|
|
|
|
raw_cuts = data.get("cuts")
|
|
if not isinstance(raw_cuts, list) or not raw_cuts:
|
|
raise ValueError("cuts 배열에 컷을 하나 이상 넣어주세요.")
|
|
|
|
cuts: List[Tuple[float, float, str, str]] = []
|
|
for i, c in enumerate(raw_cuts, 1):
|
|
if not isinstance(c, dict):
|
|
raise ValueError(f"{i}번 컷이 객체가 아닙니다.")
|
|
try:
|
|
s = parse_time(c["start"])
|
|
e = parse_time(c["end"])
|
|
except KeyError as k:
|
|
raise ValueError(f"{i}번 컷에 {k} 필드가 없습니다.")
|
|
if e <= s:
|
|
raise ValueError(f"{i}번 컷: 끝({c.get('end')})이 시작({c.get('start')})보다 커야 합니다.")
|
|
bottom = str(c.get("bottom", "") or "").strip()
|
|
effect = str(c.get("effect", "") or "").strip()
|
|
cuts.append((s, e, bottom, effect))
|
|
|
|
return {
|
|
"url": url,
|
|
"cuts": cuts,
|
|
"title_top": str(data.get("title_top", "") or "").strip(),
|
|
"title_main": str(data.get("title_main", "") or "").strip(),
|
|
"channel": str(data.get("channel", "") or "").strip(),
|
|
}
|