Files
blog-writer/bots/novel/novel_shorts_converter.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

685 lines
28 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.
"""
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(dotenv_path='D:/key/blog-writer.env.env')
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}")