컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
"""숏폼 CLI: 롱폼 + 하이라이트 윈도우 → 9:16 중앙크롭 자막 드래프트.
|
||
|
||
사용:
|
||
python build_shortform.py <video> <start> <end> [draft_name]
|
||
start/end 는 초(예: 312) 또는 mm:ss(예: 5:12)
|
||
|
||
처리:
|
||
1. 전사(캐시) → 윈도우 내 발화 세그먼트(자막 동기) 추출
|
||
2. 윈도우 구간만 h264 mp4 로 추출(.media/) → AV1/webm 함정 + CapCut 미리보기 회피
|
||
3. clip 타임스탬프를 추출본 0 기준으로 리베이스 후 9:16 자막 드래프트 빌드
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
from capcut_agent.probe import probe
|
||
from capcut_agent.transcribe import transcribe
|
||
from capcut_agent.highlight import clips_in_window, window_bounds
|
||
from capcut_agent.draft import build_shortform_draft
|
||
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
MEDIA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".media")
|
||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||
|
||
|
||
def parse_t(v: str) -> float:
|
||
if ":" in v:
|
||
m, s = v.split(":")
|
||
return int(m) * 60 + float(s)
|
||
return float(v)
|
||
|
||
|
||
def extract_window(video: str, start: float, end: float, out_path: str) -> None:
|
||
"""[start,end] 구간을 h264 mp4 로 추출. 출력 t=0 == start (정확 seek)."""
|
||
dur = end - start + 0.3 # 끝 pad 여유
|
||
cmd = [
|
||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
||
"-ss", f"{start:.3f}", "-i", video, "-t", f"{dur:.3f}",
|
||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||
"-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100",
|
||
out_path,
|
||
]
|
||
subprocess.run(cmd, check=True, encoding="utf-8", errors="replace")
|
||
|
||
|
||
def main(argv: list[str]) -> int:
|
||
if len(argv) < 4:
|
||
print("usage: python build_shortform.py <video> <start> <end> [name]")
|
||
return 2
|
||
video = os.path.abspath(argv[1])
|
||
ws, we = parse_t(argv[2]), parse_t(argv[3])
|
||
name = argv[4] if len(argv) > 4 else f"short_{int(ws)}_{int(we)}"
|
||
|
||
t0 = time.time()
|
||
tr = transcribe(video, model_size="medium", language="ko", use_cache=True)
|
||
print(f"[asr] segments={len(tr.segments)} (cache)")
|
||
|
||
ws, we = window_bounds(tr, ws, we) # 앞뒤 무음 트림
|
||
clips = clips_in_window(tr, ws, we)
|
||
if not clips:
|
||
print("[window] 발화 없음 — 구간 확인 필요")
|
||
return 1
|
||
kept = sum(e - s for s, e, _ in clips)
|
||
print(f"[window] {ws:.1f}–{we:.1f}s → clips={len(clips)}, kept={kept:.1f}s")
|
||
|
||
# 윈도우 구간만 h264 추출 (AV1/webm + CapCut 미리보기 함정 회피)
|
||
media_path = os.path.join(MEDIA_DIR, f"{name}.mp4")
|
||
extract_window(video, ws, we, media_path)
|
||
print(f"[extract] {media_path}")
|
||
|
||
# clip 을 추출본 0 기준으로 리베이스
|
||
rebased = [(s - ws, e - ws, txt) for s, e, txt in clips]
|
||
meta = probe(media_path) # 추출본 메타(해상도/fps)
|
||
|
||
path = build_shortform_draft(media_path, rebased, meta, name, captions=True)
|
||
print(f"[draft] {path}")
|
||
print(f"[done] {time.time()-t0:.1f}s. CapCut에서 '{name}' 열어 검증.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv))
|