"""하이라이트 선택 보조 — 전사(Transcript) 기반. v1 정책: 어떤 구간이 '하이라이트'인지는 에이전트(Claude)가 전체 대본을 읽고 판단한다. 이 모듈은 그 판단을 돕고(대본 포맷팅), 선택된 시간 윈도우를 숏폼 clip 리스트로 변환한다(세그먼트 단위 컷 + 자막 동기화). """ from __future__ import annotations import re from typing import List, Tuple from .draft import Clip from .transcribe import Transcript def clean_caption(t: str) -> str: """들리는 대로 유지(사투리 더듬·발음·반복 살림). 외국문자 환각·잡음만 제거. 한글/숫자/기본 문장부호만 남김 → 러시아어·아랍어 등 환각 토큰 제거. 단어 반복· 장음("코, 코, 코스에에", "야호오오")은 그대로 둠(비정상 8+ 장음만 살짝 캡). """ t = re.sub(r"[^가-힣ㄱ-ㅎㅏ-ㅣ0-9\s.,!?~…]", "", t) # 한글(자모포함)/숫자/부호만 t = re.sub(r"(.)\1{7,}", lambda m: m.group(1) * 4, t) # 8+ 반복만 캡 return re.sub(r"\s+", " ", t).strip(" .,") def _fmt_ts(sec: float) -> str: m, s = divmod(int(sec), 60) return f"{m:02d}:{s:02d}" def format_for_selection(transcript: Transcript, *, merge_gap: float = 0.0) -> str: """대본을 '[mm:ss] 텍스트' 줄들로 포맷 — 에이전트가 읽고 하이라이트 고를 용도.""" lines = [] for s in transcript.segments: lines.append(f"[{_fmt_ts(s.start)}] {s.text}") return "\n".join(lines) def clips_in_window( transcript: Transcript, win_start: float, win_end: float, *, pad: float = 0.05, ) -> List[Clip]: """[win_start, win_end] 와 겹치는 전사 세그먼트를 clip(s,e,text) 으로 변환. 세그먼트 단위라 발화 사이 무음은 자연히 잘려 숏폼이 타이트해지고, 각 세그먼트 텍스트가 그대로 자막이 되어 싱크가 맞는다. pad 로 양 끝 살짝 여유. """ clips: List[Clip] = [] for seg in transcript.segments: s = max(seg.start, win_start) e = min(seg.end, win_end) if e - s <= 0.05: continue s = max(0.0, s - pad) e = e + pad clips.append((s, e, seg.text)) return clips def window_bounds(transcript: Transcript, win_start: float, win_end: float) -> Tuple[float, float]: """윈도우 내 실제 발화의 시작/끝(앞뒤 무음 트림용).""" segs = [g for g in transcript.segments if g.end > win_start and g.start < win_end] if not segs: return win_start, win_end return max(win_start, segs[0].start), min(win_end, segs[-1].end) def _video_clips_offsets(keep_segments): """보존 구간 → (video_clips, offsets[(s,e,new_off)], total).""" video_clips, offsets, cum = [], [], 0.0 for s, e in sorted(keep_segments): if e - s <= 0: continue offsets.append((s, e, cum)) video_clips.append((s, e)) cum += e - s return video_clips, offsets, cum def captions_from_segments( keep_segments: List[Tuple[float, float]], segments: List[Tuple[float, float, str]], *, max_chars: int = 14, # Gemini가 의미단위로 끊어주므로 그 길이까진 그대로 둠 ): """Gemini 받아쓰기 세그먼트(원본 시간) → 컷 타임라인 자막. 긴 건 max_chars로 분할. 타임스탬프 검증: 보존 구간 밖(잘린 무음/영상 길이 초과)은 버림 → Gemini의 가끔 틀린 타임스탬프(영상 길이 초과 등) 자동 제거. """ video_clips, offsets, total = _video_clips_offsets(keep_segments) def map_t(t): for s, e, off in offsets: if s - 0.05 <= t <= e + 0.05: return off + (min(max(t, s), e) - s) return None caps = [] for s, e, text in segments: text = clean_caption(text) if not text: continue ns, ne = map_t(s), map_t(e) if ns is None or ne is None: # 보존 구간 밖 → 버림(타임스탬프 검증) continue if ne <= ns: ne = ns + 0.3 if len(text) <= max_chars: caps.append((ns, ne, text)) continue # 길면 공백 기준 청크 + 시간 비례 분배 chunks, cur = [], "" for w in text.split(" "): if cur and len(cur) + 1 + len(w) > max_chars: chunks.append(cur) cur = w else: cur = (cur + " " + w).strip() if cur: chunks.append(cur) span = ne - ns totch = sum(len(c) for c in chunks) or 1 t0 = ns for c in chunks: t1 = t0 + span * len(c) / totch caps.append((t0, t1, c)) t0 = t1 caps.sort() fixed = [] for ns, ne, t in caps: # 겹침 제거 if fixed and ns < fixed[-1][1]: ns = fixed[-1][1] if ne <= ns: ne = ns + 0.2 fixed.append((ns, ne, t)) return video_clips, fixed, total def _norm_hangul(s: str) -> str: """정렬용 정규화 — 한글/숫자만(공백·부호 제거).""" return re.sub(r"[^가-힣0-9]", "", s) def align_gemini_to_whisper( gemini_segs: List[Tuple[float, float, str]], transcript: Transcript, ) -> List[Tuple[float, float, str]]: """Gemini 글자 + Whisper 타이밍 하이브리드. Gemini 텍스트(품질 好, 타임스탬프 弱)를 Whisper의 실제 단어 타임스탬프(정확)에 글자수 기준으로 순차 정렬. 세그먼트마다 실제 단어 start/end로 재-앵커링 → 드리프트가 누적되지 않음. Whisper 단어가 없으면 Gemini 원본 시간 유지(폴백). Returns: [(원본시작, 원본끝, Gemini텍스트)] — 원본(클립) 시간 기준. 이후 captions_from_segments 로 컷 타임라인 매핑. """ words: List[Tuple[float, float, str]] = [] for seg in transcript.segments: for w in (seg.words or []): nt = _norm_hangul(w.text) if nt and w.end > w.start: words.append((w.start, w.end, nt)) words.sort() if not words: return gemini_segs # 폴백: Whisper 단어 없음 → Gemini 타임스탬프 그대로 retimed: List[Tuple[float, float, str]] = [] i, n = 0, len(words) for gs, ge, text in gemini_segs: target = len(_norm_hangul(text)) if target == 0 or i >= n: retimed.append((gs, ge, text)) # 남은 단어 없음/빈 텍스트 → 원본 유지 continue start_t = words[i][0] acc, last_e = 0, words[i][1] while i < n and acc < target: acc += len(words[i][2]) last_e = words[i][1] i += 1 retimed.append((start_t, last_e, text)) return retimed # ── 자막 줄바꿈(청킹) 규칙 ───────────────────────────────────────────── # 문제: 앞에서부터 12자 차면 무조건 끊는 방식이면 "어쩌구 예를 / 들어 어떻게", # "제가 또 유용하게 쓸 / 수 있잖아요" 처럼 한 덩어리인 말이 두 자막으로 갈린다. # 해결: 무음으로 나눈 덩어리 안에서 '어디서 끊을지'를 DP로 한 번에 고른다. # ★ 단어 타임스탬프는 절대 건드리지 않는다 — 묶는 방법만 고르므로 싱크 불변. # 새 줄을 이 어절로 시작하면 어색한 것들(의존명사·보조용언·연어의 뒷부분). # 앞줄에 붙어야 말이 된다 → 이 앞에서 끊으면 큰 벌점. BOUND_WORDS = frozenset(""" 들어 들면 들자면 수 것 걸 게 거 줄 때 뿐 등 만큼 대로 채 척 듯 듯이 적 번 개 명 분 가지 정도 밖에 만에 나름 김에 무렵 마련 따름 나위 때문에 때문이 위해 위한 대해 대한 통해 관해 비해 불구하고 아니라 아니고 아니면 보다 봐도 있어 있어요 있는 있다 있고 있을 있었 있잖아요 있습니다 없어 없어요 없는 없다 없고 없을 없었 없잖아요 없습니다 같아 같아요 같은 같이 같다 만하다 버렸 놓고 주세요 드려요 합니다 해요 한다 하는 하고 해서 하죠 하잖아요 """.split()) # 의존명사 어간 + 조사 조합("번도", "적이", "것을", "때가" …)도 같이 잡는다. # 어간만으로는 "한 / 번도 없습니다" 처럼 조사가 붙은 형태를 놓친다. _BOUND_STEMS = frozenset(""" 수 것 거 걸 게 줄 때 뿐 등 적 번 개 명 분 가지 정도 만큼 대로 채 척 듯 무렵 셈 """.split()) _PARTICLES = ("이", "가", "은", "는", "을", "를", "도", "만", "에", "에서", "으로", "로", "와", "과", "의", "야", "라", "이라", "라도", "이라도", "조차", "까지", "부터", "마다", "밖에", "나", "이나", "처럼", "보다", "대로", "요") # 어간+조사로 분해되지만 실제로는 홀로 쓰는 말 → 예외 _BOUND_EXC = frozenset("거의 등등 게요 거야 게임 개월 분들 분들이 분들은".split()) # 뒤에 반드시 명사가 오는 관형사 — 여기서 줄을 끊으면 "한 / 번도" 가 된다. # ※ "네"(=예), "세"(세다) 처럼 흔한 동음이의어는 오탐이 커서 제외. _DETERMINERS = frozenset("한 두 몇 여러 각 온 전 어떤 무슨 웬 딴 새".split()) # 관형사형 어미(-는/-던/-ㄹ받침)로 끝나는 어절 → 뒤에 반드시 꾸밀 말이 온다. # ("당황하는 / 것 같은데", "제가 또 유용하게 쓸 / 수 있잖아요") _ADNOM_TAIL = ("는", "던") _ADNOM_SKIP = frozenset("나는 너는 저는 그는 우린 우리는 저희는 얘는 걔는 쟤는 이는".split()) # 문장이 끝나는 자리 = 좋은 끊김 _ENDING_TAIL = ("요", "죠", "다", "까", "네", "군", "고", "서", "며", "면", "데", "만", "지만", "니까", "는데", "어서", "아서") def _tok(w: str) -> str: """벌점 판정용 어절 정규화 — 앞뒤 공백·문장부호 제거.""" return w.strip(" .,!?~…\"'“”") def _is_adnominal(w: str) -> bool: """관형사형(수식어)으로 끝나는 어절인가. 뒤에 명사가 와야 하므로 끊으면 안 됨.""" t = _tok(w) if len(t) < 2 or t in _ADNOM_SKIP: return False if t.endswith(("습니다", "니다", "어요", "아요", "해요", "예요", "이에요")): return False if t.endswith(_ADNOM_TAIL): return True # 받침 ㄹ (쓸·할·볼·만들…) — 조사 '-을/-를'(밥을·책을)은 제외 last = t[-1] if t.endswith(("을", "를", "늘", "물", "들")): return False return "가" <= last <= "힣" and (ord(last) - 0xAC00) % 28 == 8 def _is_bound(w: str) -> bool: """줄 첫머리에 오면 안 되는 어절인가(의존명사·보조용언·연어 뒷부분).""" t = _tok(w) if not t: return False if t in BOUND_WORDS: return True if t in _BOUND_EXC: return False for k in (1, 2): # 의존명사 어간(1~2글자) + 조사 if len(t) > k and t[:k] in _BOUND_STEMS and t[k:] in _PARTICLES: return True return False def _break_cost(prev_w: str, next_w: str) -> int: """prev_w 와 next_w 사이에서 줄을 끊을 때의 벌점(작을수록 좋은 자리).""" cost = 0 if _is_bound(next_w): cost += 60 # "예를 / 들어", "쓸 / 수 있잖아요", "한 / 번도" if _is_adnominal(prev_w) or _tok(prev_w) in _DETERMINERS: cost += 50 # "당황하는 / 것 같은데", "한 / 번도" p = prev_w.strip() if p.endswith((".", "?", "!")): cost -= 25 # 문장 끝 = 가장 좋은 자리 elif p.endswith(","): cost -= 12 elif _tok(p).endswith(_ENDING_TAIL): cost -= 15 # 어미로 끝남 = 좋은 자리 return cost def _chunk_words( words: List[Tuple[float, float, str]], *, target_chars: int, max_chars: int, max_dur: float, ) -> List[Tuple[float, float, str]]: """한 덩어리(무음 사이)의 단어들을 자막 줄로 최적 분할. 비용 = Σ (줄길이 - target_chars)² + 끊는 자리 벌점 을 최소화하는 줄바꿈을 DP로 고른다(문단 조판과 같은 방식). 어절 수² 라 실질 비용은 무시할 수준. """ n = len(words) if n == 0: return [] raw = [w[2] for w in words] INF = float("inf") cost = [INF] * (n + 1) back = [0] * (n + 1) cost[0] = 0.0 for j in range(1, n + 1): line_txt = "" for i in range(j - 1, -1, -1): # 화면에 실제로 보일 문자열로 길이를 잰다(부호 포함, 최종 자막과 동일) line_txt = raw[i] + line_txt line_len = len(clean_caption(line_txt)) single = (j - i == 1) if not single: if line_len > max_chars: break # 더 길어지기만 하므로 중단 if words[j - 1][1] - words[i][0] > max_dur: break if cost[i] == INF: continue c = cost[i] + (line_len - target_chars) ** 2 if j < n: # 마지막 줄 뒤는 끊는 게 아님 c += _break_cost(raw[j - 1], raw[j]) if c < cost[j]: cost[j] = c back[j] = i lines: List[Tuple[float, float, str]] = [] j = n while j > 0: i = back[j] lines.append((words[i][0], words[j - 1][1], clean_caption("".join(raw[i:j])))) j = i lines.reverse() return lines def cut_plan( keep_segments: List[Tuple[float, float]], transcript: Transcript, *, max_chars: int = 14, target_chars: int = 10, max_dur: float = 2.8, max_gap: float = 0.6, ): """오디오 기준 보존 구간(keep_segments) + 전사 → 비디오 컷 & 자막 배치 계획. ★ 컷은 '실제 오디오 무음'(silence.detect_speech_segments)으로 정한다 → VAD가 놓치는 짧은 외침("거제! 야호!")도 오디오가 있으면 보존됨. ★ 자막은 단어 타임스탬프 기준으로 청크화. 어디서 끊을지는 _chunk_words(DP)가 고른다 → 길이는 target_chars 근처로 고르게, "예를/들어" 같이 붙어야 할 말은 안 갈라짐. Returns: video_clips: [(src_start, src_end)] 보존 구간(타임라인에 순서대로 이어붙임) captions: [(target_start, target_end, text)] 새 타임라인 기준 자막 total: 총 길이(초) """ regions = sorted(keep_segments) video_clips: List[Tuple[float, float]] = [] offsets: List[Tuple[float, float, float]] = [] # (src_s, src_e, new_offset) cum = 0.0 for s, e in regions: if e - s <= 0: continue offsets.append((s, e, cum)) video_clips.append((s, e)) cum += e - s total = cum def map_t(t: float): for s, e, off in offsets: if s - 0.02 <= t <= e + 0.02: return off + (min(max(t, s), e) - s) return None # 잘려나간(무음) 구간 # 단어를 새 타임라인으로 매핑(보존 구간 안의 단어만) words: List[Tuple[float, float, str]] = [] for seg in transcript.segments: for w in (seg.words or []): ns, ne = map_t(w.start), map_t(w.end) if ns is None or ne is None or not w.text.strip(): continue words.append((ns, ne, w.text)) words.sort() # max_gap 이상 쉬면 다른 덩어리(문장) → 덩어리마다 DP로 줄바꿈 최적화 captions: List[Tuple[float, float, str]] = [] group: List[Tuple[float, float, str]] = [] def flush(): if not group: return for ns, ne, text in _chunk_words(group, target_chars=target_chars, max_chars=max_chars, max_dur=max_dur): if len(text) >= 2: # 1글자 잔챙이 제외 captions.append((ns, ne, text)) for w in words: # 쉼이 길거나 덩어리가 너무 커지면(DP 비용 방어) 끊는다 if group and (w[0] - group[-1][1] > max_gap or len(group) >= 120): flush() group = [] group.append(w) flush() return video_clips, captions, total