feat: v3.0 엔진 추상화 + 소설 파이프라인 추가

[1순위] 엔진 추상화 리팩토링
- config/engine.json: 단일 설정 파일로 writing/tts/image/video/publishing 엔진 제어
- bots/engine_loader.py: EngineLoader 팩토리 클래스 (Claude/OpenClaw/Gemini Writer, gTTS/GoogleCloud/OpenAI/ElevenLabs TTS, DALL-E/External 이미지)

[2순위] VideoEngine 추상화
- bots/converters/video_engine.py: VideoEngine ABC + FFmpegSlidesEngine/SeedanceEngine/SoraEngine/RunwayEngine/VeoEngine 구현
- Seedance 2.0 API 연동 + 실패 시 ffmpeg_slides 자동 fallback

[3순위] 소설 연재 파이프라인
- bots/novel/novel_writer.py: AI 에피소드 자동 생성 (Claude/엔진 추상화)
- bots/novel/novel_blog_converter.py: 에피소드 → 장르별 테마 Blogger HTML
- bots/novel/novel_shorts_converter.py: key_scenes → TTS + Pillow + VideoEngine → MP4
- bots/novel/novel_manager.py: 전체 파이프라인 조율 + Telegram 명령 처리
- config/novels/shadow-protocol.json: 예시 소설 설정 (2040 서울 SF 스릴러)

[스케줄러] 소설 파이프라인 통합
- 매주 월/목 09:00 자동 실행 (job_novel_pipeline)
- Telegram 명령: /novel_list, /novel_gen, /novel_status

