주요 추가 기능: - bots/shorts/ 서브모듈 7개: tts_engine, script_extractor, asset_resolver, stock_fetcher, caption_renderer, video_assembler, youtube_uploader - bots/shorts_bot.py: 6단계 Shorts 파이프라인 오케스트레이터 (auto/semi_auto 두 가지 생산 모드, CLI 지원) - bots/writer_bot.py: 독립 실행형 AI 글쓰기 봇 (대시보드 연동) - bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인 - config/shorts_config.json: Shorts 전체 설정 - templates/shorts/extract_prompt.txt: LLM 스크립트 추출 프롬프트 - scheduler.py에 shorts 잡(10:35/16:00) + /shorts Telegram 명령 추가 보안 개선: - .env 파일 외부 경로 참조로 변경 (load_dotenv dotenv_path, 24개 파일) - .gitignore에 민감 파일/내부 문서/런타임 데이터 항목 추가 문서: - README.md 전면 재작성 (상세 한글 설명, 설치/설정/사용법 포함) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""
|
||
bots/shorts/video_assembler.py
|
||
역할: 준비된 클립 + TTS 오디오 + ASS 자막 → 최종 쇼츠 MP4 조립
|
||
|
||
FFmpeg 전용 (CapCut 없음):
|
||
1. 각 클립을 오디오 길이에 맞게 비율 배분
|
||
2. xfade crossfade로 연결
|
||
3. ASS 자막 burn-in
|
||
4. TTS 오디오 합성 + BGM 덕킹
|
||
5. 페이드인/페이드아웃
|
||
6. 루프 최적화: 마지막 클립 = 첫 클립 복사 (리플레이 유도)
|
||
|
||
출력:
|
||
data/shorts/rendered/{timestamp}.mp4
|
||
"""
|
||
import json
|
||
import logging
|
||
import os
|
||
import subprocess
|
||
import tempfile
|
||
import wave
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
BASE_DIR = Path(__file__).parent.parent.parent
|
||
|
||
|
||
def _load_config() -> dict:
|
||
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
|
||
if cfg_path.exists():
|
||
return json.loads(cfg_path.read_text(encoding='utf-8'))
|
||
return {}
|
||
|
||
|
||
def _get_ffmpeg() -> str:
|
||
ffmpeg_env = os.environ.get('FFMPEG_PATH', '')
|
||
if ffmpeg_env and Path(ffmpeg_env).exists():
|
||
return ffmpeg_env
|
||
return 'ffmpeg'
|
||
|
||
|
||
def _get_wav_duration(wav_path: Path) -> float:
|
||
try:
|
||
with wave.open(str(wav_path), 'rb') as wf:
|
||
return wf.getnframes() / wf.getframerate()
|
||
except Exception:
|
||
# ffprobe 폴백
|
||
try:
|
||
result = subprocess.run(
|
||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||
'-of', 'default=noprint_wrappers=1:nokey=1', str(wav_path)],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
return float(result.stdout.strip())
|
||
except Exception:
|
||
return 20.0
|
||
|
||
|
||
def _get_video_duration(video_path: Path) -> float:
|
||
try:
|
||
result = subprocess.run(
|
||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||
'-of', 'default=noprint_wrappers=1:nokey=1', str(video_path)],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
return float(result.stdout.strip())
|
||
except Exception:
|
||
return 6.0
|
||
|
||
|
||
# ─── 클립 연결 ────────────────────────────────────────────────
|
||
|
||
def _trim_clip(src: Path, dst: Path, duration: float, ffmpeg: str) -> bool:
|
||
"""클립을 duration 초로 트리밍."""
|
||
cmd = [
|
||
ffmpeg, '-y', '-i', str(src),
|
||
'-t', f'{duration:.3f}',
|
||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||
'-an', '-r', '30',
|
||
str(dst),
|
||
]
|
||
try:
|
||
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
|
||
return True
|
||
except subprocess.CalledProcessError as e:
|
||
logger.warning(f'트리밍 실패: {e.stderr.decode(errors="ignore")[:200]}')
|
||
return False
|
||
|
||
|
||
def _concat_with_xfade(clips: list[Path], output: Path, crossfade: float, ffmpeg: str) -> bool:
|
||
"""
|
||
xfade 트랜지션으로 클립 연결.
|
||
2개 이상 클립의 경우 순차 xfade 적용.
|
||
"""
|
||
if len(clips) == 1:
|
||
import shutil
|
||
shutil.copy2(str(clips[0]), str(output))
|
||
return True
|
||
|
||
# 각 클립 길이 확인
|
||
durations = [_get_video_duration(c) for c in clips]
|
||
|
||
# ffmpeg complex filtergraph 구성
|
||
inputs = []
|
||
for c in clips:
|
||
inputs += ['-i', str(c)]
|
||
|
||
# xfade chain: [0][1]xfade, [xfade1][2]xfade, ...
|
||
filter_parts = []
|
||
offset = 0.0
|
||
prev_label = '[0:v]'
|
||
|
||
for i in range(1, len(clips)):
|
||
offset += durations[i - 1] - crossfade
|
||
out_label = f'[xf{i}]'
|
||
filter_parts.append(
|
||
f'{prev_label}[{i}:v]xfade=transition=fade:duration={crossfade}:offset={offset:.3f}{out_label}'
|
||
)
|
||
prev_label = out_label
|
||
|
||
filter_complex = ';'.join(filter_parts)
|
||
|
||
cmd = [
|
||
ffmpeg, '-y',
|
||
*inputs,
|
||
'-filter_complex', filter_complex,
|
||
'-map', prev_label,
|
||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||
'-an', '-r', '30',
|
||
str(output),
|
||
]
|
||
try:
|
||
subprocess.run(cmd, check=True, capture_output=True, timeout=300)
|
||
return True
|
||
except subprocess.CalledProcessError as e:
|
||
logger.warning(f'xfade 연결 실패: {e.stderr.decode(errors="ignore")[:300]}')
|
||
# 폴백: 단순 concat (트랜지션 없음)
|
||
return _concat_simple(clips, output, ffmpeg)
|
||
|
||
|
||
def _concat_simple(clips: list[Path], output: Path, ffmpeg: str) -> bool:
|
||
"""트랜지션 없는 단순 concat (폴백)."""
|
||
list_file = output.parent / 'concat_list.txt'
|
||
lines = [f"file '{c.as_posix()}'" for c in clips]
|
||
list_file.write_text('\n'.join(lines), encoding='utf-8')
|
||
|
||
cmd = [
|
||
ffmpeg, '-y',
|
||
'-f', 'concat', '-safe', '0',
|
||
'-i', str(list_file),
|
||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||
'-an', '-r', '30',
|
||
str(output),
|
||
]
|
||
try:
|
||
subprocess.run(cmd, check=True, capture_output=True, timeout=300)
|
||
list_file.unlink(missing_ok=True)
|
||
return True
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error(f'단순 concat 실패: {e.stderr.decode(errors="ignore")[:200]}')
|
||
list_file.unlink(missing_ok=True)
|
||
return False
|
||
|
||
|
||
# ─── 오디오 합성 ─────────────────────────────────────────────
|
||
|
||
def _mix_audio(tts_wav: Path, bgm_path: Optional[Path], bgm_db: float,
|
||
total_dur: float, output: Path, ffmpeg: str) -> bool:
|
||
"""TTS + BGM 혼합 (BGM 덕킹)."""
|
||
if bgm_path and bgm_path.exists():
|
||
cmd = [
|
||
ffmpeg, '-y',
|
||
'-i', str(tts_wav),
|
||
'-stream_loop', '-1', '-i', str(bgm_path),
|
||
'-filter_complex', (
|
||
f'[1:a]volume={bgm_db}dB,atrim=0:{total_dur:.3f}[bgm];'
|
||
f'[0:a][bgm]amix=inputs=2:duration=first[aout]'
|
||
),
|
||
'-map', '[aout]',
|
||
'-c:a', 'aac', '-b:a', '192k',
|
||
'-t', f'{total_dur:.3f}',
|
||
str(output),
|
||
]
|
||
else:
|
||
cmd = [
|
||
ffmpeg, '-y',
|
||
'-i', str(tts_wav),
|
||
'-c:a', 'aac', '-b:a', '192k',
|
||
'-t', f'{total_dur:.3f}',
|
||
str(output),
|
||
]
|
||
try:
|
||
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
|
||
return True
|
||
except subprocess.CalledProcessError as e:
|
||
logger.warning(f'오디오 혼합 실패: {e.stderr.decode(errors="ignore")[:200]}')
|
||
return False
|
||
|
||
|
||
# ─── 최종 합성 ────────────────────────────────────────────────
|
||
|
||
def _assemble_final(
|
||
video: Path, audio: Path, ass_path: Optional[Path],
|
||
output: Path, fade_in: float, fade_out: float,
|
||
total_dur: float, cfg: dict, ffmpeg: str,
|
||
) -> bool:
|
||
"""
|
||
비디오 + 오디오 + ASS 자막 → 최종 MP4.
|
||
페이드인/아웃 + 루프 최적화 (0.2s 무음 끝에 추가).
|
||
"""
|
||
vid_cfg = cfg.get('video', {})
|
||
crf = vid_cfg.get('crf', 18)
|
||
codec = vid_cfg.get('codec', 'libx264')
|
||
audio_codec = vid_cfg.get('audio_codec', 'aac')
|
||
audio_bitrate = vid_cfg.get('audio_bitrate', '192k')
|
||
|
||
# 페이드인/아웃 필터
|
||
fade_filter = (
|
||
f'fade=t=in:st=0:d={fade_in},'
|
||
f'fade=t=out:st={total_dur - fade_out:.3f}:d={fade_out}'
|
||
)
|
||
|
||
# ASS 자막 burn-in
|
||
if ass_path and ass_path.exists():
|
||
ass_posix = ass_path.as_posix().replace(':', '\\:')
|
||
vf = f'{fade_filter},ass={ass_posix}'
|
||
else:
|
||
vf = fade_filter
|
||
|
||
cmd = [
|
||
ffmpeg, '-y',
|
||
'-i', str(video),
|
||
'-i', str(audio),
|
||
'-vf', vf,
|
||
'-af', (
|
||
f'afade=t=in:st=0:d={fade_in},'
|
||
f'afade=t=out:st={total_dur - fade_out:.3f}:d={fade_out},'
|
||
f'apad=pad_dur=0.2' # 루프 최적화: 0.2s 무음
|
||
),
|
||
'-c:v', codec, '-crf', str(crf), '-preset', 'medium',
|
||
'-c:a', audio_codec, '-b:a', audio_bitrate,
|
||
'-r', str(vid_cfg.get('fps', 30)),
|
||
'-shortest',
|
||
str(output),
|
||
]
|
||
try:
|
||
subprocess.run(cmd, check=True, capture_output=True, timeout=600)
|
||
return True
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error(f'최종 합성 실패: {e.stderr.decode(errors="ignore")[:400]}')
|
||
return False
|
||
|
||
|
||
# ─── 파일 크기 체크 ──────────────────────────────────────────
|
||
|
||
def _check_filesize(path: Path, max_mb: int = 50) -> bool:
|
||
size_mb = path.stat().st_size / (1024 * 1024)
|
||
logger.info(f'출력 파일 크기: {size_mb:.1f}MB')
|
||
return size_mb <= max_mb
|
||
|
||
|
||
def _rerender_smaller(src: Path, dst: Path, ffmpeg: str) -> bool:
|
||
"""파일 크기 초과 시 CRF 23으로 재인코딩."""
|
||
cmd = [
|
||
ffmpeg, '-y', '-i', str(src),
|
||
'-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
|
||
'-c:a', 'aac', '-b:a', '128k',
|
||
str(dst),
|
||
]
|
||
try:
|
||
subprocess.run(cmd, check=True, capture_output=True, timeout=600)
|
||
return True
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error(f'재인코딩 실패: {e.stderr.decode(errors="ignore")[:200]}')
|
||
return False
|
||
|
||
|
||
# ─── 메인 엔트리포인트 ────────────────────────────────────────
|
||
|
||
def assemble(
|
||
clips: list[Path],
|
||
tts_wav: Path,
|
||
ass_path: Optional[Path],
|
||
output_dir: Path,
|
||
timestamp: str,
|
||
cfg: Optional[dict] = None,
|
||
work_dir: Optional[Path] = None,
|
||
) -> Path:
|
||
"""
|
||
클립 + TTS + 자막 → 최종 쇼츠 MP4.
|
||
|
||
Args:
|
||
clips: [clip_path, ...] — 준비된 1080×1920 MP4 목록
|
||
tts_wav: TTS 오디오 WAV 경로
|
||
ass_path: ASS 자막 경로 (None이면 자막 없음)
|
||
output_dir: data/shorts/rendered/
|
||
timestamp: 파일명 prefix
|
||
cfg: shorts_config.json dict
|
||
work_dir: 임시 작업 디렉터리 (None이면 자동 생성)
|
||
|
||
Returns:
|
||
rendered_path
|
||
|
||
Raises:
|
||
RuntimeError — 조립 실패 또는 품질 게이트 미통과
|
||
"""
|
||
if cfg is None:
|
||
cfg = _load_config()
|
||
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
ffmpeg = _get_ffmpeg()
|
||
|
||
vid_cfg = cfg.get('video', {})
|
||
crossfade = vid_cfg.get('crossfade_sec', 0.3)
|
||
fade_in = vid_cfg.get('fade_in_sec', 0.5)
|
||
fade_out = vid_cfg.get('fade_out_sec', 0.5)
|
||
bgm_path_str = vid_cfg.get('bgm_path', '')
|
||
bgm_db = vid_cfg.get('bgm_volume_db', -18)
|
||
bgm_path = BASE_DIR / bgm_path_str if bgm_path_str else None
|
||
|
||
audio_dur = _get_wav_duration(tts_wav)
|
||
logger.info(f'TTS 길이: {audio_dur:.1f}초')
|
||
|
||
# 품질 게이트: 15초 미만 / 60초 초과
|
||
if audio_dur < 10:
|
||
raise RuntimeError(f'TTS 길이 너무 짧음: {audio_dur:.1f}초 (최소 10초)')
|
||
if audio_dur > 65:
|
||
raise RuntimeError(f'TTS 길이 너무 김: {audio_dur:.1f}초 (최대 65초)')
|
||
|
||
if not clips:
|
||
raise RuntimeError('클립 없음 — 조립 불가')
|
||
|
||
# 임시 작업 디렉터리
|
||
import contextlib
|
||
import shutil
|
||
|
||
tmp_cleanup = work_dir is None
|
||
if work_dir is None:
|
||
work_dir = output_dir / f'_work_{timestamp}'
|
||
work_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
try:
|
||
# ── 루프 최적화: 클립 목록 끝에 첫 클립 추가 ──────────────
|
||
loop_clips = list(clips)
|
||
if len(clips) > 1:
|
||
loop_clip = work_dir / 'loop_clip.mp4'
|
||
if _trim_clip(clips[0], loop_clip, min(2.0, _get_video_duration(clips[0])), ffmpeg):
|
||
loop_clips.append(loop_clip)
|
||
|
||
# ── 클립 길이 배분 ────────────────────────────────────────
|
||
total_clip_dur = audio_dur + fade_in + fade_out
|
||
n = len(loop_clips)
|
||
base_dur = total_clip_dur / n
|
||
clip_dur = max(3.0, min(base_dur, 8.0))
|
||
|
||
# 각 클립 트리밍
|
||
trimmed = []
|
||
for i, clip in enumerate(loop_clips):
|
||
t = work_dir / f'trimmed_{i:02d}.mp4'
|
||
src_dur = _get_video_duration(clip)
|
||
actual_dur = min(clip_dur, src_dur)
|
||
if actual_dur < 1.0:
|
||
actual_dur = src_dur
|
||
if _trim_clip(clip, t, actual_dur, ffmpeg):
|
||
trimmed.append(t)
|
||
else:
|
||
logger.warning(f'클립 {i} 트리밍 실패 — 건너뜀')
|
||
|
||
if not trimmed:
|
||
raise RuntimeError('트리밍된 클립 없음')
|
||
|
||
# ── 클립 연결 ─────────────────────────────────────────────
|
||
concat_out = work_dir / 'concat.mp4'
|
||
if not _concat_with_xfade(trimmed, concat_out, crossfade, ffmpeg):
|
||
raise RuntimeError('클립 연결 실패')
|
||
|
||
# ── 오디오 혼합 ───────────────────────────────────────────
|
||
audio_out = work_dir / 'audio_mixed.aac'
|
||
if not _mix_audio(tts_wav, bgm_path, bgm_db, audio_dur + 0.2, audio_out, ffmpeg):
|
||
# BGM 없이 TTS만
|
||
audio_out = tts_wav
|
||
|
||
# ── 최종 합성 ─────────────────────────────────────────────
|
||
final_out = output_dir / f'{timestamp}.mp4'
|
||
if not _assemble_final(
|
||
concat_out, audio_out, ass_path,
|
||
final_out, fade_in, fade_out, audio_dur,
|
||
cfg, ffmpeg,
|
||
):
|
||
raise RuntimeError('최종 합성 실패')
|
||
|
||
# ── 파일 크기 게이트 ──────────────────────────────────────
|
||
if not _check_filesize(final_out, max_mb=50):
|
||
logger.warning('파일 크기 초과 (>50MB) — CRF 23으로 재인코딩')
|
||
rerender_out = output_dir / f'{timestamp}_small.mp4'
|
||
if _rerender_smaller(final_out, rerender_out, ffmpeg):
|
||
final_out.unlink()
|
||
rerender_out.rename(final_out)
|
||
|
||
# ── 최종 길이 검증 ─────────────────────────────────────────
|
||
final_dur = _get_video_duration(final_out)
|
||
if final_dur < 10:
|
||
raise RuntimeError(f'최종 영상 길이 너무 짧음: {final_dur:.1f}초')
|
||
if final_dur > 65:
|
||
logger.warning(f'최종 영상 길이 초과: {final_dur:.1f}초 (YouTube Shorts 제한 60초)')
|
||
|
||
logger.info(f'쇼츠 조립 완료: {final_out.name} ({final_dur:.1f}초)')
|
||
return final_out
|
||
|
||
finally:
|
||
if tmp_cleanup and work_dir.exists():
|
||
import shutil
|
||
shutil.rmtree(work_dir, ignore_errors=True)
|