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:
684
bots/novel/novel_shorts_converter.py
Normal file
684
bots/novel/novel_shorts_converter.py
Normal 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}")
|
||||
Reference in New Issue
Block a user