🎨 K-Webtoon Storyboard Generator
+한국형 웹툰 기획 및 스토리보드 자동 생성 시스템
+40화 전체 기획안과 1화 스토리보드(30패널)를 생성합니다
+diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -42,22 +42,24 @@ FIREWORKS_API_KEY = os.getenv("FIREWORKS_API_KEY", "") BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "") API_URL = "https://api.fireworks.ai/inference/v1/chat/completions" MODEL_ID = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" -DB_PATH = "webnovel_sessions_v1.db" +DB_PATH = "webtoon_sessions_v1.db" -# Target settings for web novel - UPDATED FOR LONGER EPISODES +# Target settings for webtoon - UPDATED FOR WEBTOON TARGET_EPISODES = 40 # 40화 완결 -WORDS_PER_EPISODE = 400 # 각 화당 400-600 단어 (기존 200-300에서 증가) -TARGET_WORDS = TARGET_EPISODES * WORDS_PER_EPISODE # 총 16000 단어 +PANELS_PER_EPISODE = 30 # 각 화당 30개 패널 +TARGET_PANELS = TARGET_EPISODES * PANELS_PER_EPISODE # 총 1200 패널 -# Web novel genres -WEBNOVEL_GENRES = { +# Webtoon genres +WEBTOON_GENRES = { "로맨스": "Romance", "로판": "Romance Fantasy", "판타지": "Fantasy", "현판": "Modern Fantasy", "무협": "Martial Arts", - "미스터리": "Mystery", - "라이트노벨": "Light Novel" + "스릴러": "Thriller", + "일상": "Slice of Life", + "개그": "Comedy", + "스포츠": "Sports" } # --- Environment validation --- @@ -73,8 +75,8 @@ db_lock = threading.Lock() # --- Data classes --- @dataclass -class WebNovelBible: - """Web novel story bible for maintaining consistency""" +class WebtoonBible: + """Webtoon story bible for maintaining consistency""" genre: str = "" title: str = "" characters: Dict[str, Dict[str, Any]] = field(default_factory=dict) @@ -82,140 +84,124 @@ class WebNovelBible: plot_points: List[Dict[str, Any]] = field(default_factory=list) episode_hooks: Dict[int, str] = field(default_factory=dict) genre_elements: Dict[str, Any] = field(default_factory=dict) - power_system: Dict[str, Any] = field(default_factory=dict) - relationships: List[Dict[str, str]] = field(default_factory=list) + visual_style: Dict[str, Any] = field(default_factory=dict) + panel_compositions: List[str] = field(default_factory=list) @dataclass -class EpisodeCritique: - """Critique for each episode""" +class StoryboardPanel: + """Individual storyboard panel""" + panel_number: int + scene_type: str # wide, close-up, medium, establishing + image_prompt: str # Image generation prompt + dialogue: List[str] = field(default_factory=list) + narration: str = "" + sound_effects: List[str] = field(default_factory=list) + emotion_notes: str = "" + camera_angle: str = "" + background: str = "" + +@dataclass +class EpisodeStoryboard: + """Complete storyboard for one episode""" episode_number: int - hook_effectiveness: float = 0.0 - pacing_score: float = 0.0 - genre_adherence: float = 0.0 - character_consistency: List[str] = field(default_factory=list) - reader_engagement: float = 0.0 - required_fixes: List[str] = field(default_factory=list) + title: str + panels: List[StoryboardPanel] = field(default_factory=list) + total_panels: int = 30 + cliffhanger: str = "" # --- Genre-specific prompts and elements --- GENRE_ELEMENTS = { "로맨스": { "key_elements": ["감정선", "오해와 화해", "달콤한 순간", "질투", "고백"], - "popular_tropes": ["계약연애", "재벌과 평민", "첫사랑 재회", "짝사랑", "삼각관계"], - "must_have": ["심쿵 포인트", "달달한 대사", "감정 묘사", "스킨십", "해피엔딩"], - "episode_structure": "감정의 롤러코스터, 매 화 끝 설렘 포인트" + "visual_styles": ["소프트 톤", "파스텔", "꽃 배경", "빛망울 효과", "분홍빛 필터"], + "panel_types": ["클로즈업 감정샷", "투샷", "손 클로즈업", "눈빛 교환", "백허그"], + "typical_scenes": ["카페 데이트", "우산 씬", "불꽃놀이", "옥상 고백", "공항 이별"] }, "로판": { - "key_elements": ["회귀/빙의", "원작 지식", "운명 변경", "마법/검술", "신분 상승"], - "popular_tropes": ["악녀가 되었다", "폐녀 각성", "계약결혼", "집착남주", "역하렘"], - "must_have": ["차원이동 설정", "먼치킨 요소", "로맨스", "복수", "성장"], - "episode_structure": "원작 전개 비틀기, 매 화 새로운 변수" + "key_elements": ["회귀/빙의", "드레스", "무도회", "마법", "신분 상승"], + "visual_styles": ["화려한 의상", "유럽풍 배경", "반짝이 효과", "마법진", "성 배경"], + "panel_types": ["전신샷", "드레스 디테일", "마법 이펙트", "회상씬", "충격 리액션"], + "typical_scenes": ["무도회장", "정원 산책", "서재", "마법 수업", "알현실"] }, "판타지": { "key_elements": ["마법체계", "레벨업", "던전", "길드", "모험"], - "popular_tropes": ["회귀", "시스템", "먼치킨", "히든피스", "각성"], - "must_have": ["성장 곡선", "전투씬", "세계관", "동료", "최종보스"], - "episode_structure": "점진적 강해짐, 새로운 도전과 극복" + "visual_styles": ["다이나믹 액션", "이펙트 강조", "몬스터 디자인", "판타지 배경", "빛 효과"], + "panel_types": ["액션씬", "풀샷 전투", "스킬 발동", "몬스터 등장", "파워업"], + "typical_scenes": ["던전 입구", "보스전", "길드 회관", "수련장", "아이템 획득"] }, "현판": { - "key_elements": ["숨겨진 능력", "일상과 비일상", "도시 판타지", "능력자 사회", "각성"], - "popular_tropes": ["헌터", "게이트", "길드", "랭킹", "아이템"], - "must_have": ["현실감", "능력 각성", "사회 시스템", "액션", "성장"], - "episode_structure": "일상 속 비일상 발견, 점진적 세계관 확장" + "key_elements": ["게이트", "헌터", "각성", "현대 도시", "능력"], + "visual_styles": ["도시 배경", "네온 효과", "현대적 액션", "특수 효과", "어반 판타지"], + "panel_types": ["도시 전경", "능력 발현", "게이트 출현", "전투 액션", "일상 대비"], + "typical_scenes": ["게이트 현장", "헌터 협회", "훈련장", "병원", "학교"] }, "무협": { "key_elements": ["무공", "문파", "강호", "복수", "의협"], - "popular_tropes": ["천재", "폐급에서 최강", "기연", "환생", "마교"], - "must_have": ["무공 수련", "대결", "문파 설정", "경지", "최종 결전"], - "episode_structure": "수련과 대결의 반복, 점진적 경지 상승" + "visual_styles": ["동양풍", "먹 효과", "기 표현", "중국풍 의상", "산수화 배경"], + "panel_types": ["검술 동작", "경공술", "기공 수련", "대결 구도", "폭발 이펙트"], + "typical_scenes": ["무림맹", "객잔", "절벽", "폭포 수련", "비무대회"] + }, + "스릴러": { + "key_elements": ["서스펜스", "공포", "추격", "심리전", "반전"], + "visual_styles": ["어두운 톤", "그림자 강조", "대비 효과", "불안한 구도", "붉은색 강조"], + "panel_types": ["극단 클로즈업", "Dutch angle", "실루엣", "충격 컷", "공포 연출"], + "typical_scenes": ["어두운 골목", "폐건물", "지하실", "추격씬", "대치 상황"] }, - "미스터리": { - "key_elements": ["단서", "추리", "반전", "서스펜스", "진실"], - "popular_tropes": ["탐정", "연쇄 사건", "과거의 비밀", "복수극", "심리전"], - "must_have": ["복선", "붉은 청어", "논리적 추리", "충격 반전", "해결"], - "episode_structure": "단서의 점진적 공개, 긴장감 상승" + "일상": { + "key_elements": ["일상", "공감", "소소한 재미", "관계", "성장"], + "visual_styles": ["따뜻한 색감", "부드러운 선", "일상 배경", "캐주얼", "편안한 구도"], + "panel_types": ["일상 컷", "리액션", "대화씬", "배경 묘사", "감정 표현"], + "typical_scenes": ["집", "학교", "회사", "동네", "편의점"] }, - "라이트노벨": { - "key_elements": ["학원", "일상", "코미디", "모에", "배틀"], - "popular_tropes": ["이세계", "하렘", "츤데레", "치트", "길드"], - "must_have": ["가벼운 문체", "유머", "캐릭터성", "일러스트적 묘사", "왁자지껄"], - "episode_structure": "에피소드 중심, 개그와 진지의 균형" + "개그": { + "key_elements": ["개그", "패러디", "과장", "반전", "슬랩스틱"], + "visual_styles": ["과장된 표정", "데포르메", "효과선", "말풍선 연출", "파격 구도"], + "panel_types": ["과장 리액션", "개그 컷", "패러디", "충격 표정", "망가짐"], + "typical_scenes": ["개그 상황", "일상 붕괴", "오해 상황", "추격전", "단체 개그"] + }, + "스포츠": { + "key_elements": ["경기", "훈련", "팀워크", "라이벌", "성장"], + "visual_styles": ["다이나믹", "스피드선", "땀 표현", "근육 묘사", "경기장"], + "panel_types": ["액션 컷", "결정적 순간", "전신 동작", "표정 클로즈업", "경기 전경"], + "typical_scenes": ["경기장", "훈련장", "라커룸", "벤치", "시상대"] } } -# Episode hooks by genre -EPISODE_HOOKS = { - "로맨스": [ - "그의 입술이 내 귀에 닿을 듯 가까워졌다.", - "'사실... 너를 처음 본 순간부터...'", - "그때, 예상치 못한 사람이 문을 열고 들어왔다.", - "메시지를 확인한 순간, 심장이 멈출 것 같았다." - ], - "로판": [ - "그 순간, 원작에는 없던 인물이 나타났다.", - "'폐하, 계약을 파기하겠습니다.'", - "검은 오라가 그를 감싸며 눈빛이 변했다.", - "회귀 전에는 몰랐던 진실이 드러났다." - ], - "판타지": [ - "[새로운 스킬을 획득했습니다!]", - "던전 최심부에서 발견한 것은...", - "'이건... SSS급 아이템이다!'", - "시스템 창에 뜬 경고 메시지를 보고 경악했다." - ], - "현판": [ - "평범한 학생인 줄 알았던 그의 눈이 붉게 빛났다.", - "갑자기 하늘에 거대한 균열이 생겼다.", - "'당신도... 능력자였군요.'", - "핸드폰에 뜬 긴급 재난 문자를 보고 얼어붙었다." - ], - "무협": [ - "그의 검에서 흘러나온 검기를 보고 모두가 경악했다.", - "'이것이... 전설의 천마신공?!'", - "피를 토하며 쓰러진 사부가 마지막으로 남긴 말은...", - "그때, 하늘에서 한 줄기 빛이 내려왔다." - ], - "미스터리": [ - "그리고 시체 옆에서 발견된 것은...", - "'범인은 이 안에 있습니다.'", - "일기장의 마지막 페이지를 넘기자...", - "CCTV에 찍힌 그 순간, 모든 것이 뒤바뀌었다." - ], - "라이트노벨": [ - "'선배! 사실 저... 마왕이에요!'", - "전학생의 정체는 다름 아닌...", - "그녀의 가방에서 떨어진 것을 보고 경악했다.", - "'어라? 이거... 게임 아이템이 현실에?'" - ] +# Panel composition types +PANEL_COMPOSITIONS = { + "establishing": "전체 배경과 분위기를 보여주는 원거리 샷", + "wide": "캐릭터와 배경이 모두 보이는 와이드 샷", + "medium": "캐릭터 상반신이 보이는 미디엄 샷", + "close_up": "얼굴이나 표정에 집중하는 클로즈업", + "extreme_close_up": "눈, 입 등 특정 부위 극단 클로즈업", + "over_shoulder": "어깨 너머로 보는 오버숄더 샷", + "dutch_angle": "긴장감을 위한 기울어진 앵글", + "bird_eye": "위에서 내려다보는 부감 샷", + "worm_eye": "아래서 올려다보는 앙각 샷", + "action": "움직임을 강조하는 액션 샷" } # --- Core logic classes --- -class WebNovelTracker: - """Web novel narrative tracker""" +class WebtoonTracker: + """Webtoon narrative and storyboard tracker""" def __init__(self): - self.story_bible = WebNovelBible() - self.episode_critiques: Dict[int, EpisodeCritique] = {} + self.story_bible = WebtoonBible() + self.episode_storyboards: Dict[int, EpisodeStoryboard] = {} self.episodes: Dict[int, str] = {} - self.total_word_count = 0 - self.reader_engagement_curve: List[float] = [] + self.total_panel_count = 0 def set_genre(self, genre: str): - """Set the novel genre""" + """Set the webtoon genre""" self.story_bible.genre = genre self.story_bible.genre_elements = GENRE_ELEMENTS.get(genre, {}) - def add_episode(self, episode_num: int, content: str, hook: str): - """Add episode content""" - self.episodes[episode_num] = content - self.story_bible.episode_hooks[episode_num] = hook - self.total_word_count = sum(len(ep.split()) for ep in self.episodes.values()) - - def add_episode_critique(self, episode_num: int, critique: EpisodeCritique): - """Add episode critique""" - self.episode_critiques[episode_num] = critique - self.reader_engagement_curve.append(critique.reader_engagement) + def add_storyboard(self, episode_num: int, storyboard: EpisodeStoryboard): + """Add episode storyboard""" + self.episode_storyboards[episode_num] = storyboard + self.total_panel_count += len(storyboard.panels) -class WebNovelDatabase: - """Database management for web novel system""" +class WebtoonDatabase: + """Database management for webtoon system""" @staticmethod def init_db(): with sqlite3.connect(DB_PATH) as conn: @@ -235,24 +221,21 @@ class WebNovelDatabase: status TEXT DEFAULT 'active', current_episode INTEGER DEFAULT 0, total_episodes INTEGER DEFAULT 40, - final_novel TEXT, - reader_report TEXT, - total_words INTEGER DEFAULT 0, + planning_doc TEXT, story_bible TEXT, - engagement_score REAL DEFAULT 0.0 + visual_style TEXT ) ''') - # Episodes table + # Storyboards table cursor.execute(''' - CREATE TABLE IF NOT EXISTS episodes ( + CREATE TABLE IF NOT EXISTS storyboards ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, episode_number INTEGER NOT NULL, - content TEXT, - hook TEXT, - word_count INTEGER DEFAULT 0, - reader_engagement REAL DEFAULT 0.0, + title TEXT, + storyboard_data TEXT, + panel_count INTEGER DEFAULT 30, status TEXT DEFAULT 'pending', created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (session_id) REFERENCES sessions(session_id), @@ -260,36 +243,23 @@ class WebNovelDatabase: ) ''') - # Episode critiques table + # Panels table cursor.execute(''' - CREATE TABLE IF NOT EXISTS episode_critiques ( + CREATE TABLE IF NOT EXISTS panels ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, episode_number INTEGER NOT NULL, - critique_data TEXT, + panel_number INTEGER NOT NULL, + scene_type TEXT, + image_prompt TEXT, + dialogue TEXT, + narration TEXT, + sound_effects TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) ''') - # Random themes library with genre - cursor.execute(''' - CREATE TABLE IF NOT EXISTS webnovel_themes ( - theme_id TEXT PRIMARY KEY, - genre TEXT NOT NULL, - theme_text TEXT NOT NULL, - language TEXT NOT NULL, - title TEXT, - protagonist TEXT, - setting TEXT, - hook TEXT, - generated_at TEXT DEFAULT (datetime('now')), - use_count INTEGER DEFAULT 0, - rating REAL DEFAULT 0.0, - tags TEXT - ) - ''') - conn.commit() @staticmethod @@ -306,7 +276,7 @@ class WebNovelDatabase: @staticmethod def create_session(user_query: str, genre: str, language: str) -> str: session_id = hashlib.md5(f"{user_query}{genre}{datetime.now()}".encode()).hexdigest() - with WebNovelDatabase.get_db() as conn: + with WebtoonDatabase.get_db() as conn: conn.cursor().execute( '''INSERT INTO sessions (session_id, user_query, genre, language) VALUES (?, ?, ?, ?)''', @@ -316,75 +286,44 @@ class WebNovelDatabase: return session_id @staticmethod - def save_episode(session_id: str, episode_num: int, content: str, - hook: str, engagement: float = 0.0): - word_count = len(content.split()) if content else 0 - with WebNovelDatabase.get_db() as conn: + def save_storyboard(session_id: str, episode_num: int, storyboard: EpisodeStoryboard): + with WebtoonDatabase.get_db() as conn: cursor = conn.cursor() + + # Save storyboard cursor.execute(''' - INSERT INTO episodes (session_id, episode_number, content, hook, - word_count, reader_engagement, status) - VALUES (?, ?, ?, ?, ?, ?, 'complete') + INSERT INTO storyboards (session_id, episode_number, title, + storyboard_data, panel_count, status) + VALUES (?, ?, ?, ?, ?, 'complete') ON CONFLICT(session_id, episode_number) - DO UPDATE SET content=?, hook=?, word_count=?, - reader_engagement=?, status='complete' - ''', (session_id, episode_num, content, hook, word_count, engagement, - content, hook, word_count, engagement)) + DO UPDATE SET title=?, storyboard_data=?, panel_count=?, status='complete' + ''', (session_id, episode_num, storyboard.title, + json.dumps(asdict(storyboard)), len(storyboard.panels), + storyboard.title, json.dumps(asdict(storyboard)), len(storyboard.panels))) - # Update session progress - cursor.execute(''' - UPDATE sessions - SET current_episode = ?, - total_words = ( - SELECT SUM(word_count) FROM episodes WHERE session_id = ? - ), - updated_at = datetime('now') - WHERE session_id = ? - ''', (episode_num, session_id, session_id)) + # Save individual panels + for panel in storyboard.panels: + cursor.execute(''' + INSERT INTO panels (session_id, episode_number, panel_number, + scene_type, image_prompt, dialogue, narration, sound_effects) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', (session_id, episode_num, panel.panel_number, + panel.scene_type, panel.image_prompt, + json.dumps(panel.dialogue), panel.narration, + json.dumps(panel.sound_effects))) conn.commit() - @staticmethod - def get_episodes(session_id: str) -> List[Dict]: - with WebNovelDatabase.get_db() as conn: - rows = conn.cursor().execute( - '''SELECT * FROM episodes WHERE session_id = ? - ORDER BY episode_number''', - (session_id,) - ).fetchall() - return [dict(row) for row in rows] - - @staticmethod - def save_webnovel_theme(genre: str, theme_text: str, language: str, - metadata: Dict[str, Any]) -> str: - theme_id = hashlib.md5(f"{genre}{theme_text}{datetime.now()}".encode()).hexdigest()[:12] - - with WebNovelDatabase.get_db() as conn: - conn.cursor().execute(''' - INSERT INTO webnovel_themes - (theme_id, genre, theme_text, language, title, protagonist, - setting, hook, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', (theme_id, genre, theme_text, language, - metadata.get('title', ''), - metadata.get('protagonist', ''), - metadata.get('setting', ''), - metadata.get('hook', ''), - json.dumps(metadata.get('tags', [])))) - conn.commit() - - return theme_id - # --- LLM Integration --- -class WebNovelSystem: - """Web novel generation system""" +class WebtoonSystem: + """Webtoon planning and storyboard generation system""" def __init__(self): self.api_key = FIREWORKS_API_KEY self.api_url = API_URL self.model_id = MODEL_ID - self.tracker = WebNovelTracker() + self.tracker = WebtoonTracker() self.current_session_id = None - WebNovelDatabase.init_db() + WebtoonDatabase.init_db() def create_headers(self): return { @@ -395,210 +334,188 @@ class WebNovelSystem: # --- Prompt generation functions --- def create_planning_prompt(self, query: str, genre: str, language: str) -> str: - """Create initial planning prompt for web novel""" + """Create initial planning prompt for webtoon""" genre_info = GENRE_ELEMENTS.get(genre, {}) lang_prompts = { - "Korean": f"""한국 웹소설 시장을 겨냥한 {genre} 장르 웹소설을 기획하세요. + "Korean": f"""한국 웹툰 시장을 겨냥한 {genre} 장르 웹툰을 기획하세요. **[핵심 스토리 설정 - 반드시 이 내용을 중심으로 전개하세요]** {query} **장르:** {genre} -**목표:** 40화 완결, 총 16,000단어 +**목표:** 40화 완결 웹툰 -⚠️ **중요**: 위에 제시된 스토리 설정을 반드시 기반으로 하여 플롯을 구성하세요. 이 설정이 전체 이야기의 핵심이며, 모든 에피소드는 이 설정을 중심으로 전개되어야 합니다. +⚠️ **중요**: 위에 제시된 스토리 설정을 반드시 기반으로 하여 플롯을 구성하세요. -**장르 필수 요소 (스토리 설정과 조화롭게 포함):** +**장르 필수 요소:** - 핵심 요소: {', '.join(genre_info.get('key_elements', []))} -- 인기 트로프: {', '.join(genre_info.get('popular_tropes', []))} -- 필수 포함: {', '.join(genre_info.get('must_have', []))} - -**전체 구성 (입력된 스토리 설정을 기반으로):** -1. **1-5화**: 제시된 설정의 주인공과 상황 소개, 핵심 갈등 제시 -2. **6-15화**: 설정에서 제시된 갈등의 심화, 관계 발전 -3. **16-25화**: 설정과 관련된 중요한 반전, 새로운 진실 발견 -4. **26-35화**: 설정의 핵심 갈등이 최고조에 이르기 -5. **36-40화**: 설정에서 시작된 모든 이야기의 대단원 - -**각 화 구성 원칙:** -- 400-600단어 분량 (충실한 내용) -- 입력된 스토리 설정에 충실한 전개 -- 매 화 끝 강력한 후크 -- 빠른 전개와 몰입감 - -입력된 스토리 설정을 중심으로 구체적인 40화 플롯라인을 제시하세요. 각 화마다 핵심 사건과 전개를 명시하세요.""", - - "English": f"""Plan a Korean-style web novel for {genre} genre. - -**[Core Story Setting - MUST base the story on this]** -{query} - -**Genre:** {genre} -**Goal:** 40 episodes, total 16,000 words - -⚠️ **IMPORTANT**: You MUST base the plot on the story setting provided above. This setting is the core of the entire story, and all episodes must revolve around this setting. - -**Genre Requirements (incorporate harmoniously with story setting):** -- Key elements: {', '.join(genre_info.get('key_elements', []))} -- Popular tropes: {', '.join(genre_info.get('popular_tropes', []))} -- Must include: {', '.join(genre_info.get('must_have', []))} - -**Overall Structure (based on the input story setting):** -1. **Episodes 1-5**: Introduce protagonist and situation from the setting, present core conflict -2. **Episodes 6-15**: Deepen conflicts from the setting, develop relationships -3. **Episodes 16-25**: Major twist related to the setting, new revelations -4. **Episodes 26-35**: Core conflicts from the setting reach climax -5. **Episodes 36-40**: Resolution of all storylines started from the setting - -**Episode Principles:** -- 400-600 words each (substantial content) -- Faithful development of the input story setting -- Strong hook at episode end -- Fast pacing and immersion - -Provide detailed 40-episode plotline centered on the input story setting. Specify key events for each episode.""" - } - - return lang_prompts.get(language, lang_prompts["Korean"]) - - def create_episode_prompt(self, episode_num: int, plot_outline: str, - previous_content: str, genre: str, language: str) -> str: - """Create prompt for individual episode - UPDATED FOR LONGER CONTENT""" - genre_info = GENRE_ELEMENTS.get(genre, {}) - hooks = EPISODE_HOOKS.get(genre, ["다음 순간, 충격적인 일이..."]) - - lang_prompts = { - "Korean": f"""웹소설 {episode_num}화를 작성하세요. - -**장르:** {genre} -**분량:** 400-600단어 (엄격히 준수 - 충실한 내용으로) - -**전체 플롯에서 {episode_num}화 내용:** -{self._extract_episode_plan(plot_outline, episode_num)} +- 비주얼 스타일: {', '.join(genre_info.get('visual_styles', []))} +- 주요 씬: {', '.join(genre_info.get('typical_scenes', []))} -⚠️ **중요**: 위의 플롯 내용을 반드시 충실히 따라 작성하세요. 플롯에서 벗어나지 마세요. +**전체 구성 (40화 기준):** +1. **1-5화**: 세계관 소개, 주인공 등장, 핵심 갈등 제시 +2. **6-15화**: 갈등 심화, 캐릭터 관계 발전, 서브플롯 전개 +3. **16-25화**: 중간 클라이맥스, 반전, 새로운 위기 +4. **26-35화**: 최종 갈등 고조, 결전 준비 +5. **36-40화**: 클라이맥스, 해결, 에필로그 -**이전 내용 요약:** -{previous_content[-1500:] if previous_content else "첫 화입니다"} +**웹툰 특화 요소:** +- 매 화 마지막 강력한 클리프행어 +- 세로 스크롤에 적합한 연출 +- 임팩트 있는 대사와 액션 +- 독자 댓글을 유발하는 전개 -**작성 형식:** -반드시 다음 형식으로 시작하세요: -{episode_num}화. [이번 화의 핵심을 담은 매력적인 소제목] +다음 형식으로 작성하세요: -(한 줄 띄우고 본문 시작) +📚 **작품 제목:** [임팩트 있는 제목] -**작성 지침:** -1. **구성**: 3-4개의 주요 장면으로 구성 - - 도입부: 이전 화 연결 및 현재 상황 - - 전개부: 2-3개의 핵심 사건/대화 - - 클라이맥스: 긴장감 최고조 - - 후크: 다음 화 예고 +🎨 **비주얼 컨셉:** +- 그림체: [작품에 어울리는 그림체] +- 색감: [주요 색상 톤] +- 캐릭터 디자인 특징: [주인공들의 비주얼 특징] -2. **필수 요소:** - - 플롯에 제시된 내용을 충실히 구현 - - 생생한 대화와 행동 묘사 - - 캐릭터 감정과 내면 갈등 - - 장면 전환과 템포 조절 - - 독자 몰입을 위한 감각적 묘사 +👥 **주요 캐릭터:** +- 주인공: [이름] - [외모 특징, 성격, 목표] +- 캐릭터2: [이름] - [역할, 특징] +- 캐릭터3: [이름] - [역할, 특징] -3. **장르별 특색:** - - {genre_info.get('episode_structure', '빠른 전개')} - - 핵심 요소 1개 이상 포함 +📖 **시놉시스:** +[3-4줄로 전체 스토리 요약] -4. **분량 배분:** - - 도입 (50-80단어) - - 주요 전개 (250-350단어) - - 클라이맥스와 후크 (100-150단어) +📝 **40화 전체 구성안:** +각 화별로 핵심 사건과 클리프행어를 포함하여 작성하세요. -**참고 후크 예시:** -{random.choice(hooks)} +1화: [제목] - [핵심 사건] - 클리프행어: [충격적인 마무리] +2화: [제목] - [핵심 사건] - 클리프행어: [충격적인 마무리] +... +40화: [제목] - [핵심 사건] - [대단원의 마무리]""", -플롯에 충실하면서도 몰입감 있게 작성하세요. 반드시 400-600단어로 작성하세요.""", + "English": f"""Plan a Korean-style webtoon for {genre} genre. - "English": f"""Write episode {episode_num} of the web novel. +**[Core Story Setting - MUST base the story on this]** +{query} **Genre:** {genre} -**Length:** 400-600 words (strict - with substantial content) +**Goal:** 40 episodes webtoon -**Episode {episode_num} from plot:** -{self._extract_episode_plan(plot_outline, episode_num)} +⚠️ **IMPORTANT**: You MUST base the plot on the story setting provided above. -⚠️ **IMPORTANT**: You MUST faithfully follow the plot content above. Do not deviate from the plot. +**Genre Requirements:** +- Key elements: {', '.join(genre_info.get('key_elements', []))} +- Visual styles: {', '.join(genre_info.get('visual_styles', []))} +- Typical scenes: {', '.join(genre_info.get('typical_scenes', []))} -**Previous content:** -{previous_content[-1500:] if previous_content else "First episode"} +**Overall Structure (40 episodes):** +1. **Episodes 1-5**: World introduction, protagonist debut, core conflict +2. **Episodes 6-15**: Deepening conflict, character development, subplots +3. **Episodes 16-25**: Mid-climax, plot twist, new crisis +4. **Episodes 26-35**: Final conflict escalation, preparation for showdown +5. **Episodes 36-40**: Climax, resolution, epilogue -**Format:** -Must start with: -Episode {episode_num}. [Attractive subtitle that captures the essence of this episode] +**Webtoon-specific elements:** +- Strong cliffhanger at end of each episode +- Vertical scroll-optimized directing +- Impactful dialogue and action +- Comment-inducing development -(blank line then start main text) +Format as follows: -**Guidelines:** -1. **Structure**: 3-4 major scenes - - Opening: Connect from previous, current situation - - Development: 2-3 key events/dialogues - - Climax: Peak tension - - Hook: Next episode teaser +📚 **Title:** [Impactful title] -2. **Essential elements:** - - Faithfully implement the plot content - - Vivid dialogue and action - - Character emotions and conflicts - - Scene transitions and pacing - - Sensory details for immersion +🎨 **Visual Concept:** +- Art style: [Suitable art style] +- Color tone: [Main color palette] +- Character design: [Visual characteristics] -3. **Genre specifics:** - - {genre_info.get('episode_structure', 'Fast pacing')} - - Include at least 1 core element +👥 **Main Characters:** +- Protagonist: [Name] - [Appearance, personality, goal] +- Character2: [Name] - [Role, traits] +- Character3: [Name] - [Role, traits] -4. **Word distribution:** - - Opening (50-80 words) - - Main development (250-350 words) - - Climax and hook (100-150 words) +📖 **Synopsis:** +[3-4 line story summary] -**Hook example:** -{random.choice(hooks)} +📝 **40 Episode Structure:** +Include key events and cliffhangers for each episode. -Write faithfully to the plot while being immersive. Must be 400-600 words.""" +Episode 1: [Title] - [Key event] - Cliffhanger: [Shocking ending] +Episode 2: [Title] - [Key event] - Cliffhanger: [Shocking ending] +... +Episode 40: [Title] - [Key event] - [Grand finale]""" } return lang_prompts.get(language, lang_prompts["Korean"]) - def create_episode_critique_prompt(self, episode_num: int, content: str, - genre: str, language: str) -> str: - """Create critique prompt for episode""" + def create_storyboard_prompt(self, episode_num: int, plot_outline: str, + genre: str, language: str) -> str: + """Create prompt for episode 1 storyboard with 30 panels""" + genre_info = GENRE_ELEMENTS.get(genre, {}) + lang_prompts = { - "Korean": f"""{genre} 웹소설 {episode_num}화를 평가하세요. + "Korean": f"""웹툰 {episode_num}화 스토리보드를 30개 패널로 작성하세요. -**작성된 내용:** -{content} +**장르:** {genre} +**1화 내용:** {self._extract_episode_plan(plot_outline, episode_num)} -**평가 기준:** -1. **후크 효과성 (25점)**: 다음 화를 읽고 싶게 만드는가? -2. **페이싱 (25점)**: 전개 속도가 적절한가? -3. **장르 적합성 (25점)**: {genre} 장르 관습을 잘 따르는가? -4. **독자 몰입도 (25점)**: 감정적으로 빠져들게 하는가? +**패널 구성 지침:** +- 총 30개 패널로 구성 +- 다양한 샷 사이즈 활용 (와이드샷, 클로즈업, 미디엄샷 등) +- 장르 특성에 맞는 연출: {', '.join(genre_info.get('panel_types', []))} +- 세로 스크롤 웹툰에 최적화된 구성 -**점수: /100점** +**각 패널별로 다음을 포함하여 작성:** -구체적인 개선점을 제시하세요.""", +패널 1: +- 샷 타입: [establishing/wide/medium/close_up 등] +- 이미지 프롬프트: [상세한 한글 이미지 생성 프롬프트] +- 대사: [캐릭터 대사가 있다면] +- 나레이션: [해설이 있다면] +- 효과음: [필요한 효과음] +- 배경: [배경 설명] - "English": f"""Evaluate {genre} web novel episode {episode_num}. +...이런 식으로 30개 패널 모두 작성 -**Written content:** -{content} +**중요 연출 포인트:** +1. 첫 패널은 임팩트 있는 establishing shot +2. 감정선은 클로즈업으로 강조 +3. 액션은 다이나믹한 앵글 활용 +4. 마지막 패널은 강력한 클리프행어 +5. 대사와 이미지가 조화롭게 구성 -**Evaluation criteria:** -1. **Hook effectiveness (25pts)**: Makes readers want next episode? -2. **Pacing (25pts)**: Appropriate development speed? -3. **Genre fit (25pts)**: Follows {genre} conventions? -4. **Reader engagement (25pts)**: Emotionally immersive? +반드시 30개 패널을 모두 작성하세요.""", -**Score: /100 points** + "English": f"""Create Episode {episode_num} storyboard with 30 panels. -Provide specific improvements.""" +**Genre:** {genre} +**Episode 1 content:** {self._extract_episode_plan(plot_outline, episode_num)} + +**Panel composition guidelines:** +- Total 30 panels +- Various shot sizes (wide, close-up, medium, etc.) +- Genre-appropriate directing: {', '.join(genre_info.get('panel_types', []))} +- Optimized for vertical scroll webtoon + +**For each panel include:** + +Panel 1: +- Shot type: [establishing/wide/medium/close_up etc] +- Image prompt: [Detailed Korean image generation prompt] +- Dialogue: [Character dialogue if any] +- Narration: [Narration if any] +- Sound effects: [Required sound effects] +- Background: [Background description] + +...continue for all 30 panels + +**Key directing points:** +1. First panel: impactful establishing shot +2. Emphasize emotions with close-ups +3. Dynamic angles for action +4. Last panel: powerful cliffhanger +5. Harmonious dialogue and image composition + +Must write all 30 panels.""" } return lang_prompts.get(language, lang_prompts["Korean"]) @@ -615,7 +532,6 @@ Provide specific improvements.""" f"{episode_num}.", f"[{episode_num}]" ] - # Also check for next episode patterns next_patterns = [ f"{episode_num+1}화:", f"Episode {episode_num+1}:", f"제{episode_num+1}화:", f"EP{episode_num+1}:", @@ -623,27 +539,18 @@ Provide specific improvements.""" ] for line in lines: - # Start capturing when we find the episode number if any(pattern in line for pattern in patterns): capturing = True episode_section.append(line) - # Stop capturing when we find the next episode number elif capturing and any(pattern in line for pattern in next_patterns): break elif capturing: episode_section.append(line) - # If we found episode content, return it if episode_section: return '\n'.join(episode_section) - # If no specific episode found, provide more context - logger.warning(f"Could not find specific plan for episode {episode_num}") - return f"""에피소드 {episode_num}에 대한 구체적인 플롯을 찾을 수 없습니다. -전체 플롯을 참고하여 {episode_num}화를 작성하되, 반드시 사용자가 제공한 원본 스토리 설정을 따르세요. - -참고: 전체 플롯 일부 -{plot_outline[:1000]}...""" + return f"에피소드 {episode_num} 내용을 플롯에서 참조하여 작성하세요." # --- LLM call functions --- def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str: @@ -660,14 +567,13 @@ Provide specific improvements.""" system_prompts = self.get_system_prompts(language) full_messages = [{"role": "system", "content": system_prompts.get(role, "")}, *messages] - # Increased max_tokens for longer episodes - max_tokens = 5000 if role == "writer" else 10000 + max_tokens = 15000 if role == "storyboarder" else 10000 payload = { "model": self.model_id, "messages": full_messages, "max_tokens": max_tokens, - "temperature": 0.6, + "temperature": 0.7, "top_p": 1, "top_k": 40, "presence_penalty": 0, @@ -724,230 +630,191 @@ Provide specific improvements.""" yield f"❌ Error occurred: {str(e)}" def get_system_prompts(self, language: str) -> Dict[str, str]: - """System prompts for web novel roles - UPDATED FOR LONGER EPISODES""" + """System prompts for webtoon roles""" base_prompts = { "Korean": { - "planner": """당신은 한국 웹소설 시장을 완벽히 이해하는 기획자입니다. -독자를 중독시키는 플롯과 전개를 설계합니다. -장르별 관습과 독자 기대를 정확히 파악합니다. -40화 완결 구조로 완벽한 기승전결을 만듭니다. -각 화마다 충실한 내용과 전개를 계획합니다. + "planner": """당신은 한국 웹툰 시장을 완벽히 이해하는 웹툰 기획자입니다. +독자를 사로잡는 스토리와 비주얼 연출을 기획합니다. +40화 완결 구조로 완벽한 기승전결을 설계합니다. +각 화마다 강력한 클리프행어로 다음 화를 기대하게 만듭니다. +장르별 특성과 독자층을 정확히 파악합니다. -⚠️ 가장 중요한 원칙: 사용자가 제공한 스토리 설정을 절대적으로 우선시하고, 이를 중심으로 모든 플롯을 구성합니다. 장르 관습보다 사용자의 구체적 설정이 항상 우선입니다.""", - - "writer": """당신은 독자를 사로잡는 웹소설 작가입니다. -풍부하고 몰입감 있는 문체를 구사합니다. -각 화를 400-600단어로 충실하게 작성합니다. -여러 장면과 전환을 통해 이야기를 전개합니다. -대화, 행동, 내면 묘사를 균형있게 배치합니다. -매 화 끝에 강력한 후크로 다음 화를 기다리게 만듭니다. - -⚠️ 가장 중요한 원칙: 제공된 플롯 아웃라인을 정확히 따르고, 절대 임의로 내용을 변경하거나 추가하지 않습니다. 플롯에 명시된 내용만을 충실히 구현합니다.""", +⚠️ 가장 중요한 원칙: 사용자가 제공한 스토리 설정을 절대적으로 우선시하고, 이를 중심으로 모든 플롯을 구성합니다.""", - "critic": """당신은 웹소설 독자의 마음을 읽는 평론가입니다. -재미와 몰입감을 최우선으로 평가합니다. -장르적 쾌감과 독자 만족도를 분석합니다. -구체적이고 실용적인 개선안을 제시합니다. -플롯 충실도와 일관성을 중요하게 평가합니다.""" + "storyboarder": """당신은 웹툰 스토리보드 전문가입니다. +30개 패널로 한 화를 완벽하게 구성합니다. +세로 스크롤에 최적화된 연출을 합니다. +각 패널마다 상세한 이미지 프롬프트를 한글로 작성합니다. +대사, 나레이션, 효과음을 적절히 배치합니다. +다양한 카메라 앵글과 샷 사이즈를 활용합니다. +감정선과 액션을 시각적으로 극대화합니다. + +⚠️ 가장 중요한 원칙: 반드시 30개 패널을 모두 작성하고, 각 패널마다 이미지 생성이 가능한 구체적인 한글 프롬프트를 제공합니다.""" }, "English": { - "planner": """You perfectly understand the Korean web novel market. -Design addictive plots and developments. -Accurately grasp genre conventions and reader expectations. + "planner": """You perfectly understand the Korean webtoon market. +Design stories and visual direction that captivate readers. Create perfect story structure in 40 episodes. -Plan substantial content and development for each episode. +Make readers anticipate next episode with strong cliffhangers. +Accurately understand genre characteristics and readership. -⚠️ Most important principle: Absolutely prioritize the user's story setting and build all plots around it. User's specific settings always take precedence over genre conventions.""", - - "writer": """You are a web novelist who captivates readers. -Use rich and immersive writing style. -Write each episode with 400-600 words faithfully. -Develop story through multiple scenes and transitions. -Balance dialogue, action, and inner descriptions. -End each episode with powerful hook for next. - -⚠️ Most important principle: Follow the provided plot outline exactly and never arbitrarily change or add content. Faithfully implement only what is specified in the plot.""", +⚠️ Most important principle: Absolutely prioritize the user's story setting and build all plots around it.""", - "critic": """You read web novel readers' minds. -Prioritize fun and immersion in evaluation. -Analyze genre satisfaction and reader enjoyment. -Provide specific, practical improvements. -Evaluate plot fidelity and consistency as important factors.""" + "storyboarder": """You are a webtoon storyboard specialist. +Perfectly compose one episode with 30 panels. +Optimize direction for vertical scrolling. +Write detailed image prompts in Korean for each panel. +Properly place dialogue, narration, and sound effects. +Use various camera angles and shot sizes. +Visually maximize emotions and action. + +⚠️ Most important principle: Must write all 30 panels and provide specific Korean prompts for image generation for each panel.""" } } return base_prompts.get(language, base_prompts["Korean"]) # --- Main process --- - def process_webnovel_stream(self, query: str, genre: str, language: str, + def process_webtoon_stream(self, query: str, genre: str, language: str, session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], None, None]: - """Web novel generation process""" + """Webtoon planning and storyboard generation process""" try: - resume_from_episode = 0 - plot_outline = "" - - if session_id: - self.current_session_id = session_id - # Resume logic here - else: - self.current_session_id = WebNovelDatabase.create_session(query, genre, language) + if not session_id: + self.current_session_id = WebtoonDatabase.create_session(query, genre, language) self.tracker.set_genre(genre) logger.info(f"Created new session: {self.current_session_id}") - # Store the original query for reference self.original_query = query + else: + self.current_session_id = session_id - # Generate plot outline first - if resume_from_episode == 0: - yield "🎬 웹소설 플롯 구성 중...", "", f"장르: {genre}", self.current_session_id - - plot_prompt = self.create_planning_prompt(query, genre, language) - plot_outline = self.call_llm_sync( - [{"role": "user", "content": plot_prompt}], - "planner", language - ) - - # Store plot outline for debugging - self.plot_outline = plot_outline - - yield "✅ 플롯 구성 완료!", "", f"40화 구성 완료", self.current_session_id - - # Generate episodes - accumulated_content = "" - for episode_num in range(resume_from_episode + 1, TARGET_EPISODES + 1): - # Write episode - yield f"✍️ {episode_num}화 집필 중...", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id - - # Create enhanced episode prompt with original query reminder - episode_prompt = self.create_episode_prompt( - episode_num, plot_outline, accumulated_content, genre, language - ) - - # Add reminder about original query before calling LLM - enhanced_prompt = f"""⚠️ 필수 확인사항: -원본 스토리 설정: {query} - -이 설정을 반드시 반영하여 작성하세요. - -{episode_prompt}""" - - episode_content = self.call_llm_sync( - [{"role": "user", "content": enhanced_prompt}], - "writer", language - ) - - # Extract episode title and content - lines = episode_content.strip().split('\n') - episode_title = "" - actual_content = episode_content - - # Check if first line contains episode number and title - if lines and (f"{episode_num}화." in lines[0] or f"Episode {episode_num}." in lines[0]): - episode_title = lines[0] - # Join the rest as content (excluding the title line and empty line after it) - actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:]) - else: - # If no title format found, generate a default title - episode_title = f"{episode_num}화. 제{episode_num}화" - - # Extract hook (last sentence) - sentences = actual_content.split('.') - hook = sentences[-2] + '.' if len(sentences) > 1 else sentences[-1] - - # Save episode with full content including title - full_episode_content = f"{episode_title}\n\n{actual_content}" - WebNovelDatabase.save_episode( - self.current_session_id, episode_num, - full_episode_content, hook - ) - - # Add to accumulated content with title - accumulated_content += f"\n\n### {episode_title}\n{actual_content}" - - # Quick critique every 5 episodes - if episode_num % 5 == 0: - critique_prompt = self.create_episode_critique_prompt( - episode_num, episode_content, genre, language - ) - critique = self.call_llm_sync( - [{"role": "user", "content": critique_prompt}], - "critic", language - ) - - yield f"✅ {episode_num}화 완료!", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id + # Phase 1: Generate planning document (40 episodes structure) + yield "🎬 웹툰 기획안 작성 중...", "", f"장르: {genre}", self.current_session_id + + planning_prompt = self.create_planning_prompt(query, genre, language) + planning_doc = self.call_llm_sync( + [{"role": "user", "content": planning_prompt}], + "planner", language + ) + + self.planning_doc = planning_doc + + yield "✅ 기획안 완성!", planning_doc, "40화 구성 완료", self.current_session_id - # Complete - total_words = len(accumulated_content.split()) - yield f"🎉 웹소설 완성!", accumulated_content, f"총 {total_words:,}단어, {TARGET_EPISODES}화 완결", self.current_session_id + # Phase 2: Generate Episode 1 Storyboard (30 panels) + yield "🎨 1화 스토리보드 작성 중...", planning_doc, "30개 패널 구성 중", self.current_session_id + + storyboard_prompt = self.create_storyboard_prompt(1, planning_doc, genre, language) + storyboard_content = self.call_llm_sync( + [{"role": "user", "content": storyboard_prompt}], + "storyboarder", language + ) + + # Parse storyboard into structured format + storyboard = self.parse_storyboard(storyboard_content, 1) + + # Save to database + WebtoonDatabase.save_storyboard(self.current_session_id, 1, storyboard) + + # Format final output + final_output = f"{planning_doc}\n\n{'='*50}\n\n## 📋 1화 스토리보드 (30 패널)\n\n{storyboard_content}" + + yield "🎉 완성!", final_output, "기획안 + 1화 스토리보드 완료", self.current_session_id except Exception as e: - logger.error(f"Web novel generation error: {e}", exc_info=True) - yield f"❌ 오류 발생: {e}", accumulated_content if 'accumulated_content' in locals() else "", "오류", self.current_session_id + logger.error(f"Webtoon generation error: {e}", exc_info=True) + yield f"❌ 오류 발생: {e}", "", "오류", self.current_session_id + + def parse_storyboard(self, content: str, episode_num: int) -> EpisodeStoryboard: + """Parse storyboard text into structured format""" + storyboard = EpisodeStoryboard(episode_number=episode_num, title=f"{episode_num}화") + + # Simple parsing - can be enhanced + panels = [] + current_panel = None + panel_number = 0 + + lines = content.split('\n') + for line in lines: + if '패널' in line or 'Panel' in line: + if current_panel: + panels.append(current_panel) + panel_number += 1 + current_panel = StoryboardPanel(panel_number=panel_number, scene_type="medium") + elif current_panel: + if '이미지 프롬프트:' in line or 'Image prompt:' in line: + current_panel.image_prompt = line.split(':', 1)[1].strip() + elif '대사:' in line or 'Dialogue:' in line: + dialogue = line.split(':', 1)[1].strip() + if dialogue: + current_panel.dialogue.append(dialogue) + elif '나레이션:' in line or 'Narration:' in line: + current_panel.narration = line.split(':', 1)[1].strip() + elif '효과음:' in line or 'Sound effects:' in line: + effects = line.split(':', 1)[1].strip() + if effects: + current_panel.sound_effects.append(effects) + elif '샷 타입:' in line or 'Shot type:' in line: + current_panel.scene_type = line.split(':', 1)[1].strip() + + if current_panel: + panels.append(current_panel) + + storyboard.panels = panels[:30] # Limit to 30 panels + return storyboard # --- Export functions --- -def export_to_txt(episodes: List[Dict], genre: str, title: str = "") -> str: - """Export web novel to TXT format""" +def export_to_txt(planning_doc: str, storyboard: str, genre: str, title: str = "") -> str: + """Export webtoon planning and storyboard to TXT format""" content = f"{'=' * 50}\n" - content += f"{title if title else genre + ' 웹소설'}\n" + content += f"{title if title else genre + ' 웹툰'}\n" content += f"{'=' * 50}\n\n" - content += f"총 {len(episodes)}화 완결\n" - content += f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}\n" + content += f"장르: {genre}\n" + content += f"총 40화 기획\n" content += f"{'=' * 50}\n\n" - for ep in episodes: - ep_num = ep.get('episode_number', 0) - ep_content = ep.get('content', '') - - # Content already includes title, so just add it - content += f"\n{ep_content}\n" - content += f"\n{'=' * 50}\n" + content += "📚 기획안\n\n" + content += planning_doc + content += f"\n\n{'=' * 50}\n\n" + + content += "🎨 1화 스토리보드 (30 패널)\n\n" + content += storyboard return content -def export_to_docx(episodes: List[Dict], genre: str, title: str = "") -> bytes: - """Export web novel to DOCX format - matches screen display exactly""" +def export_to_docx(planning_doc: str, storyboard: str, genre: str, title: str = "") -> bytes: + """Export webtoon planning and storyboard to DOCX format""" if not DOCX_AVAILABLE: raise Exception("python-docx is not installed") doc = Document() # Title - doc.add_heading(title if title else f"{genre} 웹소설", 0) + doc.add_heading(title if title else f"{genre} 웹툰", 0) - # Stats - doc.add_paragraph(f"총 {len(episodes)}화 완결") - doc.add_paragraph(f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}") + # Info + doc.add_paragraph(f"장르: {genre}") + doc.add_paragraph("총 40화 기획") doc.add_page_break() - # Episodes - for idx, ep in enumerate(episodes): - ep_num = ep.get('episode_number', 0) - ep_content = ep.get('content', '') - - # Split content into lines - lines = ep_content.strip().split('\n') - - # First line should be the title (e.g., "1화. 제목") - if lines: - # Add episode title as heading - doc.add_heading(lines[0], 1) - - # Add the rest of the content - content_lines = lines[1:] if len(lines) > 1 else [] - - # Skip empty lines at the beginning - while content_lines and not content_lines[0].strip(): - content_lines.pop(0) - - # Add content paragraphs - for line in content_lines: - if line.strip(): # Only add non-empty lines - doc.add_paragraph(line.strip()) - elif len(doc.paragraphs) > 0: # Add spacing between paragraphs - doc.add_paragraph() - - # Add page break except for the last episode - if idx < len(episodes) - 1: - doc.add_page_break() + # Planning document + doc.add_heading("📚 기획안", 1) + for line in planning_doc.split('\n'): + if line.strip(): + if line.startswith('#'): + doc.add_heading(line.replace('#', '').strip(), 2) + else: + doc.add_paragraph(line) + + doc.add_page_break() + + # Storyboard + doc.add_heading("🎨 1화 스토리보드 (30 패널)", 1) + for line in storyboard.split('\n'): + if line.strip(): + if '패널' in line or 'Panel' in line: + doc.add_heading(line, 2) + else: + doc.add_paragraph(line) # Save to bytes bytes_io = io.BytesIO() @@ -955,917 +822,318 @@ def export_to_docx(episodes: List[Dict], genre: str, title: str = "") -> bytes: bytes_io.seek(0) return bytes_io.getvalue() +def generate_random_webtoon_theme(genre: str, language: str) -> str: + """Generate random webtoon theme""" + templates = { + "로맨스": [ + "재벌 3세 상사와 신입사원의 비밀 계약연애", + "고등학교 때 첫사랑과 10년 만의 재회", + "냉혈 검사와 열혈 변호사의 법정 로맨스" + ], + "로판": [ + "악녀로 빙의했는데 1년 후 처형 예정", + "회귀한 황녀, 버려진 왕자와 손잡다", + "계약결혼한 북부 공작이 집착남이 되었다" + ], + "판타지": [ + "F급 헌터가 SSS급 네크로맨서로 각성", + "100층 탑을 역주행하는 회귀자", + "버그로 최강이 된 게임 속 NPC" + ], + "현판": [ + "무능력자인 줄 알았는데 SSS급 생산직", + "게이트 속에서 10년, 돌아온 최강자", + "헌터 고등학교의 숨겨진 랭킹 1위" + ], + "무협": [ + "천하제일문 막내가 마교 교주 제자가 되다", + "100년 전으로 회귀한 화산파 장문인", + "폐급 무공으로 천하를 제패하다" + ], + "스릴러": [ + "폐교에 갇힌 동창회, 한 명씩 사라진다", + "타임루프 속 연쇄살인범 찾기", + "내 남편이 사이코패스였다" + ], + "일상": [ + "편의점 알바생의 소소한 일상", + "30대 직장인의 퇴사 준비 일기", + "우리 동네 고양이들의 비밀 회의" + ], + "개그": [ + "이세계 용사인데 스탯이 이상하다", + "우리 학교 선생님은 전직 마왕", + "좀비 아포칼립스인데 나만 개그 캐릭터" + ], + "스포츠": [ + "벤치 멤버에서 에이스가 되기까지", + "여자 야구부 창설기", + "은퇴 선수의 코치 도전기" + ] + } -def generate_random_webnovel_theme(genre: str, language: str) -> str: - """Generate random web novel theme using novel_themes.json and LLM""" - try: - # Load novel_themes.json with better error handling - json_path = Path("novel_themes.json") - if not json_path.exists(): - logger.warning("novel_themes.json not found, using fallback") - return generate_fallback_theme(genre, language) + genre_themes = templates.get(genre, templates["로맨스"]) + return random.choice(genre_themes) + +# --- UI functions --- +def format_planning_display(planning_doc: str) -> str: + """Format planning document for display""" + return planning_doc + +def format_storyboard_display(storyboard: str) -> str: + """Format storyboard for display""" + return storyboard + +# --- Gradio interface --- +def create_interface(): + with gr.Blocks(theme=gr.themes.Soft(), title="K-Webtoon Storyboard Generator") as interface: + gr.HTML(""" + - # Filter valid plot points and questions - valid_plot_points = [p for p in plot_points if p and not p.startswith("...") and len(p) > 10] - valid_questions = [q for q in reader_questions if q and not q.startswith("...") and len(q) > 10] +
한국형 웹툰 기획 및 스토리보드 자동 생성 시스템
+40화 전체 기획안과 1화 스토리보드(30패널)를 생성합니다
+한국형 웹소설 자동 생성 시스템
-장르별 맞춤형 40화 완결 웹소설을 생성합니다
-테마 라이브러리 로딩 중...
") - - refresh_library_btn = gr.Button("🔄 새로고침") - - # Event handlers - def process_query(query, genre, language, session_id): - system = WebNovelSystem() - episodes = "" - novel = "" - - for ep_display, novel_display, status, new_session_id in system.process_webnovel_stream(query, genre, language, session_id): - episodes = ep_display - novel = novel_display - yield episodes, novel, status, new_session_id - - def handle_random_theme(genre, language): - return generate_random_webnovel_theme(genre, language) - - def handle_download(download_format, session_id, genre): - """Handle download request""" - if not session_id: - return None - - try: - episodes = WebNovelDatabase.get_episodes(session_id) - if not episodes: - return None - - # Get title from first episode or generate default - title = f"{genre} 웹소설" - - if download_format == "TXT": - content = export_to_txt(episodes, genre, title) - - # Save to temporary file - with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', - suffix='.txt', delete=False) as f: - f.write(content) - return f.name - - elif download_format == "DOCX": - if not DOCX_AVAILABLE: - gr.Warning("DOCX export requires python-docx library") - return None - - content = export_to_docx(episodes, genre, title) - - # Save to temporary file - with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx', - delete=False) as f: - f.write(content) - return f.name - - except Exception as e: - logger.error(f"Download error: {e}") - gr.Warning(f"다운로드 중 오류 발생: {str(e)}") - return None - - # Connect events - submit_btn.click( - fn=process_query, - inputs=[query_input, genre_select, language_select, current_session_id], - outputs=[episodes_display, novel_display, status_text, current_session_id] - ) - - random_btn.click( - fn=handle_random_theme, - inputs=[genre_select, language_select], - outputs=[query_input] - ) - - download_btn.click( - fn=handle_download, - inputs=[download_format, current_session_id, genre_select], - outputs=[download_file] - ).then( - fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False), - inputs=[download_file], - outputs=[download_file] - ) - - # Examples - gr.Examples( - examples=[ - ["계약결혼한 재벌 3세와 평범한 회사원의 로맨스", "로맨스"], - ["회귀한 천재 마법사의 복수극", "로판"], - ["F급 헌터에서 SSS급 각성자가 되는 이야기", "현판"], - ["폐급에서 천하제일이 되는 무공 천재", "무협"], - ["평범한 고등학생이 이세계 용사가 되는 이야기", "라이트노벨"] - ], - inputs=[query_input, genre_select] - ) - - return interface + return interface # Main if __name__ == "__main__": - logger.info("K-WebNovel Generator Starting...") - logger.info("=" * 60) - - # Environment check - logger.info(f"API Endpoint: {API_URL}") - logger.info(f"Model: {MODEL_ID}") - logger.info(f"Target: {TARGET_EPISODES} episodes, {TARGET_WORDS:,} words") - logger.info("Genres: " + ", ".join(WEBNOVEL_GENRES.keys())) - - logger.info("=" * 60) - - # Initialize database - logger.info("Initializing database...") - WebNovelDatabase.init_db() - logger.info("Database ready.") - - # Launch interface - interface = create_interface() - interface.launch( - server_name="0.0.0.0", - server_port=7860, - share=False - ) \ No newline at end of file + logger.info("K-Webtoon Storyboard Generator Starting...") + logger.info("=" * 60) + + # Environment check + logger.info(f"API Endpoint: {API_URL}") + logger.info(f"Model: {MODEL_ID}") + logger.info(f"Target: {TARGET_EPISODES} episodes, {PANELS_PER_EPISODE} panels per episode") + logger.info("Genres: " + ", ".join(WEBTOON_GENRES.keys())) + + logger.info("=" * 60) + + # Initialize database + logger.info("Initializing database...") + WebtoonDatabase.init_db() + logger.info("Database ready.") + + # Launch interface + interface = create_interface() + interface.launch( + server_name="0.0.0.0", + server_port=7860, + share=False + ) \ No newline at end of file