주요 추가 기능: - 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>
262 lines
9.0 KiB
Python
262 lines
9.0 KiB
Python
"""
|
|
bots/shorts/caption_renderer.py
|
|
역할: 단어별 타임스탬프 → ASS 자막 파일 생성 (단어별 하이라이트)
|
|
|
|
스타일:
|
|
- 기본: 흰색 볼드, 검정 아웃라인 3px
|
|
- 하이라이트: 노란색 (#FFD700) — 현재 발음 중인 단어
|
|
- 훅 텍스트: 중앙 상단, 72px, 1.5초 표시
|
|
- 본문 자막: 하단 200px, 48px, 최대 2줄
|
|
|
|
출력:
|
|
data/shorts/captions/{timestamp}.ass
|
|
"""
|
|
import json
|
|
import logging
|
|
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 _hex_to_ass(hex_color: str, alpha: int = 0) -> str:
|
|
"""
|
|
HTML hex (#RRGGBB) → ASS 색상 &HAABBGGRR 변환.
|
|
ASS는 BGR 순서이며 alpha는 00(불투명)~FF(투명).
|
|
"""
|
|
c = hex_color.lstrip('#')
|
|
r, g, b = c[0:2], c[2:4], c[4:6]
|
|
return f'&H{alpha:02X}{b}{g}{r}'
|
|
|
|
|
|
# ─── 시간 포맷 ────────────────────────────────────────────────
|
|
|
|
def _sec_to_ass_time(seconds: float) -> str:
|
|
"""초(float) → ASS 시간 포맷 H:MM:SS.cc."""
|
|
cs = int(round(seconds * 100))
|
|
h = cs // 360000
|
|
cs %= 360000
|
|
m = cs // 6000
|
|
cs %= 6000
|
|
s = cs // 100
|
|
cs %= 100
|
|
return f'{h}:{m:02d}:{s:02d}.{cs:02d}'
|
|
|
|
|
|
# ─── ASS 헤더 ────────────────────────────────────────────────
|
|
|
|
def _ass_header(cfg: dict) -> str:
|
|
cap_cfg = cfg.get('caption', {})
|
|
font_ko = cap_cfg.get('font_ko', 'Pretendard')
|
|
font_size = cap_cfg.get('font_size', 48)
|
|
hook_size = cap_cfg.get('hook_font_size', 72)
|
|
default_color = _hex_to_ass(cap_cfg.get('default_color', '#FFFFFF'))
|
|
highlight_color = _hex_to_ass(cap_cfg.get('highlight_color', '#FFD700'))
|
|
outline_color = _hex_to_ass(cap_cfg.get('outline_color', '#000000'))
|
|
outline_w = cap_cfg.get('outline_width', 3)
|
|
margin_v = cap_cfg.get('position_from_bottom', 200)
|
|
|
|
return f"""[Script Info]
|
|
ScriptType: v4.00+
|
|
PlayResX: 1080
|
|
PlayResY: 1920
|
|
ScaledBorderAndShadow: yes
|
|
|
|
[V4+ Styles]
|
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
Style: Default,{font_ko},{font_size},{default_color},{default_color},{outline_color},&H80000000,-1,0,0,0,100,100,0,0,1,{outline_w},1,2,20,20,{margin_v},1
|
|
Style: Highlight,{font_ko},{font_size},{highlight_color},{highlight_color},{outline_color},&H80000000,-1,0,0,0,100,100,0,0,1,{outline_w},1,2,20,20,{margin_v},1
|
|
Style: Hook,{font_ko},{hook_size},{default_color},{default_color},{outline_color},&H80000000,-1,0,0,0,100,100,0,0,1,{outline_w+1},2,5,20,20,100,1
|
|
|
|
[Events]
|
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
"""
|
|
|
|
|
|
# ─── 단어 → 자막 라인 분할 ────────────────────────────────────
|
|
|
|
def _split_into_lines(words: list[dict], max_chars: int = 18) -> list[list[dict]]:
|
|
"""
|
|
단어 리스트 → 라인 리스트 (최대 max_chars 자).
|
|
반환: [[{word, start, end}, ...], ...]
|
|
"""
|
|
lines = []
|
|
cur_line: list[dict] = []
|
|
cur_len = 0
|
|
|
|
for w in words:
|
|
word_text = w['word']
|
|
if cur_line and cur_len + len(word_text) + 1 > max_chars:
|
|
lines.append(cur_line)
|
|
cur_line = [w]
|
|
cur_len = len(word_text)
|
|
else:
|
|
cur_line.append(w)
|
|
cur_len += len(word_text) + (1 if cur_line else 0)
|
|
|
|
if cur_line:
|
|
lines.append(cur_line)
|
|
|
|
return lines
|
|
|
|
|
|
def _line_start_end(line: list[dict]) -> tuple[float, float]:
|
|
return line[0]['start'], line[-1]['end']
|
|
|
|
|
|
# ─── ASS 이벤트 생성 ─────────────────────────────────────────
|
|
|
|
def _word_highlight_event(
|
|
line: list[dict],
|
|
highlight_color_hex: str,
|
|
default_color_hex: str,
|
|
outline_color_hex: str,
|
|
outline_w: int,
|
|
) -> str:
|
|
"""
|
|
한 라인의 모든 단어에 대해 단어별 하이라이트 오버라이드 태그 생성.
|
|
각 단어 재생 시간 동안 해당 단어만 highlight_color로 표시.
|
|
ASS override tag: {\\c&Hxxxxxx&} 로 색상 변경.
|
|
|
|
반환: 단어별 ASS 이벤트 문자열 목록
|
|
"""
|
|
hi_ass = _hex_to_ass(highlight_color_hex)
|
|
df_ass = _hex_to_ass(default_color_hex)
|
|
|
|
events = []
|
|
for i, w in enumerate(line):
|
|
start_t = w['start']
|
|
end_t = w['end']
|
|
|
|
# 전체 라인 텍스트: 현재 단어만 하이라이트
|
|
parts = []
|
|
for j, other in enumerate(line):
|
|
if j == i:
|
|
parts.append(f'{{\\c{hi_ass}}}{other["word"]}{{\\c{df_ass}}}')
|
|
else:
|
|
parts.append(other['word'])
|
|
text = ' '.join(parts)
|
|
|
|
event = (
|
|
f'Dialogue: 0,{_sec_to_ass_time(start_t)},{_sec_to_ass_time(end_t)},'
|
|
f'Default,,0,0,0,,{text}'
|
|
)
|
|
events.append(event)
|
|
|
|
return '\n'.join(events)
|
|
|
|
|
|
def _hook_event(hook_text: str, duration: float = 1.5) -> str:
|
|
"""훅 텍스트 — 중앙 상단, 72px, 1.5초 표시."""
|
|
return (
|
|
f'Dialogue: 1,{_sec_to_ass_time(0.0)},{_sec_to_ass_time(duration)},'
|
|
f'Hook,,0,0,0,,{hook_text}'
|
|
)
|
|
|
|
|
|
# ─── 균등 분할 타임스탬프 폴백 ───────────────────────────────
|
|
|
|
def _build_uniform_timestamps(script: dict, total_duration: float) -> list[dict]:
|
|
"""
|
|
Whisper 타임스탬프 없을 때 텍스트를 균등 시간으로 분할.
|
|
"""
|
|
parts = [script.get('hook', '')]
|
|
parts.extend(script.get('body', []))
|
|
parts.append(script.get('closer', ''))
|
|
text = ' '.join(p for p in parts if p)
|
|
words = text.split()
|
|
|
|
if not words:
|
|
return []
|
|
|
|
dur_per_word = total_duration / len(words)
|
|
return [
|
|
{
|
|
'word': w,
|
|
'start': round(i * dur_per_word, 3),
|
|
'end': round((i + 1) * dur_per_word, 3),
|
|
}
|
|
for i, w in enumerate(words)
|
|
]
|
|
|
|
|
|
# ─── 메인 엔트리포인트 ────────────────────────────────────────
|
|
|
|
def render_captions(
|
|
script: dict,
|
|
timestamps: list[dict],
|
|
output_dir: Path,
|
|
timestamp: str,
|
|
wav_duration: float = 0.0,
|
|
cfg: Optional[dict] = None,
|
|
) -> Path:
|
|
"""
|
|
스크립트 + 단어별 타임스탬프 → ASS 자막 파일 생성.
|
|
|
|
Args:
|
|
script: {hook, body, closer, ...}
|
|
timestamps: [{word, start, end}, ...] — 비어있으면 균등 분할
|
|
output_dir: data/shorts/captions/
|
|
timestamp: 파일명 prefix
|
|
wav_duration: TTS 오디오 총 길이 (균등 분할 폴백용)
|
|
cfg: shorts_config.json dict
|
|
|
|
Returns:
|
|
ass_path
|
|
"""
|
|
if cfg is None:
|
|
cfg = _load_config()
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
ass_path = output_dir / f'{timestamp}.ass'
|
|
|
|
cap_cfg = cfg.get('caption', {})
|
|
max_chars = cap_cfg.get('max_chars_per_line_ko', 18)
|
|
highlight_color = cap_cfg.get('highlight_color', '#FFD700')
|
|
default_color = cap_cfg.get('default_color', '#FFFFFF')
|
|
outline_color = cap_cfg.get('outline_color', '#000000')
|
|
outline_w = cap_cfg.get('outline_width', 3)
|
|
|
|
# 타임스탬프 없으면 균등 분할
|
|
if not timestamps:
|
|
logger.warning('단어별 타임스탬프 없음 — 균등 분할 사용 (캡션 품질 저하)')
|
|
if wav_duration <= 0:
|
|
wav_duration = 20.0
|
|
timestamps = _build_uniform_timestamps(script, wav_duration)
|
|
|
|
# ASS 헤더
|
|
header = _ass_header(cfg)
|
|
events = []
|
|
|
|
# 훅 이벤트 (첫 1.5초 중앙 표시)
|
|
hook_text = script.get('hook', '')
|
|
if hook_text and timestamps:
|
|
hook_end = min(1.5, timestamps[0]['start'] + 1.5) if timestamps else 1.5
|
|
events.append(_hook_event(hook_text, hook_end))
|
|
|
|
# 단어별 하이라이트 이벤트
|
|
lines = _split_into_lines(timestamps, max_chars)
|
|
for line in lines:
|
|
if not line:
|
|
continue
|
|
line_event = _word_highlight_event(
|
|
line, highlight_color, default_color, outline_color, outline_w
|
|
)
|
|
events.append(line_event)
|
|
|
|
ass_content = header + '\n'.join(events) + '\n'
|
|
ass_path.write_text(ass_content, encoding='utf-8-sig') # BOM for Windows compatibility
|
|
logger.info(f'ASS 자막 생성: {ass_path.name} ({len(timestamps)}단어, {len(lines)}라인)')
|
|
return ass_path
|