[기타 수정]
- collector_bot.py: 한국어 유니코드 감지 + RSS 신뢰도 override 버그 수정
- quality_rules.json: min_score 70→60
- scripts/get_token.py: YouTube OAuth scope 추가
- .env.example: SEEDANCE/ELEVENLABS/GEMINI/RUNWAY API 키 항목 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-26 09:33:04 +09:00
parent b54f8e198e
commit 8a7a122bb3
16 changed files with 3487 additions and 8 deletions
View File
+341
View File
@@ -0,0 +1,341 @@
"""
novel_blog_converter.py
소설 연재 파이프라인 — 에피소드 → Blogger-ready HTML 변환 모듈
역할: 에피소드 dict + 소설 설정 → 장르별 테마 HTML 생성
출력: data/novels/{novel_id}/episodes/ep{N:03d}_blog.html
"""
import json
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
logger = logging.getLogger(__name__)
BLOG_BASE_URL = 'https://the4thpath.com'
# ─── 장르별 컬러 테마 ─────────────────────────────────────────────────────────
GENRE_THEMES = {
'sci-fi': {
'bg': '#0a0f1e',
'accent': '#00bcd4',
'accent_dim': '#007c8c',
'card_bg': '#0e1628',
'text': '#cfe8ef',
'meta': '#6fa8bc',
'nav_bg': '#0c1220',
},
'thriller': {
'bg': '#0a0a0d',
'accent': '#bf3a3a',
'accent_dim': '#8a2222',
'card_bg': '#141418',
'text': '#e8e0e0',
'meta': '#a08080',
'nav_bg': '#111115',
},
'fantasy': {
'bg': '#0f0a1e',
'accent': '#c8a84e',
'accent_dim': '#8a7030',
'card_bg': '#180f2e',
'text': '#e8e0f0',
'meta': '#9a8ab0',
'nav_bg': '#130c22',
},
'romance': {
'bg': '#ffffff',
'accent': '#d85a30',
'accent_dim': '#a04020',
'card_bg': '#fff5f0',
'text': '#2a1a14',
'meta': '#8a5a4a',
'nav_bg': '#fff0ea',
},
'default': {
'bg': '#0a0a0d',
'accent': '#c8a84e',
'accent_dim': '#8a7030',
'card_bg': '#141418',
'text': '#e8e0d0',
'meta': '#a09070',
'nav_bg': '#111115',
},
}
def _get_theme(genre: str) -> dict:
"""장르 문자열에서 테마 결정 (부분 매칭 포함)"""
genre_lower = genre.lower()
for key in GENRE_THEMES:
if key in genre_lower:
return GENRE_THEMES[key]
return GENRE_THEMES['default']
def _build_json_ld(episode: dict, novel_config: dict, post_url: str = '') -> str:
"""Schema.org Article JSON-LD 생성"""
schema = {
'@context': 'https://schema.org',
'@type': 'Article',
'headline': f"{novel_config.get('title_ko', '')} {episode.get('episode_num', 0)}화 — {episode.get('title', '')}",
'description': episode.get('hook', ''),
'datePublished': datetime.now(timezone.utc).isoformat(),
'dateModified': datetime.now(timezone.utc).isoformat(),
'author': {
'@type': 'Person',
'name': 'The 4th Path'
},
'publisher': {
'@type': 'Organization',
'name': 'The 4th Path',
'logo': {
'@type': 'ImageObject',
'url': f'{BLOG_BASE_URL}/logo.png'
}
},
'mainEntityOfPage': {
'@type': 'WebPage',
'@id': post_url or BLOG_BASE_URL
},
'genre': novel_config.get('genre', ''),
'isPartOf': {
'@type': 'CreativeWorkSeries',
'name': novel_config.get('title_ko', ''),
'position': episode.get('episode_num', 0)
}
}
return (
'<script type="application/ld+json">\n'
+ json.dumps(schema, ensure_ascii=False, indent=2)
+ '\n</script>'
)
def _body_to_html(body_text: str) -> str:
"""소설 본문 텍스트 → HTML 단락 변환 (빈 줄 기준 분리)"""
paragraphs = []
for para in body_text.split('\n\n'):
para = para.strip()
if not para:
continue
# 대화문 들여쓰기 처리
lines = para.split('\n')
html_lines = []
for line in lines:
line = line.strip()
if not line:
continue
# HTML 특수문자 이스케이프
line = (line.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
# 대화문 (따옴표 시작) 스타일 적용
if line.startswith('"') or line.startswith('"') or line.startswith('"'):
html_lines.append(
f'<span style="color:inherit;opacity:0.9">{line}</span>'
)
else:
html_lines.append(line)
paragraphs.append('<br>\n'.join(html_lines))
return '\n'.join(
f'<p style="margin:0 0 1.6em 0;line-height:1.9;letter-spacing:0.01em">{p}</p>'
for p in paragraphs if p
)
def convert(
episode: dict,
novel_config: dict,
prev_url: str = '',
next_url: str = '',
save_file: bool = True
) -> str:
"""
에피소드 + 소설 설정 → Blogger-ready HTML.
data/novels/{novel_id}/episodes/ep{N:03d}_blog.html 저장.
반환: HTML 문자열
"""
novel_id = novel_config.get('novel_id', episode.get('novel_id', 'unknown'))
ep_num = episode.get('episode_num', 0)
title = episode.get('title', f'에피소드 {ep_num}')
body_text = episode.get('body', '')
hook = episode.get('hook', '')
genre = novel_config.get('genre', '')
title_ko = novel_config.get('title_ko', '')
logger.info(f"[{novel_id}] 에피소드 {ep_num} 블로그 변환 시작")
theme = _get_theme(genre)
bg = theme['bg']
accent = theme['accent']
accent_dim = theme['accent_dim']
card_bg = theme['card_bg']
text_color = theme['text']
meta_color = theme['meta']
nav_bg = theme['nav_bg']
# 다음 에피소드 예정일 (publish_schedule 파싱 — 간단 처리)
next_date_str = '다음 회 예고'
try:
schedule = novel_config.get('publish_schedule', '')
# "매주 월/목 09:00" 형식에서 요일 추출
if schedule:
next_date_str = schedule.replace('매주 ', '').replace('09:00', '').strip()
except Exception:
pass
# 본문 HTML
body_html = _body_to_html(body_text)
# JSON-LD
post_url = ''
json_ld = _build_json_ld(episode, novel_config, post_url)
# 이전/다음 네비게이션
prev_link = (
f'<a href="{prev_url}" style="color:{accent};text-decoration:none;font-size:14px">&#8592; {ep_num - 1}화</a>'
if prev_url and ep_num > 1
else f'<span style="color:{meta_color};font-size:14px">첫 번째 에피소드</span>'
)
next_link = (
f'<a href="{next_url}" style="color:{accent};text-decoration:none;font-size:14px">{ep_num + 1}화 &#8594;</a>'
if next_url
else f'<span style="color:{meta_color};font-size:14px">다음 회 업데이트 예정</span>'
)
# 전체 HTML 조립
html = f"""{json_ld}
<style>
.post-title {{ display:none!important }}
</style>
<div style="max-width:680px;margin:0 auto;padding:24px 16px;background:{bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Malgun Gothic','Apple SD Gothic Neo',sans-serif;color:{text_color}">
<!-- 에피소드 배지 -->
<div style="margin-bottom:20px">
<span style="display:inline-block;background:{accent};color:#fff;font-size:12px;font-weight:700;letter-spacing:0.08em;padding:5px 14px;border-radius:20px;text-transform:uppercase">
연재소설 · 에피소드 {ep_num}
</span>
</div>
<!-- 소설 제목 -->
<p style="margin:0 0 6px 0;font-size:13px;color:{meta_color};letter-spacing:0.05em;font-weight:600">
{title_ko}
</p>
<!-- 에피소드 제목 -->
<h1 style="margin:0 0 20px 0;font-size:28px;font-weight:800;line-height:1.3;color:#fff;letter-spacing:-0.01em">
{title}
</h1>
<!-- 메타 정보 -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:32px;padding-bottom:20px;border-bottom:1px solid {accent_dim}40">
<span style="font-size:13px;color:{meta_color}">{genre}</span>
<span style="color:{accent_dim}">·</span>
<span style="font-size:13px;color:{meta_color}">{novel_config.get('episode_length','')}</span>
<span style="color:{accent_dim}">·</span>
<span style="font-size:13px;color:{meta_color}">{datetime.now().strftime('%Y.%m.%d')}</span>
</div>
<!-- 에피소드 이전/다음 네비게이션 (상단) -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:32px;padding:14px 18px;background:{nav_bg};border-radius:10px;border:1px solid {accent_dim}30">
{prev_link}
<span style="font-size:12px;color:{meta_color};font-weight:600">{ep_num}화</span>
{next_link}
</div>
<!-- 본문 -->
<div style="font-size:17px;line-height:1.9;color:{text_color}">
{body_html}
</div>
<!-- 구분선 -->
<div style="margin:40px 0 32px 0;height:2px;background:linear-gradient(to right,{accent},{accent}00)"></div>
<!-- 클로징 박스: 다음 에피소드 예고 -->
<div style="background:{card_bg};border-left:4px solid {accent};border-radius:0 12px 12px 0;padding:20px 22px;margin-bottom:32px">
<p style="margin:0 0 8px 0;font-size:12px;font-weight:700;color:{accent};letter-spacing:0.08em;text-transform:uppercase">
다음 에피소드 예고 · {next_date_str}
</p>
<p style="margin:0;font-size:15px;line-height:1.7;color:{text_color}">
{hook if hook else '다음 회를 기대해 주세요.'}
</p>
</div>
<!-- AdSense 슬롯 -->
<!-- AD_SLOT_NOVEL -->
<!-- 에피소드 이전/다음 네비게이션 (하단) -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:32px;padding:14px 18px;background:{nav_bg};border-radius:10px;border:1px solid {accent_dim}30">
{prev_link}
<a href="#" style="color:{meta_color};text-decoration:none;font-size:13px">&#8593; 목록</a>
{next_link}
</div>
<!-- 소설 정보 푸터 -->
<div style="margin-top:32px;padding:20px;background:{card_bg};border-radius:12px;border:1px solid {accent_dim}20">
<p style="margin:0 0 8px 0;font-size:13px;font-weight:700;color:{accent}">
{title_ko} 정보
</p>
<p style="margin:0 0 6px 0;font-size:13px;color:{meta_color}">
장르: {genre} · 목표 {novel_config.get('episode_count_target', 20)}화 완결
</p>
<p style="margin:0;font-size:12px;color:{meta_color}">
연재 일정: {novel_config.get('publish_schedule', '')} · The 4th Path
</p>
</div>
</div>"""
if save_file:
output_dir = BASE_DIR / 'data' / 'novels' / novel_id / 'episodes'
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f'ep{ep_num:03d}_blog.html'
try:
output_path.write_text(html, encoding='utf-8')
logger.info(f"블로그 HTML 저장: {output_path}")
except Exception as e:
logger.error(f"블로그 HTML 저장 실패: {e}")
logger.info(f"[{novel_id}] 에피소드 {ep_num} 블로그 변환 완료")
return html
# ─── 직접 실행 테스트 ─────────────────────────────────────────────────────────
if __name__ == '__main__':
import sys as _sys
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
# 샘플 테스트
sample_config = json.loads(
(BASE_DIR / 'config' / 'novels' / 'shadow-protocol.json')
.read_text(encoding='utf-8')
)
sample_episode = {
'novel_id': 'shadow-protocol',
'episode_num': 1,
'title': '프로토콜',
'body': '빗소리가 유리창을 두드렸다.\n\n서진은 모니터를 응시했다.',
'hook': '아리아의 목소리가 처음으로 떨렸다.',
'key_scenes': [
'서진이 빗속에서 데이터를 분석하는 장면',
'아리아가 이상 신호를 감지하는 장면',
'감시 드론이 서진의 아파트 창문을 지나가는 장면',
],
'summary': '서진은 오라클 시스템에서 숨겨진 프로토콜을 발견한다.',
'generated_at': '2026-03-26T00:00:00+00:00',
}
html = convert(sample_episode, sample_config)
print(f"HTML 생성 완료: {len(html)}")
+507
View File
@@ -0,0 +1,507 @@
"""
novel_manager.py
소설 연재 파이프라인 — 연재 관리 + Telegram 명령어 처리 모듈
역할: 소설 목록 관리, 에피소드 파이프라인 실행, 스케줄 조정, Telegram 응답
"""
import json
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
sys.path.insert(0, str(BASE_DIR / 'bots' / 'novel'))
logger = logging.getLogger(__name__)
if not logger.handlers:
logs_dir = BASE_DIR / 'logs'
logs_dir.mkdir(exist_ok=True)
handler = logging.FileHandler(logs_dir / 'novel.log', encoding='utf-8')
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
logger.addHandler(handler)
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.INFO)
# ─── NovelManager ────────────────────────────────────────────────────────────
class NovelManager:
"""config/novels/*.json 전체를 관리하고 파이프라인을 실행하는 클래스."""
def __init__(self):
self.novels_config_dir = BASE_DIR / 'config' / 'novels'
self.novels_data_dir = BASE_DIR / 'data' / 'novels'
self.novels_config_dir.mkdir(parents=True, exist_ok=True)
self.novels_data_dir.mkdir(parents=True, exist_ok=True)
# ── 소설 목록 조회 ────────────────────────────────────────────────────────
def get_all_novels(self) -> list:
"""config/novels/*.json 전체 로드"""
novels = []
for path in sorted(self.novels_config_dir.glob('*.json')):
try:
data = json.loads(path.read_text(encoding='utf-8'))
novels.append(data)
except Exception as e:
logger.error(f"소설 설정 로드 실패 ({path.name}): {e}")
return novels
def get_active_novels(self) -> list:
"""status == 'active' 인 소설만 반환"""
return [n for n in self.get_all_novels() if n.get('status') == 'active']
def get_due_novels(self) -> list:
"""
오늘 발행 예정인 소설 반환.
publish_schedule 예: "매주 월/목 09:00"
"""
today_weekday = datetime.now().weekday() # 0=월, 1=화, ..., 6=일
_KO_DAY_MAP = {
'': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6
}
due = []
for novel in self.get_active_novels():
schedule = novel.get('publish_schedule', '')
try:
# "매주 월/목 09:00" 형식 파싱
parts = schedule.replace('매주 ', '').split(' ')
days_part = parts[0] if parts else ''
days = [d.strip() for d in days_part.split('/')]
for day in days:
if _KO_DAY_MAP.get(day) == today_weekday:
due.append(novel)
break
except Exception as e:
logger.warning(f"스케줄 파싱 실패 ({novel.get('novel_id')}): {e}")
return due
# ── 파이프라인 실행 ───────────────────────────────────────────────────────
def run_episode_pipeline(self, novel_id: str,
telegram_notify: bool = True) -> bool:
"""
완전한 에피소드 파이프라인:
1. NovelWriter.generate_episode()
2. NovelBlogConverter.convert()
3. NovelShortsConverter.generate()
4. publisher_bot으로 블로그 발행
5. 성공 시 Telegram 알림
반환: 성공 여부
"""
logger.info(f"[{novel_id}] 에피소드 파이프라인 시작")
# 소설 설정 로드
config_path = self.novels_config_dir / f'{novel_id}.json'
if not config_path.exists():
logger.error(f"소설 설정 없음: {config_path}")
return False
try:
novel_config = json.loads(config_path.read_text(encoding='utf-8'))
except Exception as e:
logger.error(f"소설 설정 로드 실패: {e}")
return False
# 데이터 디렉터리 생성
self.create_novel_dirs(novel_id)
# ── Step 1: 에피소드 생성 ─────────────────────────────────────────────
episode = None
try:
from novel_writer import NovelWriter
writer = NovelWriter(novel_id)
episode = writer.generate_episode()
if not episode:
logger.error(f"[{novel_id}] 에피소드 생성 실패")
return False
logger.info(f"[{novel_id}] 에피소드 {episode['episode_num']} 생성 완료")
except Exception as e:
logger.error(f"[{novel_id}] Step 1 (에피소드 생성) 실패: {e}")
return False
# ── Step 2: 블로그 HTML 변환 ──────────────────────────────────────────
html = ''
try:
from novel_blog_converter import convert as blog_convert
html = blog_convert(episode, novel_config, save_file=True)
logger.info(f"[{novel_id}] 블로그 HTML 변환 완료")
except Exception as e:
logger.error(f"[{novel_id}] Step 2 (블로그 변환) 실패: {e}")
# ── Step 3: 쇼츠 영상 생성 ───────────────────────────────────────────
shorts_path = ''
try:
from novel_shorts_converter import NovelShortsConverter
converter = NovelShortsConverter()
shorts_path = converter.generate(episode, novel_config)
if shorts_path:
logger.info(f"[{novel_id}] 쇼츠 생성 완료: {shorts_path}")
else:
logger.warning(f"[{novel_id}] 쇼츠 생성 실패 (계속 진행)")
except Exception as e:
logger.error(f"[{novel_id}] Step 3 (쇼츠 생성) 실패: {e}")
# ── Step 4: 블로그 발행 ───────────────────────────────────────────────
publish_ok = False
if html:
try:
publish_ok = self._publish_episode(episode, novel_config, html)
if publish_ok:
logger.info(f"[{novel_id}] 블로그 발행 완료")
else:
logger.warning(f"[{novel_id}] 블로그 발행 실패")
except Exception as e:
logger.error(f"[{novel_id}] Step 4 (발행) 실패: {e}")
# ── Step 5: Telegram 알림 ─────────────────────────────────────────────
if telegram_notify:
try:
ep_num = episode.get('episode_num', 0)
title = episode.get('title', '')
msg = (
f"소설 연재 완료!\n"
f"제목: {novel_config.get('title_ko', novel_id)}\n"
f"에피소드: {ep_num}화 — {title}\n"
f"블로그: {'발행 완료' if publish_ok else '발행 실패'}\n"
f"쇼츠: {'생성 완료' if shorts_path else '생성 실패'}"
)
self._send_telegram(msg)
except Exception as e:
logger.warning(f"Telegram 알림 실패: {e}")
success = episode is not None
logger.info(f"[{novel_id}] 파이프라인 완료 (성공={success})")
return success
# ── 소설 상태 조회 ────────────────────────────────────────────────────────
def get_novel_status(self, novel_id: str) -> dict:
"""소설 현황 반환 (에피소드 수, 마지막 발행일, 다음 예정일 등)"""
config_path = self.novels_config_dir / f'{novel_id}.json'
if not config_path.exists():
return {}
try:
config = json.loads(config_path.read_text(encoding='utf-8'))
except Exception as e:
logger.error(f"소설 설정 로드 실패: {e}")
return {}
episodes_dir = self.novels_data_dir / novel_id / 'episodes'
ep_files = list(episodes_dir.glob('ep*.json')) if episodes_dir.exists() else []
# 요약/블로그 파일 제외 (ep001.json 만 카운트)
ep_files = [
f for f in ep_files
if '_summary' not in f.name and '_blog' not in f.name
]
last_ep_date = ''
if config.get('episode_log'):
last_log = config['episode_log'][-1]
last_ep_date = last_log.get('generated_at', '')[:10]
return {
'novel_id': novel_id,
'title_ko': config.get('title_ko', ''),
'status': config.get('status', 'unknown'),
'current_episode': config.get('current_episode', 0),
'episode_count_target': config.get('episode_count_target', 0),
'episode_files': len(ep_files),
'last_published': last_ep_date,
'publish_schedule': config.get('publish_schedule', ''),
'genre': config.get('genre', ''),
}
def list_novels_text(self) -> str:
"""Telegram용 소설 목록 텍스트 반환"""
novels = self.get_all_novels()
if not novels:
return '등록된 소설이 없습니다.'
lines = ['소설 목록:\n']
for n in novels:
status_label = '연재중' if n.get('status') == 'active' else '중단'
lines.append(
f"[{status_label}] {n.get('title_ko', n.get('novel_id', ''))}\n"
f" 장르: {n.get('genre', '')} | "
f"{n.get('current_episode', 0)}/{n.get('episode_count_target', 0)}화 | "
f"{n.get('publish_schedule', '')}\n"
)
return '\n'.join(lines)
# ── 디렉터리 생성 ─────────────────────────────────────────────────────────
def create_novel_dirs(self, novel_id: str):
"""data/novels/{novel_id}/episodes/, shorts/, images/ 폴더 생성"""
base = self.novels_data_dir / novel_id
for sub in ['episodes', 'shorts', 'images']:
(base / sub).mkdir(parents=True, exist_ok=True)
logger.info(f"[{novel_id}] 데이터 디렉터리 생성: {base}")
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────
def _publish_episode(self, episode: dict, novel_config: dict,
html: str) -> bool:
"""publisher_bot을 통해 블로그 발행"""
try:
import publisher_bot
novel_id = novel_config.get('novel_id', '')
ep_num = episode.get('episode_num', 0)
title_ko = novel_config.get('title_ko', '')
article = {
'title': f"{title_ko} {ep_num}화 — {episode.get('title', '')}",
'body': html,
'_body_is_html': True,
'_html_content': html,
'corner': '연재소설',
'slug': f"{novel_id}-ep{ep_num:03d}",
'labels': ['연재소설', title_ko, f'에피소드{ep_num}'],
}
return publisher_bot.publish(article)
except ImportError:
logger.warning("publisher_bot 없음 — 발행 건너뜀")
return False
except Exception as e:
logger.error(f"발행 실패: {e}")
return False
def _send_telegram(self, message: str):
"""Telegram 메시지 전송"""
try:
import telegram_bot
telegram_bot.send_message(message)
except ImportError:
logger.warning("telegram_bot 없음 — 알림 건너뜀")
except Exception as e:
logger.warning(f"Telegram 전송 실패: {e}")
def _update_novel_status(self, novel_id: str, status: str) -> bool:
"""소설 status 필드 업데이트 (active / paused)"""
config_path = self.novels_config_dir / f'{novel_id}.json'
if not config_path.exists():
return False
try:
config = json.loads(config_path.read_text(encoding='utf-8'))
config['status'] = status
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding='utf-8'
)
logger.info(f"[{novel_id}] status 변경: {status}")
return True
except Exception as e:
logger.error(f"소설 status 업데이트 실패: {e}")
return False
def _find_novel_by_title(self, title_query: str) -> str:
"""소설 제목(한국어 or 영어) 또는 novel_id로 검색 — novel_id 반환"""
title_query = title_query.strip()
for novel in self.get_all_novels():
if (title_query in novel.get('title_ko', '')
or title_query in novel.get('title', '')
or title_query == novel.get('novel_id', '')):
return novel.get('novel_id', '')
return ''
def run_all(self) -> list:
"""오늘 발행 예정인 모든 활성 소설 파이프라인 실행 (스케줄러용)"""
results = []
for novel in self.get_due_novels():
novel_id = novel.get('novel_id', '')
ok = self.run_episode_pipeline(novel_id, telegram_notify=True)
results.append({'novel_id': novel_id, 'success': ok})
return results
# ─── Telegram 명령 처리 함수 (scheduler.py에서 호출) ─────────────────────────
def handle_novel_command(text: str) -> str:
"""
Telegram 소설 명령어 처리.
지원 명령:
"소설 새로 만들기"
"소설 목록"
"소설 {제목} 다음 에피소드"
"소설 {제목} 현황"
"소설 {제목} 중단"
"소설 {제목} 재개"
반환: 응답 문자열
"""
manager = NovelManager()
text = text.strip()
# ── "소설 목록" ───────────────────────────────────────────────────────────
if text in ('소설 목록', '소설목록'):
return manager.list_novels_text()
# ── "소설 새로 만들기" ────────────────────────────────────────────────────
if '새로 만들기' in text or '새로만들기' in text:
return (
'새 소설 설정 방법:\n\n'
'1. config/novels/ 폴더에 {novel_id}.json 파일 생성\n'
'2. 필수 필드: novel_id, title, title_ko, genre,\n'
' setting, characters, base_story, publish_schedule\n'
'3. status: "active" 로 설정하면 자동 연재 시작\n\n'
'예시: config/novels/shadow-protocol.json 참고'
)
# ── "소설 {제목} 다음 에피소드" ───────────────────────────────────────────
if '다음 에피소드' in text:
title_query = (text.replace('소설', '', 1)
.replace('다음 에피소드', '')
.strip())
if not title_query:
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 다음 에피소드'
novel_id = manager._find_novel_by_title(title_query)
if not novel_id:
return (
f'"{title_query}" 소설을 찾을 수 없습니다.\n'
'소설 목록을 확인해 주세요.'
)
status_info = manager.get_novel_status(novel_id)
if status_info.get('status') != 'active':
return f'"{title_query}" 소설은 현재 연재 중단 상태입니다.'
try:
ok = manager.run_episode_pipeline(novel_id, telegram_notify=False)
if ok:
updated = manager.get_novel_status(novel_id)
ep = updated.get('current_episode', 0)
return (
f"에피소드 {ep}화 생성 및 발행 완료!\n"
f"소설: {updated.get('title_ko', novel_id)}"
)
else:
return '에피소드 생성 실패. 로그를 확인해 주세요.'
except Exception as e:
logger.error(f"에피소드 파이프라인 오류: {e}")
return f'오류 발생: {e}'
# ── "소설 {제목} 현황" ────────────────────────────────────────────────────
if '현황' in text:
title_query = text.replace('소설', '', 1).replace('현황', '').strip()
if not title_query:
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 현황'
novel_id = manager._find_novel_by_title(title_query)
if not novel_id:
return f'"{title_query}" 소설을 찾을 수 없습니다.'
s = manager.get_novel_status(novel_id)
if not s:
return f'"{title_query}" 현황을 불러올 수 없습니다.'
return (
f"소설 현황: {s.get('title_ko', novel_id)}\n\n"
f"상태: {s.get('status', '')}\n"
f"현재 에피소드: {s.get('current_episode', 0)}화 / "
f"목표 {s.get('episode_count_target', 0)}\n"
f"마지막 발행: {s.get('last_published', '없음')}\n"
f"연재 일정: {s.get('publish_schedule', '')}\n"
f"장르: {s.get('genre', '')}"
)
# ── "소설 {제목} 중단" ────────────────────────────────────────────────────
if '중단' in text:
title_query = text.replace('소설', '', 1).replace('중단', '').strip()
if not title_query:
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 중단'
novel_id = manager._find_novel_by_title(title_query)
if not novel_id:
return f'"{title_query}" 소설을 찾을 수 없습니다.'
ok = manager._update_novel_status(novel_id, 'paused')
if ok:
try:
config = json.loads(
(manager.novels_config_dir / f'{novel_id}.json')
.read_text(encoding='utf-8')
)
return f'"{config.get("title_ko", novel_id)}" 연재를 일시 중단했습니다.'
except Exception:
return '중단 처리 완료.'
else:
return '중단 처리 실패. 로그를 확인해 주세요.'
# ── "소설 {제목} 재개" ────────────────────────────────────────────────────
if '재개' in text:
title_query = text.replace('소설', '', 1).replace('재개', '').strip()
if not title_query:
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 재개'
novel_id = manager._find_novel_by_title(title_query)
if not novel_id:
return f'"{title_query}" 소설을 찾을 수 없습니다.'
ok = manager._update_novel_status(novel_id, 'active')
if ok:
try:
config = json.loads(
(manager.novels_config_dir / f'{novel_id}.json')
.read_text(encoding='utf-8')
)
return (
f'"{config.get("title_ko", novel_id)}" 연재를 재개합니다.\n'
f'연재 일정: {config.get("publish_schedule", "")}'
)
except Exception:
return '재개 처리 완료.'
else:
return '재개 처리 실패. 로그를 확인해 주세요.'
# ── 알 수 없는 명령 ───────────────────────────────────────────────────────
return (
'소설 명령어 목록:\n\n'
'소설 목록\n'
'소설 새로 만들기\n'
'소설 {제목} 다음 에피소드\n'
'소설 {제목} 현황\n'
'소설 {제목} 중단\n'
'소설 {제목} 재개'
)
# ─── 직접 실행 테스트 ─────────────────────────────────────────────────────────
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
manager = NovelManager()
print("=== 전체 소설 목록 ===")
print(manager.list_novels_text())
print("\n=== 활성 소설 ===")
for n in manager.get_active_novels():
print(f" - {n.get('title_ko')} ({n.get('novel_id')})")
print("\n=== 오늘 발행 예정 소설 ===")
due = manager.get_due_novels()
if due:
for n in due:
print(f" - {n.get('title_ko')}")
else:
print(" (없음)")
print("\n=== shadow-protocol 현황 ===")
status = manager.get_novel_status('shadow-protocol')
print(json.dumps(status, ensure_ascii=False, indent=2))
print("\n=== Telegram 명령 테스트 ===")
for cmd in ['소설 목록', '소설 그림자 프로토콜 현황', '소설 잘못된제목 현황']:
print(f"\n명령: {cmd}")
print(handle_novel_command(cmd))
+684
View File
@@ -0,0 +1,684 @@
"""
novel_shorts_converter.py
소설 연재 파이프라인 — 에피소드 KEY_SCENES → 쇼츠 영상 생성 모듈
역할: key_scenes 3개를 기반으로 engine.json 설정에 따라 영상 생성
출력: data/novels/{novel_id}/shorts/ep{N:03d}_shorts.mp4
지원 모드:
ffmpeg_slides — DALL-E 이미지 + TTS + Pillow 슬라이드 (기본, 비용 0원)
seedance — Seedance 2.0 API로 시네마틱 영상 생성 (유료)
"""
import json
import logging
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
sys.path.insert(0, str(BASE_DIR / 'bots' / 'converters'))
logger = logging.getLogger(__name__)
FFMPEG = os.getenv('FFMPEG_PATH', 'ffmpeg')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
SEEDANCE_API_KEY = os.getenv('SEEDANCE_API_KEY', '')
# ─── EngineLoader / fallback ─────────────────────────────────────────────────
try:
from engine_loader import EngineLoader as _EngineLoader
_engine_loader_available = True
except ImportError:
_engine_loader_available = False
def _make_fallback_writer():
"""engine_loader 없을 때 anthropic SDK 직접 사용"""
import anthropic
client = anthropic.Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY', ''))
class _DirectWriter:
def write(self, prompt: str, system: str = '') -> str:
msg = client.messages.create(
model='claude-opus-4-6',
max_tokens=500,
system=system or '당신은 한국어 연재소설 작가입니다.',
messages=[{'role': 'user', 'content': prompt}],
)
return msg.content[0].text
return _DirectWriter()
# shorts_converter 유틸 임포트 (재사용)
try:
from shorts_converter import (
make_clip,
concat_clips_xfade,
mix_bgm,
burn_subtitles,
synthesize_section,
solid_background,
_load_font,
_text_size,
_draw_gradient_overlay,
)
_shorts_utils_available = True
except ImportError:
_shorts_utils_available = False
logger.warning("shorts_converter 임포트 실패 — ffmpeg_slides 모드 제한됨")
# ─── 이미지 생성 헬퍼 ─────────────────────────────────────────────────────────
def _generate_dalle_image(prompt: str, save_path: str) -> bool:
"""DALL-E 3로 장면 이미지 생성 (1024×1792)"""
if not OPENAI_API_KEY:
logger.warning("OPENAI_API_KEY 없음 — 단색 배경 사용")
return False
try:
import io
import requests as req
from openai import OpenAI
from PIL import Image
client = OpenAI(api_key=OPENAI_API_KEY)
full_prompt = prompt + ' No text, no letters, no numbers, no watermarks. Vertical 9:16 cinematic.'
response = client.images.generate(
model='dall-e-3',
prompt=full_prompt,
size='1024x1792',
quality='standard',
n=1,
)
img_url = response.data[0].url
img_bytes = req.get(img_url, timeout=30).content
img = Image.open(io.BytesIO(img_bytes)).convert('RGB')
img = img.resize((1080, 1920))
img.save(save_path)
logger.info(f"DALL-E 이미지 생성: {save_path}")
return True
except Exception as e:
logger.warning(f"DALL-E 이미지 생성 실패: {e}")
return False
def _make_solid_slide(save_path: str, color=(10, 10, 13)):
"""단색 슬라이드 PNG 생성"""
try:
from PIL import Image
img = Image.new('RGB', (1080, 1920), color)
img.save(save_path)
except Exception as e:
logger.error(f"단색 슬라이드 생성 실패: {e}")
def _make_text_slide(save_path: str, text: str, bg_color=(10, 10, 13),
accent_color=(200, 168, 78)):
"""텍스트 오버레이 슬라이드 PNG 생성"""
try:
from PIL import Image, ImageDraw
img = Image.new('RGB', (1080, 1920), bg_color)
draw = ImageDraw.Draw(img)
W, H = 1080, 1920
# 상단 강조선
draw.rectangle([0, 0, W, 6], fill=accent_color)
# 텍스트
font = _load_font(52, bold=True) if _shorts_utils_available else None
if font:
words = text.split()
lines = []
current = ''
for word in words:
test = (current + ' ' + word).strip()
w, _ = _text_size(draw, test, font)
if w <= W - 120:
current = test
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
y = H // 2 - len(lines) * 60 // 2
for line in lines[:5]:
lw, lh = _text_size(draw, line, font)
draw.text(((W - lw) // 2, y), line, font=font, fill=(255, 255, 255))
y += lh + 16
# 하단 강조선
draw.rectangle([0, H - 6, W, H], fill=accent_color)
img.save(save_path)
except Exception as e:
logger.error(f"텍스트 슬라이드 생성 실패: {e}")
# ─── Seedance API 헬퍼 ────────────────────────────────────────────────────────
def _call_seedance_api(prompt: str, duration: str = '10s',
resolution: str = '1080x1920') -> str:
"""
Seedance 2.0 API 호출 → 영상 다운로드 후 임시 경로 반환.
실패 시 '' 반환.
"""
if not SEEDANCE_API_KEY:
logger.warning("SEEDANCE_API_KEY 없음 — seedance 모드 불가")
return ''
try:
import requests as req
# engine.json에서 api_url 읽기
engine_config_path = BASE_DIR / 'config' / 'engine.json'
api_url = 'https://api.seedance2.ai/v1/generate'
if engine_config_path.exists():
cfg = json.loads(engine_config_path.read_text(encoding='utf-8'))
api_url = (cfg.get('video_generation', {})
.get('options', {})
.get('seedance', {})
.get('api_url', api_url))
headers = {
'Authorization': f'Bearer {SEEDANCE_API_KEY}',
'Content-Type': 'application/json',
}
payload = {
'prompt': prompt,
'resolution': resolution,
'duration': duration,
'audio': True,
}
logger.info(f"Seedance API 요청: {prompt[:80]}...")
resp = req.post(api_url, json=payload, headers=headers, timeout=120)
resp.raise_for_status()
data = resp.json()
# 영상 URL 파싱 (실제 API 응답 구조에 따라 조정 필요)
video_url = data.get('url') or data.get('video_url') or data.get('output')
if not video_url:
logger.error(f"Seedance 응답에 URL 없음: {data}")
return ''
# 영상 다운로드
tmp_path = tempfile.mktemp(suffix='.mp4')
video_resp = req.get(video_url, timeout=180)
video_resp.raise_for_status()
Path(tmp_path).write_bytes(video_resp.content)
logger.info(f"Seedance 영상 다운로드 완료: {tmp_path}")
return tmp_path
except Exception as e:
logger.error(f"Seedance API 호출 실패: {e}")
return ''
def _run_ffmpeg(args: list) -> bool:
"""ffmpeg 실행 헬퍼"""
cmd = [FFMPEG, '-y', '-loglevel', 'error'] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
logger.error(f"ffmpeg 오류: {result.stderr[-400:]}")
return result.returncode == 0
except Exception as e:
logger.error(f"ffmpeg 실행 실패: {e}")
return False
def _get_clip_duration(mp4_path: str) -> float:
"""ffprobe로 영상 길이(초) 측정"""
try:
import subprocess
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_format', mp4_path],
capture_output=True, text=True, timeout=10
)
data = json.loads(result.stdout)
return float(data['format']['duration'])
except Exception:
return 10.0
# ─── 테마 색상 헬퍼 ───────────────────────────────────────────────────────────
_GENRE_COLORS = {
'sci-fi': {'bg': (10, 15, 30), 'accent': (0, 188, 212)},
'thriller': {'bg': (10, 10, 13), 'accent': (191, 58, 58)},
'fantasy': {'bg': (15, 10, 30), 'accent': (200, 168, 78)},
'romance': {'bg': (255, 245, 240), 'accent': (216, 90, 48)},
'default': {'bg': (10, 10, 13), 'accent': (200, 168, 78)},
}
def _genre_colors(genre: str) -> dict:
genre_lower = genre.lower()
for key in _GENRE_COLORS:
if key in genre_lower:
return _GENRE_COLORS[key]
return _GENRE_COLORS['default']
# ─── NovelShortsConverter ────────────────────────────────────────────────────
class NovelShortsConverter:
"""소설 에피소드 key_scenes → 쇼츠 MP4 생성"""
def __init__(self, engine=None):
"""engine: EngineLoader 인스턴스 (없으면 내부에서 결정)"""
if engine is not None:
self.engine = engine
elif _engine_loader_available:
self.engine = _EngineLoader()
else:
self.engine = None
# video_generation provider 결정
self.video_provider = self._get_video_provider()
# 번역용 writer (씬 → 영어 프롬프트)
if self.engine is not None:
try:
self.writer = self.engine.get_writer()
except Exception:
self.writer = _make_fallback_writer()
else:
self.writer = _make_fallback_writer()
def _get_video_provider(self) -> str:
"""engine.json에서 video_generation.provider 읽기"""
try:
cfg_path = BASE_DIR / 'config' / 'engine.json'
if cfg_path.exists():
cfg = json.loads(cfg_path.read_text(encoding='utf-8'))
return cfg.get('video_generation', {}).get('provider', 'ffmpeg_slides')
except Exception:
pass
return 'ffmpeg_slides'
# ── 공개 API ──────────────────────────────────────────────────────────────
def generate(self, episode: dict, novel_config: dict) -> str:
"""
episode의 key_scenes 3개로 쇼츠 생성.
반환: MP4 경로 (data/novels/{novel_id}/shorts/ep{N:03d}_shorts.mp4)
실패 시 '' 반환.
"""
novel_id = novel_config.get('novel_id', episode.get('novel_id', 'unknown'))
ep_num = episode.get('episode_num', 0)
key_scenes = episode.get('key_scenes', [])
if not key_scenes:
logger.error(f"[{novel_id}] key_scenes 없음 — 쇼츠 생성 불가")
return ''
# 출력 디렉터리 준비
shorts_dir = BASE_DIR / 'data' / 'novels' / novel_id / 'shorts'
shorts_dir.mkdir(parents=True, exist_ok=True)
output_path = str(shorts_dir / f'ep{ep_num:03d}_shorts.mp4')
logger.info(f"[{novel_id}] 에피소드 {ep_num} 쇼츠 생성 시작 (모드: {self.video_provider})")
try:
if self.video_provider == 'seedance' and SEEDANCE_API_KEY:
result = self._generate_seedance(episode, novel_config, output_path)
else:
if self.video_provider == 'seedance':
logger.warning("SEEDANCE_API_KEY 없음 — ffmpeg_slides 모드로 대체")
result = self._generate_ffmpeg_slides(episode, novel_config, output_path)
if result:
logger.info(f"[{novel_id}] 쇼츠 생성 완료: {output_path}")
else:
logger.error(f"[{novel_id}] 쇼츠 생성 실패")
return output_path if result else ''
except Exception as e:
logger.error(f"[{novel_id}] 쇼츠 생성 중 예외: {e}")
return ''
# ── ffmpeg_slides 모드 ────────────────────────────────────────────────────
def _generate_ffmpeg_slides(self, episode: dict, novel_config: dict,
output_path: str) -> bool:
"""
ffmpeg_slides 모드:
인트로 슬라이드 + key_scene 1~3 (DALL-E 이미지 + TTS) + 아웃트로 + concat
"""
if not _shorts_utils_available:
logger.error("shorts_converter 유틸 없음 — ffmpeg_slides 불가")
return False
novel_id = novel_config.get('novel_id', '')
ep_num = episode.get('episode_num', 0)
key_scenes = episode.get('key_scenes', [])
hook = episode.get('hook', '')
title_ko = novel_config.get('title_ko', '')
genre = novel_config.get('genre', '')
colors = _genre_colors(genre)
bg_color = colors['bg']
accent_color = colors['accent']
# 임시 작업 디렉터리
tmp_dir = Path(tempfile.mkdtemp(prefix=f'novel_{novel_id}_ep{ep_num}_'))
try:
clips = []
# ── 인트로 슬라이드 (소설 제목 + 에피소드 번호) ──────────────────
intro_slide = str(tmp_dir / 'intro.png')
intro_text = f"{title_ko}\n{ep_num}"
_make_text_slide(intro_slide, f"{title_ko} · {ep_num}",
bg_color, accent_color)
intro_audio = str(tmp_dir / 'intro.wav')
synthesize_section(f"{title_ko} {ep_num}", intro_audio,
'ko-KR-Wavenet-A', 1.0)
intro_mp4 = str(tmp_dir / 'intro.mp4')
dur = make_clip(intro_slide, intro_audio, intro_mp4)
if dur > 0:
clips.append({'mp4': intro_mp4, 'duration': dur})
# ── key_scene 슬라이드 1~3 ────────────────────────────────────────
images_dir = BASE_DIR / 'data' / 'novels' / novel_id / 'images'
images_dir.mkdir(parents=True, exist_ok=True)
for i, scene in enumerate(key_scenes[:3], 1):
if not scene:
continue
# DALL-E 이미지 (또는 텍스트 슬라이드 폴백)
scene_slide = str(tmp_dir / f'scene{i}.png')
img_prompt = self._scene_to_image_prompt(scene, novel_config)
img_generated = _generate_dalle_image(img_prompt, scene_slide)
if not img_generated:
_make_text_slide(scene_slide, scene, bg_color, accent_color)
# 생성된 이미지를 data/images에도 저장 (캐릭터 일관성 참조용)
perm_img = str(images_dir / f'ep{ep_num:03d}_scene{i}.png')
if img_generated and Path(scene_slide).exists():
import shutil
shutil.copy2(scene_slide, perm_img)
# TTS
scene_audio = str(tmp_dir / f'scene{i}.wav')
synthesize_section(scene, scene_audio, 'ko-KR-Wavenet-A', 1.0)
# 클립 생성
scene_mp4 = str(tmp_dir / f'scene{i}.mp4')
dur = make_clip(scene_slide, scene_audio, scene_mp4)
if dur > 0:
clips.append({'mp4': scene_mp4, 'duration': dur})
# ── 아웃트로 슬라이드 (훅 문장 + 다음 에피소드 예고) ──────────────
outro_slide = str(tmp_dir / 'outro.png')
outro_text = hook if hook else '다음 회를 기대해 주세요'
_make_text_slide(outro_slide, outro_text, bg_color, accent_color)
outro_audio = str(tmp_dir / 'outro.wav')
synthesize_section(outro_text, outro_audio, 'ko-KR-Wavenet-A', 1.0)
outro_mp4 = str(tmp_dir / 'outro.mp4')
dur = make_clip(outro_slide, outro_audio, outro_mp4)
if dur > 0:
clips.append({'mp4': outro_mp4, 'duration': dur})
if not clips:
logger.error("생성된 클립 없음")
return False
# ── 클립 결합 ────────────────────────────────────────────────────
concat_mp4 = str(tmp_dir / 'concat.mp4')
ok = concat_clips_xfade(clips, concat_mp4, transition='fade', trans_dur=0.5)
if not ok:
logger.error("클립 결합 실패")
return False
# ── BGM 믹스 ─────────────────────────────────────────────────────
bgm_path = str(BASE_DIR / 'assets' / 'bgm.mp3')
bgm_mp4 = str(tmp_dir / 'bgm_mixed.mp4')
mix_bgm(concat_mp4, bgm_path, bgm_mp4, volume=0.08)
final_source = bgm_mp4 if Path(bgm_mp4).exists() else concat_mp4
# ── 최종 출력 복사 ────────────────────────────────────────────────
import shutil
shutil.copy2(final_source, output_path)
return True
except Exception as e:
logger.error(f"ffmpeg_slides 생성 중 예외: {e}")
return False
finally:
# 임시 파일 정리
try:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
# ── seedance 모드 ─────────────────────────────────────────────────────────
def _generate_seedance(self, episode: dict, novel_config: dict,
output_path: str) -> bool:
"""
seedance 모드:
key_scene → 영어 Seedance 프롬프트 변환 → API 호출 → 클립 concat
인트로2초 + 씬10초×3 + 아웃트로3초 = 35초 구성
"""
novel_id = novel_config.get('novel_id', '')
ep_num = episode.get('episode_num', 0)
key_scenes = episode.get('key_scenes', [])
hook = episode.get('hook', '')
title_ko = novel_config.get('title_ko', '')
genre = novel_config.get('genre', '')
colors = _genre_colors(genre)
bg_color = colors['bg']
accent_color = colors['accent']
tmp_dir = Path(tempfile.mkdtemp(prefix=f'novel_seedance_{novel_id}_ep{ep_num}_'))
try:
clip_paths = []
# ── 인트로 슬라이드 (2초, 정적 이미지) ──────────────────────────
intro_slide = str(tmp_dir / 'intro.png')
_make_text_slide(intro_slide, f"{title_ko} · {ep_num}",
bg_color, accent_color)
intro_mp4 = str(tmp_dir / 'intro.mp4')
if _run_ffmpeg([
'-loop', '1', '-i', intro_slide,
'-c:v', 'libx264', '-t', '2',
'-pix_fmt', 'yuv420p',
'-vf', 'scale=1080:1920',
intro_mp4,
]):
clip_paths.append({'mp4': intro_mp4, 'duration': 2.0})
# ── key_scene 클립 (Seedance 10초×3) ─────────────────────────────
for i, scene in enumerate(key_scenes[:3], 1):
if not scene:
continue
en_prompt = self._scene_to_seedance_prompt(scene, novel_config)
seedance_mp4 = _call_seedance_api(en_prompt, duration='10s')
if seedance_mp4 and Path(seedance_mp4).exists():
# 영구 저장
images_dir = BASE_DIR / 'data' / 'novels' / novel_id / 'images'
images_dir.mkdir(parents=True, exist_ok=True)
perm_clip = str(images_dir / f'ep{ep_num:03d}_scene{i}_seedance.mp4')
import shutil
shutil.copy2(seedance_mp4, perm_clip)
clip_paths.append({'mp4': seedance_mp4, 'duration': 10.0})
else:
# Seedance 실패 시 DALL-E 슬라이드 폴백
logger.warning(f"장면 {i} Seedance 실패 — 이미지 슬라이드 대체")
fallback_slide = str(tmp_dir / f'scene{i}_fallback.png')
img_prompt = self._scene_to_image_prompt(scene, novel_config)
img_ok = _generate_dalle_image(img_prompt, fallback_slide)
if not img_ok:
_make_text_slide(fallback_slide, scene, bg_color, accent_color)
fallback_mp4 = str(tmp_dir / f'scene{i}_fallback.mp4')
if _run_ffmpeg([
'-loop', '1', '-i', fallback_slide,
'-c:v', 'libx264', '-t', '10',
'-pix_fmt', 'yuv420p',
'-vf', 'scale=1080:1920',
fallback_mp4,
]):
clip_paths.append({'mp4': fallback_mp4, 'duration': 10.0})
# ── 아웃트로 (3초, 정적 이미지) ──────────────────────────────────
outro_slide = str(tmp_dir / 'outro.png')
outro_text = hook if hook else '다음 회를 기대해 주세요'
_make_text_slide(outro_slide, outro_text, bg_color, accent_color)
outro_mp4 = str(tmp_dir / 'outro.mp4')
if _run_ffmpeg([
'-loop', '1', '-i', outro_slide,
'-c:v', 'libx264', '-t', '3',
'-pix_fmt', 'yuv420p',
'-vf', 'scale=1080:1920',
outro_mp4,
]):
clip_paths.append({'mp4': outro_mp4, 'duration': 3.0})
if not clip_paths:
logger.error("Seedance 모드: 생성된 클립 없음")
return False
# ── concat (xfade) ────────────────────────────────────────────────
if _shorts_utils_available:
concat_mp4 = str(tmp_dir / 'concat.mp4')
ok = concat_clips_xfade(clip_paths, concat_mp4,
transition='fade', trans_dur=0.5)
else:
# 단순 concat fallback
concat_mp4 = str(tmp_dir / 'concat.mp4')
list_file = str(tmp_dir / 'clips.txt')
with open(list_file, 'w') as f:
for c in clip_paths:
f.write(f"file '{c['mp4']}'\n")
ok = _run_ffmpeg([
'-f', 'concat', '-safe', '0', '-i', list_file,
'-c', 'copy', concat_mp4,
])
if not ok:
logger.error("클립 결합 실패")
return False
import shutil
shutil.copy2(concat_mp4, output_path)
return True
except Exception as e:
logger.error(f"Seedance 모드 생성 중 예외: {e}")
return False
finally:
try:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
# ── 프롬프트 변환 메서드 ──────────────────────────────────────────────────
def _scene_to_seedance_prompt(self, scene: str, novel_config: dict) -> str:
"""한국어 장면 묘사 → 영어 Seedance 프롬프트 변환"""
genre = novel_config.get('genre', 'thriller')
atmosphere = novel_config.get('setting', {}).get('atmosphere', '')
prompt = f"""다음 한국어 소설 장면 묘사를 Seedance 2.0 AI 영상 생성용 영어 프롬프트로 변환하세요.
장르: {genre}
분위기: {atmosphere}
장면: {scene}
변환 규칙:
- 영어로만 작성
- 시각적이고 구체적인 묘사 (인물, 배경, 조명, 날씨, 카메라 움직임)
- 분위기 키워드 포함 (cinematic, neo-noir 등 장르에 맞게)
- "9:16 vertical" 포함
- No text overlays, no watermarks
- 3~5문장, 100단어 이내
영어 프롬프트만 출력 (설명 없이):"""
try:
result = self.writer.write(prompt, system='You are a cinematic AI video prompt engineer.')
return result.strip()
except Exception as e:
logger.error(f"Seedance 프롬프트 변환 실패: {e}")
# 폴백: 간단 번역 구조
genre_key = 'neo-noir sci-fi' if 'sci-fi' in genre else genre
return (
f"Cinematic scene: {scene[:80]}. "
f"{genre_key} atmosphere. "
f"Dramatic lighting, rain-soaked streets of Seoul 2040. "
f"Vertical 9:16 cinematic shot. No text, no watermarks."
)
def _scene_to_image_prompt(self, scene: str, novel_config: dict) -> str:
"""DALL-E용 이미지 프롬프트 생성"""
genre = novel_config.get('genre', 'thriller')
world = novel_config.get('setting', {}).get('world', '')
atmosphere = novel_config.get('setting', {}).get('atmosphere', '')
prompt = f"""소설 장면 묘사를 DALL-E 3 이미지 생성용 영어 프롬프트로 변환하세요.
세계관: {world}
분위기: {atmosphere}
장면: {scene}
규칙:
- 영어, 2~3문장
- 세로형(1024×1792) 구도
- 장르({genre})에 맞는 분위기
- No text, no letters, no watermarks
영어 프롬프트만 출력:"""
try:
result = self.writer.write(prompt, system='You are a visual prompt engineer for DALL-E.')
return result.strip()
except Exception as e:
logger.error(f"이미지 프롬프트 변환 실패: {e}")
return (
f"Cinematic vertical illustration: {scene[:80]}. "
f"Dark atmospheric lighting. "
f"No text, no watermarks. Vertical 9:16."
)
# ─── 직접 실행 테스트 ─────────────────────────────────────────────────────────
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
sample_config = json.loads(
(BASE_DIR / 'config' / 'novels' / 'shadow-protocol.json')
.read_text(encoding='utf-8')
)
sample_episode = {
'novel_id': 'shadow-protocol',
'episode_num': 1,
'title': '프로토콜',
'body': '테스트 본문',
'hook': '아리아의 목소리가 처음으로 떨렸다.',
'key_scenes': [
'서진이 비 내리는 서울 골목을 뛰어가는 장면',
'아리아가 빨간 경고 메시지를 출력하는 장면',
'감시 드론이 서진의 아파트 창문 밖을 맴도는 장면',
],
'summary': '서진이 그림자 프로토콜을 발견한다.',
'generated_at': '2026-03-26T00:00:00+00:00',
}
converter = NovelShortsConverter()
output = converter.generate(sample_episode, sample_config)
print(f"결과: {output}")
+337
View File
@@ -0,0 +1,337 @@
"""
novel_writer.py
소설 연재 파이프라인 — AI 에피소드 생성 모듈
역할: 소설 설정 + 이전 요약을 기반으로 다음 에피소드 자동 작성
출력: data/novels/{novel_id}/episodes/ep{N:03d}.json + ep{N:03d}_summary.txt
"""
import json
import logging
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# novel/ 폴더 기준으로 BASE_DIR 설정
BASE_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'bots'))
logger = logging.getLogger(__name__)
# ─── EngineLoader / fallback ─────────────────────────────────────────────────
try:
from engine_loader import EngineLoader as _EngineLoader
_engine_loader_available = True
except ImportError:
_engine_loader_available = False
def _make_fallback_writer():
"""engine_loader 없을 때 anthropic SDK 직접 사용"""
import anthropic
import os
client = anthropic.Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY', ''))
class _DirectWriter:
def write(self, prompt: str, system: str = '') -> str:
msg = client.messages.create(
model='claude-opus-4-6',
max_tokens=4000,
system=system or '당신은 한국어 연재소설 작가입니다.',
messages=[{'role': 'user', 'content': prompt}],
)
return msg.content[0].text
return _DirectWriter()
# ─── NovelWriter ─────────────────────────────────────────────────────────────
class NovelWriter:
"""소설 설정을 읽어 다음 에피소드를 AI로 생성하고 저장하는 클래스."""
def __init__(self, novel_id: str, engine=None):
"""
novel_id: config/novels/{novel_id}.json 로드
engine : EngineLoader 인스턴스 (없으면 내부에서 생성 또는 fallback)
"""
self.novel_id = novel_id
self.novel_config = self._load_novel_config()
# writer 인스턴스 결정
if engine is not None:
self.writer = engine.get_writer()
elif _engine_loader_available:
self.writer = _EngineLoader().get_writer()
else:
logger.warning("engine_loader 없음 — anthropic SDK fallback 사용")
self.writer = _make_fallback_writer()
# 데이터 디렉터리 준비
self.episodes_dir = BASE_DIR / 'data' / 'novels' / novel_id / 'episodes'
self.episodes_dir.mkdir(parents=True, exist_ok=True)
# ── 공개 API ──────────────────────────────────────────────────────────────
def generate_episode(self) -> dict:
"""
다음 에피소드 생성.
반환:
novel_id, episode_num, title, body (2000-3000자),
hook (다음 회 예고 한 줄), key_scenes (쇼츠용 핵심 장면 3개),
summary (5줄 이내), generated_at
"""
ep_num = self.novel_config.get('current_episode', 0) + 1
logger.info(f"[{self.novel_id}] 에피소드 {ep_num} 생성 시작")
try:
prev_summaries = self._get_previous_summaries(last_n=5)
prompt = self._build_prompt(ep_num, prev_summaries)
system_msg = (
'당신은 한국어 연재소설 전문 작가입니다. '
'지시한 출력 형식을 정확히 지켜 작성하세요.'
)
raw = self.writer.write(prompt, system=system_msg)
episode = self._parse_episode_response(raw)
episode['novel_id'] = self.novel_id
episode['episode_num'] = ep_num
episode['generated_at'] = datetime.now(timezone.utc).isoformat()
# 요약 생성
episode['summary'] = self._generate_summary(episode)
# 저장
self._save_episode(episode)
logger.info(f"[{self.novel_id}] 에피소드 {ep_num} 생성 완료")
return episode
except Exception as e:
logger.error(f"[{self.novel_id}] 에피소드 생성 실패: {e}")
return {}
# ── 내부 메서드 ───────────────────────────────────────────────────────────
def _load_novel_config(self) -> dict:
"""config/novels/{novel_id}.json 로드"""
path = BASE_DIR / 'config' / 'novels' / f'{self.novel_id}.json'
if not path.exists():
logger.error(f"소설 설정 파일 없음: {path}")
return {}
try:
return json.loads(path.read_text(encoding='utf-8'))
except Exception as e:
logger.error(f"소설 설정 로드 실패: {e}")
return {}
def _build_prompt(self, ep_num: int, prev_summaries: list[str]) -> str:
"""AI에게 전달할 에피소드 작성 프롬프트 구성"""
novel = self.novel_config
setting = novel.get('setting', {})
characters = novel.get('characters', [])
# 등장인물 포맷팅
char_lines = []
for c in characters:
char_lines.append(
f"- {c['name']} ({c['role']}): {c['description']} / {c['personality']}"
)
char_text = '\n'.join(char_lines) if char_lines else '(없음)'
# 이전 요약 포맷팅
if prev_summaries:
summaries_text = '\n'.join(
f"[{i+1}회 전] {s}" for i, s in enumerate(reversed(prev_summaries))
)
else:
summaries_text = '(첫 번째 에피소드입니다)'
rules_text = '\n'.join(f'- {r}' for r in setting.get('rules', []))
return f"""당신은 연재 소설 작가입니다.
[소설 정보]
제목: {novel.get('title_ko', '')} ({novel.get('title', '')})
장르: {novel.get('genre', '')}
세계관: {setting.get('world', '')}
분위기: {setting.get('atmosphere', '')}
세계 규칙:
{rules_text}
[등장인물]
{char_text}
[기본 스토리]
{novel.get('base_story', '')}
[이전 에피소드 요약 (최신순)]
{summaries_text}
[지시]
에피소드 {ep_num}을 작성하세요.
- 분량: {novel.get('episode_length', '2000-3000자')}
- 톤: {novel.get('tone', '긴장감 있는 서스펜스')}
- 에피소드 끝에 반드시 다음 회가 궁금한 훅(cliffhanger) 포함
- 아래 형식을 정확히 지켜 출력하세요 (각 구분자 뒤에 바로 내용):
---EPISODE_TITLE---
(에피소드 제목, 한 줄)
---EPISODE_BODY---
(에피소드 본문, {novel.get('episode_length', '2000-3000자')})
---EPISODE_HOOK---
(다음 회 예고 한 줄, 독자의 궁금증을 자극하는 문장)
---KEY_SCENES---
(쇼츠 영상용 핵심 장면 3개, 각각 한 줄씩. 시각적으로 묘사)
장면1: (장면 묘사)
장면2: (장면 묘사)
장면3: (장면 묘사)"""
def _get_previous_summaries(self, last_n: int = 5) -> list[str]:
"""data/novels/{novel_id}/episodes/ep{N:03d}_summary.txt 로드 (최신 N개)"""
current_ep = self.novel_config.get('current_episode', 0)
summaries = []
# 최신 에피소드부터 역순으로 탐색
for ep in range(current_ep, max(0, current_ep - last_n), -1):
path = self.episodes_dir / f'ep{ep:03d}_summary.txt'
if path.exists():
try:
text = path.read_text(encoding='utf-8').strip()
if text:
summaries.append(text)
except Exception as e:
logger.warning(f"요약 로드 실패 ep{ep:03d}: {e}")
return summaries # 최신순 반환
def _parse_episode_response(self, raw: str) -> dict:
"""
AI 응답 파싱:
---EPISODE_TITLE--- / ---EPISODE_BODY--- / ---EPISODE_HOOK--- / ---KEY_SCENES---
섹션별로 분리하여 dict 반환
"""
sections = {}
pattern = re.compile(
r'---(\w+)---\s*\n(.*?)(?=---\w+---|$)',
re.DOTALL
)
for key, value in pattern.findall(raw):
sections[key.strip()] = value.strip()
# KEY_SCENES 파싱 (장면1: ~, 장면2: ~, 장면3: ~ 형식)
key_scenes_raw = sections.get('KEY_SCENES', '')
key_scenes = []
for line in key_scenes_raw.splitlines():
line = line.strip()
# "장면N:" 또는 "N." 또는 "- " 접두사 제거
line = re.sub(r'^(장면\d+[:.]?\s*|\d+[.)\s]+|-\s*)', '', line).strip()
if line:
key_scenes.append(line)
key_scenes = key_scenes[:3]
# 최소 3개 채우기
while len(key_scenes) < 3:
key_scenes.append('')
return {
'title': sections.get('EPISODE_TITLE', f'에피소드'),
'body': sections.get('EPISODE_BODY', raw),
'hook': sections.get('EPISODE_HOOK', ''),
'key_scenes': key_scenes,
'summary': '', # _generate_summary에서 채움
}
def _generate_summary(self, episode: dict) -> str:
"""에피소드를 5줄 이내로 요약 (다음 회 컨텍스트용)"""
body = episode.get('body', '')
if not body:
return ''
prompt = f"""다음 소설 에피소드를 5줄 이내로 간결하게 요약하세요.
다음 에피소드 작가가 스토리 흐름을 파악할 수 있도록 핵심 사건과 결말만 담으세요.
에피소드 제목: {episode.get('title', '')}
에피소드 본문:
{body[:2000]}
5줄 이내 요약:"""
try:
summary = self.writer.write(prompt, system='당신은 소설 편집자입니다.')
return summary.strip()
except Exception as e:
logger.error(f"요약 생성 실패: {e}")
# 폴백: 본문 앞 200자
return body[:200] + '...'
def _save_episode(self, episode: dict):
"""
data/novels/{novel_id}/episodes/ep{N:03d}.json 저장
data/novels/{novel_id}/episodes/ep{N:03d}_summary.txt 저장
config/novels/{novel_id}.json의 current_episode + episode_log 업데이트
"""
ep_num = episode.get('episode_num', 0)
ep_prefix = f'ep{ep_num:03d}'
# JSON 저장
json_path = self.episodes_dir / f'{ep_prefix}.json'
try:
json_path.write_text(
json.dumps(episode, ensure_ascii=False, indent=2),
encoding='utf-8'
)
logger.info(f"에피소드 저장: {json_path}")
except Exception as e:
logger.error(f"에피소드 JSON 저장 실패: {e}")
# 요약 저장
summary = episode.get('summary', '')
if summary:
summary_path = self.episodes_dir / f'{ep_prefix}_summary.txt'
try:
summary_path.write_text(summary, encoding='utf-8')
logger.info(f"요약 저장: {summary_path}")
except Exception as e:
logger.error(f"요약 저장 실패: {e}")
# 소설 설정 업데이트 (current_episode, episode_log)
config_path = BASE_DIR / 'config' / 'novels' / f'{self.novel_id}.json'
try:
config = json.loads(config_path.read_text(encoding='utf-8'))
config['current_episode'] = ep_num
log_entry = {
'episode_num': ep_num,
'title': episode.get('title', ''),
'generated_at': episode.get('generated_at', ''),
}
if 'episode_log' not in config:
config['episode_log'] = []
config['episode_log'].append(log_entry)
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding='utf-8'
)
# 로컬 캐시도 업데이트
self.novel_config = config
logger.info(f"소설 설정 업데이트: current_episode={ep_num}")
except Exception as e:
logger.error(f"소설 설정 업데이트 실패: {e}")
# ─── 직접 실행 테스트 ─────────────────────────────────────────────────────────
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
writer = NovelWriter('shadow-protocol')
ep = writer.generate_episode()
if ep:
print(f"생성 완료: 에피소드 {ep['episode_num']}{ep['title']}")
print(f"본문 길이: {len(ep['body'])}")
print(f"훅: {ep['hook']}")
else:
print("에피소드 생성 실패")