"""붙여넣기(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(), }