capcut-agent/capcut_agent/paste.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

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(),
}