컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
405 lines
16 KiB
Python
405 lines
16 KiB
Python
"""하이라이트 선택 보조 — 전사(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
|