컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
91 lines
3.2 KiB
Python
91 lines
3.2 KiB
Python
"""ffmpeg silencedetect 기반 무음 감지 → 발화(speech) 구간 추출.
|
|
|
|
핵심: 무음을 직접 찾고, 그 여집합 = 발화 구간. 발화 구간 양 끝에 padding 을
|
|
주어 단어 시작/끝이 잘리지 않게 한다(점프컷 특유의 '말 잘림' 방지).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
from typing import List, Tuple
|
|
|
|
Segment = Tuple[float, float] # (start_sec, end_sec)
|
|
|
|
_SILENCE_START = re.compile(r"silence_start:\s*(-?[\d.]+)")
|
|
_SILENCE_END = re.compile(r"silence_end:\s*(-?[\d.]+)")
|
|
|
|
|
|
def _detect_silences(video_path: str, noise_db: float, min_silence: float) -> List[Segment]:
|
|
"""무음 구간 [(start, end), ...] (초) 반환."""
|
|
cmd = [
|
|
"ffmpeg", "-hide_banner", "-nostats", "-i", video_path,
|
|
"-af", f"silencedetect=noise={noise_db}dB:d={min_silence}",
|
|
"-f", "null", "-",
|
|
]
|
|
# silencedetect 로그는 stderr 로 나온다. 단, Windows에서 부모 프로세스(uvicorn)의
|
|
# stderr 핸들이 리다이렉트된 경우 capture_output 의 stderr PIPE 가 None 으로 잡히는
|
|
# 케이스가 있어, stderr→stdout 병합 후 stdout 을 읽는다(검증된 경로).
|
|
proc = subprocess.run(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
)
|
|
log = proc.stdout or ""
|
|
|
|
silences: List[Segment] = []
|
|
cur_start = None
|
|
for line in log.splitlines():
|
|
m = _SILENCE_START.search(line)
|
|
if m:
|
|
cur_start = float(m.group(1))
|
|
continue
|
|
m = _SILENCE_END.search(line)
|
|
if m and cur_start is not None:
|
|
silences.append((cur_start, float(m.group(1))))
|
|
cur_start = None
|
|
return silences
|
|
|
|
|
|
def detect_speech_segments(
|
|
video_path: str,
|
|
duration: float,
|
|
*,
|
|
noise_db: float = -30.0,
|
|
min_silence: float = 0.4,
|
|
pad: float = 0.08,
|
|
min_speech: float = 0.2,
|
|
) -> List[Segment]:
|
|
"""무음의 여집합 = 발화 구간. padding/merge/최소길이 필터 적용.
|
|
|
|
Args:
|
|
duration: 전체 영상 길이(초). probe 로 미리 구한 값.
|
|
noise_db: 이 dB 이하를 무음으로 간주.
|
|
min_silence: 이 길이(초) 이상 지속돼야 무음으로 컷.
|
|
pad: 발화 구간 양 끝 여유(초). 말 잘림 방지.
|
|
min_speech: 이보다 짧은 발화 조각은 버림(노이즈성 컷 방지).
|
|
"""
|
|
silences = _detect_silences(video_path, noise_db, min_silence)
|
|
|
|
# 무음의 여집합 = 발화
|
|
speech: List[Segment] = []
|
|
cursor = 0.0
|
|
for s_start, s_end in silences:
|
|
if s_start > cursor:
|
|
speech.append((cursor, s_start))
|
|
cursor = max(cursor, s_end)
|
|
if cursor < duration:
|
|
speech.append((cursor, duration))
|
|
|
|
# padding (양 끝 확장) + 클램프
|
|
padded = [(max(0.0, s - pad), min(duration, e + pad)) for s, e in speech]
|
|
|
|
# padding 으로 겹친 구간 병합
|
|
merged: List[Segment] = []
|
|
for s, e in padded:
|
|
if merged and s <= merged[-1][1]:
|
|
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
|
else:
|
|
merged.append((s, e))
|
|
|
|
# 최소 발화 길이 필터
|
|
return [(s, e) for s, e in merged if (e - s) >= min_speech]
|