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] +
+

🎨 K-Webtoon Storyboard Generator

+

한국형 웹툰 기획 및 스토리보드 자동 생성 시스템

+

40화 전체 기획안과 1화 스토리보드(30패널)를 생성합니다

+
+ """) - # Get hooks safely - hooks_data = themes_data.get("episode_hooks", {}) - hook_types = list(hooks_data.keys()) - selected_hook_type = random.choice(hook_types) if hook_types else "introduction" + # State + current_session_id = gr.State(None) - hooks = hooks_data.get(selected_hook_type, []) - valid_hooks = [h for h in hooks if h and not h.startswith("...") and len(h) > 10] - selected_hook = random.choice(valid_hooks) if valid_hooks else "운명적인 만남이 시작되었다." + with gr.Tab("🎨 웹툰 기획하기"): + with gr.Group(): + gr.Markdown("### 🎯 웹툰 설정") + + with gr.Row(): + with gr.Column(scale=2): + genre_select = gr.Radio( + choices=list(WEBTOON_GENRES.keys()), + value="로맨스", + label="장르 선택", + info="원하는 장르를 선택하세요" + ) + + query_input = gr.Textbox( + label="스토리 콘셉트", + placeholder="웹툰의 기본 설정이나 주제를 입력하세요...", + lines=3 + ) + + with gr.Row(): + random_btn = gr.Button("🎲 랜덤 테마", variant="secondary") + submit_btn = gr.Button("📝 기획 시작", variant="primary", size="lg") + + with gr.Column(scale=1): + language_select = gr.Radio( + choices=["Korean", "English"], + value="Korean", + label="언어" + ) + + gr.Markdown(""" + **장르별 특징:** + - ���맨스: 감정선, 관계 중심 + - 로판: 회귀/빙의 판타지 + - 판타지: 모험과 성장 + - 현판: 현대 배경 능력물 + - 무협: 무공과 복수 + - 스릴러: 서스펜스와 반전 + - 일상: 공감과 힐링 + - 개그: 웃음과 패러디 + - 스포츠: 열정과 성장 + """) + + status_text = gr.Textbox( + label="진행 상황", + interactive=False, + value="장르를 선택하고 콘셉트를 입력하세요" + ) + + # Output + with gr.Row(): + with gr.Column(): + planning_display = gr.Markdown("*기획안이 여기에 표시됩니다*") + + with gr.Column(): + storyboard_display = gr.Markdown("*1화 스토리보드가 여기에 표시됩니다*") + + with gr.Row(): + download_format = gr.Radio( + choices=["TXT", "DOCX"], + value="TXT", + label="다운로드 형식" + ) + download_btn = gr.Button("📥 다운로드", variant="secondary") + + download_file = gr.File(visible=False) - # Get items/artifacts for certain genres - selected_item = "" - if genre in ["판타지", "현판", "무협"]: - items_data = themes_data.get("key_items_and_artifacts", {}) - item_categories = list(items_data.keys()) - if item_categories: - selected_category = random.choice(item_categories) - items = items_data.get(selected_category, []) - valid_items = [i for i in items if i and not i.startswith("...") and len(i) > 10] - selected_item = random.choice(valid_items) if valid_items else "" + with gr.Tab("📚 콘셉트 예시"): + gr.Markdown(""" + ### 장르별 인기 콘셉트 + + **로맨스** + - 계약결혼한 재벌과 평범한 회사원 + - 첫사랑과의 운명적 재회 + - 라이벌에서 연인이 되기까지 + + **판타지** + - F급에서 SSS급으로 각성 + - 회귀자의 두 번째 기회 + - 버그 캐릭터의 세계 정복 + + **현판** + - 숨겨진 최강자의 일상 + - 게이트와 헌터의 세계 + - 각성자 학교의 비밀 + + **무협** + - 천재의 몰락과 부활 + - 정파와 마교의 경계 + - 전설의 비급을 얻다 + """) - # Get plot twists safely - twists_data = themes_data.get("plot_twists_and_cliches", {}) - twist_categories = list(twists_data.keys()) - selected_twist = "" - if twist_categories: - selected_twist_cat = random.choice(twist_categories) - twists = twists_data.get(selected_twist_cat, []) - valid_twists = [t for t in twists if t and not t.startswith("...") and len(t) > 10] - selected_twist = random.choice(valid_twists) if valid_twists else "" + # Event handlers + def process_query(query, genre, language, session_id): + system = WebtoonSystem() + planning = "" + storyboard = "" + + for planning_content, storyboard_content, status, new_session_id in system.process_webtoon_stream(query, genre, language, session_id): + # Split the combined output if it contains both planning and storyboard + if '1화 스토리보드' in storyboard_content: + parts = storyboard_content.split('='*50) + if len(parts) >= 2: + planning = parts[0] + storyboard = '='*50 + '='*50.join(parts[1:]) + else: + planning = planning_content + storyboard = storyboard_content + else: + planning = planning_content + storyboard = storyboard_content + + yield planning, storyboard, status, new_session_id - # Check for fusion genres - fusion_genres = themes_data.get("fusion_genres", {}) - fusion_options = [v for v in fusion_genres.values() if v and not v.startswith("...") and len(v) > 10] - selected_fusion = random.choice(fusion_options) if fusion_options and random.random() > 0.7 else "" + def handle_random_theme(genre, language): + return generate_random_webtoon_theme(genre, language) - # Log selected elements for debugging - logger.debug(f"Selected elements - Genre: {selected_genre_key}, Character: {selected_character_key}, Mechanic: {selected_mechanic}") + def handle_download(download_format, session_id, planning, storyboard, genre): + """Handle download request""" + try: + title = f"{genre} 웹툰" + + if download_format == "TXT": + content = export_to_txt(planning, storyboard, genre, title) + + 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(planning, storyboard, genre, title) + + 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 - # Now use LLM to create a coherent theme from these elements - system = WebNovelSystem() + # Connect events + submit_btn.click( + fn=process_query, + inputs=[query_input, genre_select, language_select, current_session_id], + outputs=[planning_display, storyboard_display, status_text, current_session_id] + ) - # Create prompt for LLM - if language == "Korean": - prompt = f"""다음 요소들을 활용하여 {genre} 장르의 매력적인 웹소설을 기획하세요: - -【선택된 요소들】 -- 핵심 장르: {selected_genre_key.replace('_', ' ')} -- 캐릭터: {character_desc} -- 캐릭터 특성: {', '.join(character_traits[:3])} -- 배경: {selected_setting} -- 핵심 메커니즘: {selected_mechanic.replace('_', ' ')} -{"- 아이템: " + selected_item if selected_item else ""} -{"- 반전 요소: " + selected_twist if selected_twist else ""} -{"- 퓨전 설정: " + selected_fusion if selected_fusion else ""} - -【참고 훅】 -{selected_hook} - -【독자를 사로잡을 질문들】 -{chr(10).join(valid_questions[:2]) if valid_questions else "독자의 호기심을 자극하는 질문들"} - -다음 형식으로 정확히 작성하세요: - -📖 **제목:** -[매력적이고 기억에 남는 제목] - -🌍 **설정:** -[세계관과 배경 설정을 3-4줄로 설명] - -👥 **주요 캐릭터:** -- 주인공: [이름] - [간단한 설명] -- 주요인물1: [이름] - [간단한 설명] -- 주요인물2: [이름] - [간단한 설명] - -📝 **작품소개:** -[독자의 흥미를 끄는 3-4줄의 작품 소개. 주인공의 상황, 목표, 핵심 갈등을 포함]""" - - else: # English - prompt = f"""Create an engaging web novel for {genre} genre using these elements: - -【Selected Elements】 -- Core genre: {selected_genre_key.replace('_', ' ')} -- Character: {character_desc} -- Character traits: {', '.join(character_traits[:3])} -- Setting: {selected_setting} -- Core mechanism: {selected_mechanic.replace('_', ' ')} -{"- Item: " + selected_item if selected_item else ""} -{"- Twist: " + selected_twist if selected_twist else ""} -{"- Fusion: " + selected_fusion if selected_fusion else ""} - -【Reference Hook】 -{selected_hook} - -【Questions to captivate readers】 -{chr(10).join(valid_questions[:2]) if valid_questions else "Questions that spark reader curiosity"} - -Format exactly as follows: - -📖 **Title:** -[Attractive and memorable title] - -🌍 **Setting:** -[World and background setting in 3-4 lines] - -👥 **Main Characters:** -- Protagonist: [Name] - [Brief description] -- Key Character 1: [Name] - [Brief description] -- Key Character 2: [Name] - [Brief description] - -📝 **Synopsis:** -[3-4 lines that hook readers. Include protagonist's situation, goal, and core conflict]""" - - # Call LLM to generate theme - messages = [{"role": "user", "content": prompt}] - generated_theme = system.call_llm_sync(messages, "writer", language) + random_btn.click( + fn=handle_random_theme, + inputs=[genre_select, language_select], + outputs=[query_input] + ) - logger.info("Successfully generated theme using JSON elements") - return generated_theme + download_btn.click( + fn=handle_download, + inputs=[download_format, current_session_id, planning_display, storyboard_display, 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] + ) - except Exception as e: - logger.error(f"Error generating theme from JSON: {e}", exc_info=True) - return generate_fallback_theme(genre, language) - -def generate_fallback_theme(genre: str, language: str) -> str: - """Fallback theme generator when JSON is not available""" - templates = { - "로맨스": { - "themes": [ - """📖 **제목:** 계약결혼 365일, 기억을 잃은 재벌 남편 - -🌍 **설정:** -현대 서울, 대기업 본사와 강남의 펜트하우스가 주 무대. 3개월 계약결혼 만료 직전, 남편이 교통사고로 기억을 잃고 아내를 첫사랑으로 착각하는 상황. - -👥 **주요 캐릭터:** -- 주인공: 서연우(28) - 평범한 회사원, 부모님 병원비를 위해 계약결혼 -- 남주: 강준혁(32) - 냉혈 재벌 3세, 기억상실 후 순정남으로 변신 -- 조연: 한소영(30) - 준혁의 전 약혼녀, 복수를 계획 중 - -📝 **작품소개:** -"당신이 내 첫사랑이야." 이혼 서류에 도장을 찍으려던 순간, 교통사고를 당한 냉혈 재벌 남편이 나를 운명의 상대로 착각한다. 3개월간 연기했던 가짜 부부에서 진짜 사랑이 시작되는데...""", - - """📖 **제목:** 검사님, 이혼 소송은 제가 맡을게요 - -🌍 **설정:** -서울중앙지법과 검찰청이 주 무대. 냉혈 검사와 이혼 전문 변호사가 법정에서 대립하며 티격태격하는 법정 로맨스. - -👥 **주요 캐릭터:** -- 주인공: 오지원(30) - 승률 100% 이혼 전문 변호사 -- 남주: 민시준(33) - 원칙주의 엘리트 검사 -- 조연: 박세진(35) - 지원의 전 남편이자 시준의 선배 검사 - -📝 **작품소개:** -"변호사님, 법정에서만 만나기로 했잖아요." 하필 전 남편의 불륜 소송을 맡은 날, 상대 검사가 나타났다. 법정에선 적, 밖에선 연인. 우리의 관계는 대체 뭘까?""" - ] - }, - "로판": { - "themes": [ - """📖 **제목:** 악녀는 이번 생에서 도망친다 - -🌍 **설정:** -마법이 존재하는 제국, 1년 후 처형당할 운명의 악녀 공작 영애로 빙의. 북부 변방의 전쟁광 공작과의 계약결혼이 유일한 생존루트. - -👥 **주요 캐릭터:** -- 주인공: 아델라이드(20) - 빙의한 악녀, 원작 지식 보유 -- 남주: 카시우스(25) - 북부의 전쟁광 공작, 숨겨진 순정남 -- 악역: 황태자 레온(23) - 여주에게 집착하는 얀데레 - -📝 **작품소개:** -소설 속 악녀로 빙의했는데 이미 처형 선고를 받은 상태? 살려면 원작에 없던 북부 공작과 계약결혼해야 한다. "1년만 함께해주세요. 그 후엔 자유를 드리겠습니다." 하지만 계약 기간이 끝나도 그가 날 놓아주지 않는다.""", - - """📖 **제목:** 회귀한 황녀는 버려진 왕자를 택한다 - -🌍 **설정:** -제국력 892년으로 회귀한 황녀. 전생에서 자신을 배신한 황태자 대신, 버려진 서자 왕자와 손을 잡고 제국을 뒤집으려 한다. - -👥 **주요 캐릭터:** -- 주인공: 로젤린(22) - 회귀한 황녀, 미래를 아는 전략가 -- 남주: 다미안(24) - 버려진 서자 왕자, 숨겨진 흑막 -- 악역: 황태자 세바스찬(26) - 전생의 배신자 - -📝 **작품소개:** -독살당해 회귀한 황녀, 이번엔 다르게 살겠다. 모두가 무시하는 서자 왕자의 손을 잡았다. "저와 함께 제국을 뒤집으시겠습니까?" 하지만 그는 내가 아는 것보다 훨씬 위험한 남자였다.""" - ] - }, - "판타지": { - "themes": [ - """📖 **제목:** F급 헌터, SSS급 네크로맨서가 되다 - -🌍 **설정:** -게이트와 던전이 출현한 지 10년 후의 한국. F급 헌터가 우연히 얻은 스킬로 죽은 보스 몬스터를 부활시켜 부리는 유일무이 네크로맨서가 된다. - -👥 **주요 캐릭터:** -- 주인공: 김도현(24) - F급에서 SSS급 네크로맨서로 각성 -- 조력자: 리치 왕(???) - 첫 번째 언데드, 전설의 대마법사 -- 라이벌: 최강훈(26) - S급 길드 마스터, 주인공을 경계 - -📝 **작품소개:** -"F급 주제에 무슨 헛소리야?" 모두가 비웃었다. 하지만 첫 번째 보스를 쓰러뜨린 순간, 시스템 메시지가 떴다. [SSS급 히든 클래스: 네크로맨서 각성] 이제 죽은 보스들이 내 부하가 된다.""", - - """📖 **제목:** 탑을 역주행하는 회귀자 - -🌍 **설정:** -100층 탑 정상에서 죽은 후 튜토리얼로 회귀. 하지만 이번엔 100층부터 거꾸로 내려가며 모든 층을 정복하는 역주행 시스템이 열렸��. - -👥 **주요 캐릭터:** -- 주인공: 이성진(28) - 유일한 역주행 회귀자 -- 조력자: 관리자(???) - 탑의 시스템 AI, 주인공에게 호의적 -- 라이벌: 성하윤(25) - 이번 회차 최강 신인 - -📝 **작품소개:** -100층에서 죽었다. 눈을 떠보니 튜토리얼이었다. [역주행 시스템이 개방되었습니다] "뭐? 100층부터 시작한다고?" 최강자의 기억을 가진 채 정상에서부터 내려가는 전무후무한 공략이 시작된다.""" - ] - }, - "현판": { - "themes": [ - """📖 **제목:** 무능력자의 SSS급 아이템 제작 - -🌍 **설정:** -게이트 출현 10년, 전 국민의 70%가 각성한 한국. 무능력자로 살던 주인공에게 갑자기 아이템 제작 시스템이 열린다. - -👥 **주요 캐릭터:** -- 주인공: 박준서(25) - 무능력자에서 유일무이 아이템 제작사로 -- 의뢰인: 강하늘(27) - S급 헌터, 첫 번째 고객 -- 라이벌: 대기업 '아르테미스' - 아이템 독점 기업 - -📝 **작품소개:** -"각성 등급: 없음" 10년째 무능력자로 살았다. 그런데 오늘, 이상한 시스템 창이 떴다. [SSS급 생산직: 아이템 크래프터] 이제 내가 만든 아이템이 세계를 바꾼다.""", - - """📖 **제목:** 헌터 사관학교의 숨겨진 최강자 - -🌍 **설정:** -한국 최고의 헌터 사관학교. 입학시험 꼴찌로 들어온 주인공이 사실은 능력을 숨기고 있는 특급 요원. - -👥 **주요 캐릭터:** -- 주인공: 윤시우(20) - 꼴찌로 위장한 특급 헌터 -- 히로인: 차유진(20) - 학년 수석, 재벌가 영애 -- 교관: 한태성(35) - 전설의 헌터, 주인공의 정체를 의심 - -📝 **작품소개:** -"측정 불가? 그럼 F급이네." 일부러 힘을 숨기고 꼴찌로 입학했다. 하지만 S급 게이트가 학교에 열리면서 정체를 숨길 수 없게 됐다. "너... 대체 누구야?"라는 물음에 어떻게 답해야 할까.""" - ] - }, - "무협": { - "themes": [ - """📖 **제목:** 천하제일문 폐급제자의 마교 비급 - -🌍 **설정:** -정파 무림의 중원. 천하제일문의 폐급 막내제자가 우연히 마교 교주의 비급을 습득하고 정마를 아우르는 절대무공을 익힌다. - -👥 **주요 캐릭터:** -- 주인공: 진천(18) - 폐급에서 절대고수로 -- 스승: 혈마노조(???) - 비급에 깃든 마교 전설 -- 라이벌: 남궁세가 소가주 - 정파 제일 천재 - -📝 **작품소개:** -"하찮은 것이 감히!" 모두가 무시하던 막내제자. 하지만 떨어진 절벽에서 발견한 것은 전설로만 전해지던 천마신공. "이제부터가 진짜 시작이다." 정파와 마교를 뒤흔들 폐급의 반란이 시작된다.""", - - """📖 **제목:** 화산파 장문인으로 회귀하다 - -🌍 **설정:** -100년 전 화산파가 최고 문파이던 시절로 회귀. 미래를 아는 장문인이 되어 문파를 지키고 무림을 재편한다. - -👥 **주요 캐릭터:** -- 주인공: 청운진인(45→25) - 회귀한 화산파 장문인 -- 제자: 백무진(15) - 미래의 화산파 배신자 -- 맹우: 마교 성녀 - 전생의 적, 이생의 동료 - -📝 **작품소개:** -멸문 직전에 회귀했다. 이번엔 다르다. "앞으로 화산파는 정파의 규율을 벗어난다." 미래를 아는 장문인의 파격적인 결정. 마교와 손잡고 무림의 판도를 뒤집는다.""" - ] - }, - "미스터리": { - "themes": [ - """📖 **제목:** 폐교에 갇힌 7명, 그리고 나 - -🌍 **설정:** -폐쇄된 산골 학교, 동창회를 위해 모인 8명이 갇힌다. 하나씩 사라지는 동창들. 범인은 이 안에 있다. - -👥 **주요 캐릭터:** -- 주인공: 서민준(28) - 프로파일러 출신 교사 -- 용의자1: 김태희(28) - 실종된 친구의 전 연인 -- 용의자2: 박진우(28) - 10년 전 사건의 목격자 - -📝 **작품소개:** -"10년 전 그날처럼..." 폐교에서 열린 동창회, 하지만 출구는 봉쇄됐다. 한 명씩 사라지는 친구들. 10년 전 묻어둔 비밀이 되살아난다. 살인자는 우리 중 한 명이다.""", - - """📖 **제목:** 타임루프 속 연쇄살인마를 찾아라 - -🌍 **설정:** -같은 하루가 반복되는 타임루프. 매번 다른 방법으로 살인이 일어나지만 범인은 동일인. 루프를 깨려면 범인을 찾아야 한다. - -👥 **주요 캐릭터:** -- 주인공: 강해인(30) - 타임루프에 갇힌 형사 -- 희생자: 이수연(25) - 매번 죽는 카페 알바생 -- 용의자들: 카페 단골 5명 - 각자의 비밀을 숨기고 있음 - -📝 **작품소개:** -"또 오늘이야..." 49번째 같은 아침. 오후 3시 33분, 카페에서 살인이 일어난다. 범인을 잡아야 내일이 온다. 하지만 범인은 매번 완���한 알리바이를 만든다. 과연 50번째 오늘은 다를까?""" - ] - }, - "라이트노벨": { - "themes": [ - """📖 **제목:** 내 여자친구가 사실은 마왕이었다 - -🌍 **설정:** -평범한 고등학교, 하지만 학생과 교사 중 일부는 이세계에서 온 존재들. 주인공만 모르는 학교의 비밀. - -👥 **주요 캐릭터:** -- 주인공: 김태양(17) - 평범한 고등학생(?) -- 히로인: 루시퍼(17) - 마왕이자 여자친구 -- 라이벌: 미카엘(17) - 천사이자 학생회장 - -📝 **작품소개:** -"선배, 사실 저... 마왕이에요!" 1년째 사귄 여자친구의 충격 고백. 근데 학생회장은 천사고, 담임은 드래곤이라고? 평범한 줄 알았던 우리 학교의 정체가 밝혀진다. "그래서... 우리 헤어져야 해?"라고 묻자 그녀가 울기 시작했다.""", - - """📖 **제목:** 게임 아이템이 현실에 떨어진다 - -🌍 **설정:** -모바일 게임과 현실이 연동되기 시작한 세계. 게임에서 얻은 아이템이 현실에 나타나면서 벌어지는 학원 코미디. - -👥 **주요 캐릭터:** -- 주인공: 박도윤(18) - 게임 폐인 고등학생 -- 히로인: 최서연(18) - 전교 1등, 의외로 게임 고수 -- 친구: 장민혁(18) - 현질 전사, 개그 담당 - -📝 **작품소개:** -"어? 이거 내 SSR 무기잖아?" 핸드폰 게임에서 뽑은 아이템이 책상 위에 나타났다. 문제는 학교에 몬스터도 나타나기 시작했다는 것. "야, 수능보다 레이드가 더 중요해진 것 같은데?"라며 웃는 친구들과 함께하는 좌충우돌 학원 판타지.""" - ] - } - } - - genre_themes = templates.get(genre, templates["로맨스"]) - selected = random.choice(genre_themes["themes"]) + # Examples + gr.Examples( + examples=[ + ["재벌 3세 상사와 신입사원의 비밀 계약연애", "로맨스"], + ["악녀로 빙의했는데 1년 후 처형 예정", "로판"], +["F급 헌터가 SSS급 네크로맨서로 각성", "판타지"], + ["무능력자인 줄 알았는데 SSS급 생산직", "현판"], + ["천하제일문 막내가 마교 교주 제자가 되다", "무협"], + ["폐교에 갇힌 동창회, 한 명씩 사라진다", "스릴러"], + ["편의점 알바생의 소소한 일상", "일상"], + ["이세계 용사인데 스탯이 이상하다", "개그"], + ["벤치 멤버에서 에이스가 되기까지", "스포츠"] + ], + inputs=[query_input, genre_select] + ) - return selected - -def generate_theme_with_llm_only(genre: str, language: str) -> str: - """Generate theme using only LLM when JSON is not available or has errors""" - system = WebNovelSystem() - - # Genre-specific prompts based on popular web novel trends - genre_prompts = { - "로맨스": { - "elements": ["계약결혼", "재벌", "이혼", "첫사랑", "운명적 만남", "오해와 화해"], - "hooks": ["기억상실", "정체 숨기기", "가짜 연인", "원나잇 후 재회"] - }, - "로판": { - "elements": ["빙의", "회귀", "악녀", "황녀", "공작", "원작 파괴"], - "hooks": ["처형 직전", "파혼 선언", "독살 시도", "폐위 위기"] - }, - "판타지": { - "elements": ["시스템", "각성", "던전", "회귀", "탑 등반", "SSS급"], - "hooks": ["F급에서 시작", "숨겨진 클래스", "유일무이 스킬", "죽음 후 각성"] - }, - "현판": { - "elements": ["헌터", "게이트", "각성자", "길드", "아이템", "랭킹"], - "hooks": ["늦은 각성", "재능 재평가", "S급 게이트", "시스템 오류"] - }, - "무협": { - "elements": ["회귀", "천재", "마교", "비급", "복수", "환생"], - "hooks": ["폐급에서 최강", "배신 후 각성", "숨겨진 혈통", "기연 획득"] - }, - "미스터리": { - "elements": ["탐정", "연쇄살인", "타임루프", "초능력", "과거의 비밀"], - "hooks": ["밀실 살인", "예고 살인", "기억 조작", "시간 역행"] - }, - "라이트노벨": { - "elements": ["학원", "이세계", "히로인", "게임", "일상", "판타지"], - "hooks": ["전학생 정체", "게임 현실화", "평행세계", "숨겨진 능력"] - } - } - - genre_info = genre_prompts.get(genre, genre_prompts["로맨스"]) - - if language == "Korean": - prompt = f"""한국 웹소설 {genre} 장르의 중독성 있는 작품을 기획하세요. - -다음 인기 요소들을 참고하세요: -- 핵심 요소: {', '.join(genre_info['elements'])} -- 인기 훅: {', '.join(genre_info['hooks'])} - -다음 형식으로 정확히 작성하세요: - -📖 **제목:** -[매력적��고 기억하기 쉬운 제목] - -🌍 **설정:** -[세계관과 배경을 3-4줄로 설명. 시대, 장소, 핵심 설정 포함] - -👥 **주요 캐릭터:** -- 주인공: [이름(나이)] - [직업/신분, 핵심 특징] -- 주요인물1: [이름(나이)] - [관계/역할, 특징] -- 주요인물2: [이름(나이)] - [관계/역할, 특징] - -📝 **작품소개:** -[3-4줄로 작품의 핵심 갈등과 매력을 소개. 첫 문장은 강한 훅으로 시작하고, 주인공의 목표와 장애물을 명확히 제시]""" - else: - prompt = f"""Generate an addictive Korean web novel for {genre} genre. - -Reference these popular elements: -- Core elements: {', '.join(genre_info['elements'])} -- Popular hooks: {', '.join(genre_info['hooks'])} - -Format exactly as follows: - -📖 **Title:** -[Attractive and memorable title] - -🌍 **Setting:** -[World and background in 3-4 lines. Include era, location, core settings] - -👥 **Main Characters:** -- Protagonist: [Name(Age)] - [Job/Status, key traits] -- Key Character 1: [Name(Age)] - [Relationship/Role, traits] -- Key Character 2: [Name(Age)] - [Relationship/Role, traits] - -📝 **Synopsis:** -[3-4 lines introducing core conflict and appeal. Start with strong hook, clearly present protagonist's goal and obstacles]""" - - messages = [{"role": "user", "content": prompt}] - generated_theme = system.call_llm_sync(messages, "writer", language) - - return generated_theme - -# --- UI functions --- -def format_episodes_display(episodes: List[Dict], current_episode: int = 0) -> str: - """Format episodes for display""" - markdown = "## 📚 웹소설 연재 현황\n\n" - - if not episodes: - return markdown + "*아직 작성된 에피소드가 없습니다.*" - - # Stats - total_episodes = len(episodes) - total_words = sum(ep.get('word_count', 0) for ep in episodes) - avg_engagement = sum(ep.get('reader_engagement', 0) for ep in episodes) / len(episodes) if episodes else 0 - - markdown += f"**진행 상황:** {total_episodes} / {TARGET_EPISODES}화\n" - markdown += f"**총 단어 수:** {total_words:,} / {TARGET_WORDS:,}\n" - markdown += f"**평균 몰입도:** ⭐ {avg_engagement:.1f} / 10\n\n" - markdown += "---\n\n" - - # Episode list - for ep in episodes[-5:]: # Show last 5 episodes - ep_num = ep.get('episode_number', 0) - word_count = ep.get('word_count', 0) - - markdown += f"### 📖 {ep_num}화\n" - markdown += f"*{word_count}단어*\n\n" - - content = ep.get('content', '') - if content: - preview = content[:200] + "..." if len(content) > 200 else content - markdown += f"{preview}\n\n" - - hook = ep.get('hook', '') - if hook: - markdown += f"**🪝 후크:** *{hook}*\n\n" - - markdown += "---\n\n" - - return markdown - -def format_webnovel_display(episodes: List[Dict], genre: str) -> str: - """Format complete web novel for display""" - if not episodes: - return "아직 완성된 웹소설이 없습니다." - - formatted = f"# 🎭 {genre} 웹소설\n\n" - - # Novel stats - total_words = sum(ep.get('word_count', 0) for ep in episodes) - formatted += f"**총 {len(episodes)}화 완결 | {total_words:,}단어**\n\n" - formatted += "---\n\n" - - # Episodes - for idx, ep in enumerate(episodes): - ep_num = ep.get('episode_number', 0) - content = ep.get('content', '') - - # Content already includes the title, so display as is - formatted += f"## {content.split(chr(10))[0] if content else f'{ep_num}화'}\n\n" - - # Get the actual content (skip title and empty line) - lines = content.split('\n') - if len(lines) > 1: - actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:]) - formatted += f"{actual_content}\n\n" - - if idx < len(episodes) - 1: # Not last episode - formatted += "➡️ *다음 화에 계속...*\n\n" - - formatted += "---\n\n" - - return formatted - -# --- Gradio interface --- -def create_interface(): - with gr.Blocks(theme=gr.themes.Soft(), title="K-WebNovel Generator") as interface: - gr.HTML(""" - - -
-

