Files
blog-writer/bots/shorts/video_assembler.py
sinmb79 9b44a07a44 feat: v3.2 — YouTube Shorts 봇 + 수동 어시스트 + 보안 개선
주요 추가 기능:
- 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>
2026-03-28 17:51:02 +09:00

416 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)