📚 K-WebNovel Generator

- -
- - badge - - - badge - - - badge - - - badge - - - badge - -
- -

한국형 웹소설 자동 생성 시스템

-

장르별 맞춤형 40화 완결 웹소설을 생성합니다

-
- """) - - # State - current_session_id = gr.State(None) - - with gr.Tab("✍️ 웹소설 쓰기"): - with gr.Group(): - gr.Markdown("### 🎯 웹소설 설정") - - with gr.Row(): - with gr.Column(scale=2): - genre_select = gr.Radio( - choices=list(WEBNOVEL_GENRES.keys()), - value="로맨스", - label="장르 선택", - info="원하는 장르를 선택하세요" - ) - - query_input = gr.Textbox( - label="스토리 테마", - placeholder="웹소설의 기본 설정이나 주제를 입력하세요...", - lines=3 - ) - - with gr.Row(): - random_btn = gr.Button("🎲 랜덤 테마", variant="secondary") - submit_btn = gr.Button("📝 연재 시작", variant="primary", size="lg") - - with gr.Column(scale=1): - language_select = gr.Radio( - choices=["Korean", "English"], - value="Korean", - label="언어" - ) - - gr.Markdown(""" - **장르별 특징:** - - 로맨스: 달달한 사랑 이야기 - - 로판: 회귀/빙의 판타지 - - 판타지: 성장과 모험 - - 현판: 현대 배경 능력자 - - 무협: 무공과 강호 - - 미스터리: 추리와 반전 - - 라노벨: 가벼운 일상물 - """) - - status_text = gr.Textbox( - label="진행 상황", - interactive=False, - value="장르를 선택하고 테마를 입력하세요" - ) - - # Output - with gr.Row(): - with gr.Column(): - episodes_display = gr.Markdown("*연재 진행 상황이 여��에 표시됩니다*") - - with gr.Column(): - novel_display = gr.Markdown("*완성된 웹소설이 여기에 표시됩니다*") - - with gr.Row(): - download_format = gr.Radio( - choices=["TXT", "DOCX"], - value="TXT", - label="다운로드 형식" - ) - download_btn = gr.Button("📥 다운로드", variant="secondary") - - download_file = gr.File(visible=False) - - with gr.Tab("📚 테마 라이브러리"): - gr.Markdown("### 인기 웹소설 테마") - - library_genre = gr.Radio( - choices=["전체"] + list(WEBNOVEL_GENRES.keys()), - value="전체", - label="장르 필터" - ) - - theme_library = gr.HTML("

테마 라이브러리 로딩 중...

") - - 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