🎨 K-Webtoon Storyboard Generator
한국형 웹툰 기획 및 스토리보드 자동 생성 시스템
📚 40화 전체 구성 + 🎬 1화 30패널 스토리보드
import gradio as gr import os import json import requests from datetime import datetime import time from typing import List, Dict, Any, Generator, Tuple, Optional, Set import logging import re import tempfile from pathlib import Path import sqlite3 import hashlib import threading from contextlib import contextmanager from dataclasses import dataclass, field, asdict from collections import defaultdict import random from huggingface_hub import HfApi, upload_file, hf_hub_download import replicate from PIL import Image import io as io_module import base64 import concurrent.futures from threading import Lock # --- Logging setup --- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # --- Environment variables and constants --- FIREWORKS_API_KEY = os.getenv("FIREWORKS_API_KEY", "") REPLICATE_API_TOKEN = os.getenv("REPLICATE_API_TOKEN", "") 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 = "webtoon_sessions_v1.db" # Initialize Replicate client if token exists if REPLICATE_API_TOKEN: os.environ["REPLICATE_API_TOKEN"] = REPLICATE_API_TOKEN # Target settings for webtoon TARGET_EPISODES = 40 PANELS_PER_EPISODE = 30 TARGET_PANELS = TARGET_EPISODES * PANELS_PER_EPISODE # Webtoon genres WEBTOON_GENRES = { "로맨스": "Romance", "로판": "Romance Fantasy", "판타지": "Fantasy", "현판": "Modern Fantasy", "무협": "Martial Arts", "스릴러": "Thriller", "일상": "Slice of Life", "개그": "Comedy", "스포츠": "Sports" } # Celebrity face references for character design CELEBRITY_FACES = { "male": [ {"kr": "톰 크루즈", "en": "Tom Cruise"}, {"kr": "브래드 피트", "en": "Brad Pitt"}, {"kr": "레오나르도 디카프리오", "en": "Leonardo DiCaprio"}, {"kr": "라이언 고슬링", "en": "Ryan Gosling"}, {"kr": "크리스 헴스워스", "en": "Chris Hemsworth"}, {"kr": "로버트 다우니 주니어", "en": "Robert Downey Jr"}, {"kr": "크리스 에반스", "en": "Chris Evans"}, {"kr": "톰 히들스턴", "en": "Tom Hiddleston"}, {"kr": "베네딕트 컴버배치", "en": "Benedict Cumberbatch"}, {"kr": "키아누 리브스", "en": "Keanu Reeves"}, {"kr": "이병헌", "en": "Lee Byung-hun"}, {"kr": "공유", "en": "Gong Yoo"}, {"kr": "박서준", "en": "Park Seo-joon"}, {"kr": "송중기", "en": "Song Joong-ki"} ], "female": [ {"kr": "스칼렛 요한슨", "en": "Scarlett Johansson"}, {"kr": "엠마 왓슨", "en": "Emma Watson"}, {"kr": "제니퍼 로렌스", "en": "Jennifer Lawrence"}, {"kr": "갤 가돗", "en": "Gal Gadot"}, {"kr": "마고 로비", "en": "Margot Robbie"}, {"kr": "엠마 스톤", "en": "Emma Stone"}, {"kr": "앤 해서웨이", "en": "Anne Hathaway"}, {"kr": "나탈리 포트만", "en": "Natalie Portman"}, {"kr": "전지현", "en": "Jun Ji-hyun"}, {"kr": "송혜교", "en": "Song Hye-kyo"}, {"kr": "김태리", "en": "Kim Tae-ri"}, {"kr": "아이유", "en": "IU"}, {"kr": "수지", "en": "Suzy"}, {"kr": "한소희", "en": "Han So-hee"} ] } # Environment validation if not FIREWORKS_API_KEY: logger.error("FIREWORKS_API_KEY not set. Application will not work properly.") FIREWORKS_API_KEY = "dummy_token_for_testing" if not REPLICATE_API_TOKEN: logger.warning("REPLICATE_API_TOKEN not set. Image generation will be disabled.") # Global variables db_lock = threading.Lock() generated_images_cache = {} panel_images_state = {} character_consistency_map = {} # Genre-specific prompts and elements GENRE_ELEMENTS = { "로맨스": { "key_elements": ["감정선", "오해와 화해", "달콤한 순간", "질투", "고백"], "visual_styles": ["소프트 톤", "파스텔", "꽃 배경", "빛망울 효과", "분홍빛 필터"], "panel_types": ["클로즈업 감정샷", "투샷", "손 클로즈업", "눈빛 교환", "백허그"], "typical_scenes": ["카페 데이트", "우산 씬", "불꽃놀이", "옥상 고백", "공항 이별"] }, "로판": { "key_elements": ["회귀/빙의", "드레스", "무도회", "마법", "신분 상승"], "visual_styles": ["화려한 의상", "유럽풍 배경", "반짝이 효과", "마법진", "성 배경"], "panel_types": ["전신샷", "드레스 디테일", "마법 이펙트", "회상씬", "충격 리액션"], "typical_scenes": ["무도회장", "정원 산책", "서재", "마법 수업", "알현실"] }, "판타지": { "key_elements": ["마법체계", "레벨업", "던전", "길드", "모험"], "visual_styles": ["다이나믹 액션", "이펙트 강조", "몬스터 디자인", "판타지 배경", "빛 효과"], "panel_types": ["액션씬", "풀샷 전투", "스킬 발동", "몬스터 등장", "파워업"], "typical_scenes": ["던전 입구", "보스전", "길드 회관", "수련장", "아이템 획득"] }, "현판": { "key_elements": ["게이트", "헌터", "각성", "현대 도시", "능력"], "visual_styles": ["도시 배경", "네온 효과", "현대적 액션", "특수 효과", "어반 판타지"], "panel_types": ["도시 전경", "능력 발현", "게이트 출현", "전투 액션", "일상 대비"], "typical_scenes": ["게이트 현장", "헌터 협회", "훈련장", "병원", "학교"] }, "무협": { "key_elements": ["무공", "문파", "강호", "복수", "의협"], "visual_styles": ["동양풍", "먹 효과", "기 표현", "중국풍 의상", "산수화 배경"], "panel_types": ["검술 동작", "경공술", "기공 수련", "대결 구도", "폭발 이펙트"], "typical_scenes": ["무림맹", "객잔", "절벽", "폭포 수련", "비무대회"] }, "스릴러": { "key_elements": ["서스펜스", "공포", "추격", "심리전", "반전"], "visual_styles": ["어두운 톤", "그림자 강조", "대비 효과", "불안한 구도", "붉은색 강조"], "panel_types": ["극단 클로즈업", "Dutch angle", "실루엣", "충격 컷", "공포 연출"], "typical_scenes": ["어두운 골목", "폐건물", "지하실", "추격씬", "대치 상황"] }, "일상": { "key_elements": ["일상", "공감", "소소한 재미", "관계", "성장"], "visual_styles": ["따뜻한 색감", "부드러운 선", "일상 배경", "캐주얼", "편안한 구도"], "panel_types": ["일상 컷", "리액션", "대화씬", "배경 묘사", "감정 표현"], "typical_scenes": ["집", "학교", "회사", "동네", "편의점"] }, "개그": { "key_elements": ["개그", "패러디", "과장", "반전", "슬랩스틱"], "visual_styles": ["과장된 표정", "데포르메", "효과선", "말풍선 연출", "파격 구도"], "panel_types": ["과장 리액션", "개그 컷", "패러디", "충격 표정", "망가짐"], "typical_scenes": ["개그 상황", "일상 붕괴", "오해 상황", "추격전", "단체 개그"] }, "스포츠": { "key_elements": ["경기", "훈련", "팀워크", "라이벌", "성장"], "visual_styles": ["다이나믹", "스피드선", "땀 표현", "근육 묘사", "경기장"], "panel_types": ["액션 컷", "결정적 순간", "전신 동작", "표정 클로즈업", "경기 전경"], "typical_scenes": ["경기장", "훈련장", "라커룸", "벤치", "시상대"] } } # --- Data classes --- @dataclass class CharacterProfile: """Character profile with celebrity lookalike""" name: str role: str personality: str appearance: str celebrity_lookalike_kr: str celebrity_lookalike_en: str gender: str detailed_appearance: str = "" @dataclass class WebtoonBible: """Webtoon story bible for maintaining consistency""" genre: str = "" title: str = "" characters: Dict[str, CharacterProfile] = field(default_factory=dict) settings: Dict[str, str] = field(default_factory=dict) 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) visual_style: Dict[str, Any] = field(default_factory=dict) panel_compositions: List[str] = field(default_factory=list) @dataclass class StoryboardPanel: """Individual storyboard panel with unique ID""" panel_number: int scene_type: str image_prompt: str image_prompt_en: str = "" panel_id: str = "" 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 = "" characters_in_scene: List[str] = field(default_factory=list) generated_image_url: str = "" @dataclass class EpisodeStoryboard: """Complete storyboard for one episode""" episode_number: int title: str panels: List[StoryboardPanel] = field(default_factory=list) total_panels: int = 30 cliffhanger: str = "" # --- Core logic classes --- class WebtoonTracker: """Webtoon narrative and storyboard tracker""" def __init__(self): self.story_bible = WebtoonBible() self.episode_storyboards: Dict[int, EpisodeStoryboard] = {} self.episodes: Dict[int, str] = {} self.total_panel_count = 0 self.character_profiles: Dict[str, CharacterProfile] = {} def set_genre(self, genre: str): """Set the webtoon genre""" self.story_bible.genre = genre self.story_bible.genre_elements = GENRE_ELEMENTS.get(genre, {}) def add_character(self, character: CharacterProfile): """Add character with celebrity lookalike""" self.character_profiles[character.name] = character self.story_bible.characters[character.name] = character 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 WebtoonDatabase: """Database management for webtoon system""" @staticmethod def init_db(): with sqlite3.connect(DB_PATH) as conn: conn.execute("PRAGMA journal_mode=WAL") cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, user_query TEXT NOT NULL, genre TEXT NOT NULL, language TEXT NOT NULL, title TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), status TEXT DEFAULT 'active', current_episode INTEGER DEFAULT 0, total_episodes INTEGER DEFAULT 40, planning_doc TEXT, story_bible TEXT, visual_style TEXT, character_profiles TEXT, character_consistency TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS storyboards ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, episode_number INTEGER NOT NULL, 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), UNIQUE(session_id, episode_number) ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS panels ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, episode_number INTEGER NOT NULL, panel_number INTEGER NOT NULL, scene_type TEXT, image_prompt TEXT, image_prompt_en TEXT, dialogue TEXT, narration TEXT, sound_effects TEXT, generated_image TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) ''') conn.commit() @staticmethod @contextmanager def get_db(): with db_lock: conn = sqlite3.connect(DB_PATH, timeout=30.0) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() @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 WebtoonDatabase.get_db() as conn: conn.cursor().execute( '''INSERT INTO sessions (session_id, user_query, genre, language) VALUES (?, ?, ?, ?)''', (session_id, user_query, genre, language) ) conn.commit() return session_id @staticmethod def save_storyboard(session_id: str, episode_num: int, storyboard: EpisodeStoryboard): with WebtoonDatabase.get_db() as conn: cursor = conn.cursor() cursor.execute(''' INSERT INTO storyboards (session_id, episode_number, title, storyboard_data, panel_count, status) VALUES (?, ?, ?, ?, ?, 'complete') ON CONFLICT(session_id, episode_number) 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))) for panel in storyboard.panels: cursor.execute(''' INSERT INTO panels (session_id, episode_number, panel_number, scene_type, image_prompt, image_prompt_en, dialogue, narration, sound_effects) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', (session_id, episode_num, panel.panel_number, panel.scene_type, panel.image_prompt, panel.image_prompt_en, json.dumps(panel.dialogue), panel.narration, json.dumps(panel.sound_effects))) conn.commit() @staticmethod def save_character_profiles(session_id: str, profiles: Dict[str, CharacterProfile]): with WebtoonDatabase.get_db() as conn: cursor = conn.cursor() profiles_json = json.dumps({name: asdict(profile) for name, profile in profiles.items()}) cursor.execute( "UPDATE sessions SET character_profiles = ? WHERE session_id = ?", (profiles_json, session_id) ) conn.commit() @staticmethod def save_character_consistency(session_id: str, consistency_map: Dict): """캐릭터 일관성 정보 저장""" with WebtoonDatabase.get_db() as conn: cursor = conn.cursor() consistency_json = json.dumps(consistency_map) cursor.execute( "UPDATE sessions SET character_consistency = ? WHERE session_id = ?", (consistency_json, session_id) ) conn.commit() # --- Image Generation --- class ImageGenerator: """Handle image generation using Replicate API with improved prompts""" def __init__(self): self.generation_lock = Lock() self.active_generations = {} def enhance_prompt_for_webtoon(self, prompt: str, panel_number: int, scene_type: str = "medium") -> str: """Enhanced prompt for story-driven webtoon panel generation""" # 스토리 중심의 프롬프트 구성 base_style = "professional webtoon art, Korean manhwa style, high quality digital art" # 씬 타입별 강조점 scene_emphasis = { "establishing": "wide environmental shot, detailed background, atmospheric scene", "wide": "full scene view, multiple elements, environmental storytelling", "medium": "balanced composition, character and environment, narrative focus", "close_up": "emotional expression, detailed facial features, intimate moment", "extreme_close_up": "dramatic detail, intense emotion, impactful moment" } # 웹툰 특화 스타일 webtoon_style = "clean line art, vibrant colors, dynamic composition, professional illustration" # 스토리 중심 강조 story_focus = "narrative scene, story moment, sequential art panel" # 씬 타입 적용 scene_desc = scene_emphasis.get(scene_type, scene_emphasis["medium"]) # 최종 프롬프트 구성 enhanced_prompt = f"{base_style}, {scene_desc}, {prompt}, {webtoon_style}, {story_focus}" # 길이 제한 if len(enhanced_prompt) > 500: enhanced_prompt = f"{base_style}, {prompt[:350]}, {story_focus}" return enhanced_prompt def generate_image(self, prompt: str, panel_id: str, session_id: str, scene_type: str = "medium", progress_callback=None) -> Dict[str, Any]: """Generate image using Replicate API with enhanced quality settings""" try: if not REPLICATE_API_TOKEN: logger.warning("No Replicate API token, returning placeholder") return {"panel_id": panel_id, "status": "error", "message": "No API token"} logger.info(f"Generating image for panel {panel_id} - Scene type: {scene_type}") # 패널 번호 추출 panel_number = int(panel_id.split('_panel')[1]) if '_panel' in panel_id else 1 # 프롬프트 개선 (스토리 중심) enhanced_prompt = self.enhance_prompt_for_webtoon(prompt, panel_number, scene_type) logger.info(f"Enhanced prompt: {enhanced_prompt[:150]}...") if progress_callback: progress_callback(f"패널 {panel_number} 이미지 생성 중...") # Enhanced negative prompt for better quality negative_prompt = ( "text, watermark, signature, logo, writing, letters, words, " "low quality, blurry, pixelated, distorted, deformed, " "bad anatomy, bad proportions, extra limbs, missing limbs, " "duplicate, multiple panels, split screen, collage, " "comic strip layout, speech bubbles, dialogue boxes" ) # Run SDXL with optimized parameters input_params = { "prompt": enhanced_prompt, "negative_prompt": negative_prompt, "num_inference_steps": 35, # 품질 향상 "guidance_scale": 9.0, # 프롬프트 충실도 향상 "width": 768, # 웹툰 세로형 "height": 1024, "scheduler": "DPMSolverMultistep", # 더 나은 품질 "refine": "expert_ensemble_refiner", # SDXL refiner 사용 "high_noise_frac": 0.8, "prompt_strength": 0.9, "num_outputs": 1 } output = replicate.run( "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b", input=input_params ) if output and len(output) > 0: image_url = output[0] if isinstance(output[0], str) else str(output[0]) cache_key = f"{session_id}_{panel_id}" generated_images_cache[cache_key] = image_url logger.info(f"Successfully generated image for panel {panel_id}") return { "panel_id": panel_id, "status": "success", "image_url": image_url, "prompt": enhanced_prompt } return {"panel_id": panel_id, "status": "error", "message": "No output from model"} except Exception as e: logger.error(f"Image generation error for panel {panel_id}: {e}") return {"panel_id": panel_id, "status": "error", "message": str(e)} # --- LLM Integration --- 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 = WebtoonTracker() self.current_session_id = None self.image_generator = ImageGenerator() self.character_consistency_map = {} WebtoonDatabase.init_db() def create_headers(self): return { "Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}" } def assign_celebrity_lookalikes(self, characters: List[Dict]) -> Dict[str, CharacterProfile]: """Assign celebrity lookalikes to characters with English names""" profiles = {} used_celebrities = [] for char in characters: gender = char.get('gender', 'male') available_celebrities = [c for c in CELEBRITY_FACES.get(gender, []) if c['kr'] not in [u['kr'] for u in used_celebrities]] if not available_celebrities: available_celebrities = CELEBRITY_FACES.get(gender, []) celebrity = random.choice(available_celebrities) used_celebrities.append(celebrity) detailed_appearance = f"{celebrity['en']} lookalike face, {char.get('appearance', '')}" profile = CharacterProfile( name=char.get('name', ''), role=char.get('role', ''), personality=char.get('personality', ''), appearance=char.get('appearance', ''), celebrity_lookalike_kr=celebrity['kr'], celebrity_lookalike_en=celebrity['en'], gender=gender, detailed_appearance=detailed_appearance ) profiles[profile.name] = profile self.tracker.add_character(profile) self.character_consistency_map[profile.name] = { 'kr': f"{profile.name}({celebrity['kr']} 닮은 얼굴의 {gender})", 'en': f"{profile.name} ({celebrity['en']} lookalike {gender})", 'appearance': detailed_appearance } return profiles def translate_prompt_to_english(self, korean_prompt: str, character_profiles: Dict[str, CharacterProfile]) -> str: """한글 프롬프트를 영어로 번역""" try: english_prompt = korean_prompt for name, profile in character_profiles.items(): korean_pattern = f"{name}\\([^)]+\\)" english_replacement = f"{name} ({profile.celebrity_lookalike_en} lookalike {profile.gender})" english_prompt = re.sub(korean_pattern, english_replacement, english_prompt) translation_prompt = f"""Translate this Korean webtoon panel description to English. Focus on visual elements and actions, not just characters. Korean: {english_prompt} English translation:""" messages = [{"role": "user", "content": translation_prompt}] translated = self.call_llm_sync(messages, "translator", "English") return translated.strip() except Exception as e: logger.error(f"Translation error: {e}") return korean_prompt def create_planning_prompt(self, query: str, genre: str, language: str) -> str: """Create initial planning prompt for webtoon with character profiles""" genre_info = GENRE_ELEMENTS.get(genre, {}) lang_prompts = { "Korean": f"""한국 웹툰 시장을 겨냥한 {genre} 장르 웹툰을 기획하세요. **[핵심 스토리 설정 - 반드시 이 내용을 중심으로 전개하세요]** {query} **장르:** {genre} **목표:** 40화 완결 웹툰 ⚠️ **중요**: 1. 위에 제시된 스토리 설정을 반드시 기반으로 하여 플롯을 구성하세요. 2. 각 캐릭터의 성별(gender)을 명확히 지정하세요 (male/female). 3. 반드시 40화 전체 구성안을 모두 작성하세요. 4. 각 캐릭터의 외모를 구체적으로 묘사하세요. **장르 필수 요소:** - 핵심 요소: {', '.join(genre_info.get('key_elements', []))} - 비주얼 스타일: {', '.join(genre_info.get('visual_styles', []))} - 주요 씬: {', '.join(genre_info.get('typical_scenes', []))} 다음 형식으로 작성하세요: 📚 **작품 제목:** [임팩트 있는 제목] 🎨 **비주얼 컨셉:** - 그림체: [작품에 어울리는 그림체] - 색감: [주요 색상 톤] - 캐릭터 디자인 특징: [주인공들의 비주얼 특징] 👥 **주요 캐릭터:** (각 캐릭터마다 성별과 외모를 반드시 명시!) - 주인공: [이름] - 성별: [male/female] - 외모: [키, 체형, 머리색, 눈색, 특징] - 성격: [성격 특징] - 목표: [캐릭터의 목표] - 캐릭터2: [이름] - 성별: [male/female] - 외모: [구체적 외모 묘사] - 역할: [역할] - 특징: [특징] - 캐릭터3: [이름] - 성별: [male/female] - 외모: [구체적 외모 묘사] - 역할: [역할] - 특징: [특징] 📖 **시놉시스:** [3-4줄로 전체 스토리 요약] 📝 **40화 전체 구성안:** (반드시 40화 모두 작성!) 각 화별로 핵심 사건과 클리프행어를 포함하여 작성하세요. 1화: [제목] - [핵심 사건] - 클리프행어: [충격적인 마무리] 2화: [제목] - [핵심 사건] - 클리프행어: [충격적인 마무리] 3화: [제목] - [핵심 사건] - 클리프행어: [충격적인 마무리] ... (중간 생략하지 말고 모든 화를 작성) ... 38화: [제목] - [핵심 사건] - 클리프행어: [충격적인 마무리] 39화: [제목] - [핵심 사건] - 클리프행어: [충격적인 마무리] 40화: [제목] - [핵심 사건] - [대단원의 마무리] ⚠️ 절대 생략하지 말고 40화 모두 작성하세요!""", "English": f"""Plan a Korean-style webtoon for {genre} genre. **[Core Story Setting - MUST base the story on this]** {query} **Genre:** {genre} **Goal:** 40 episodes webtoon ⚠️ **IMPORTANT**: 1. You MUST base the plot on the story setting provided above. 2. Clearly specify each character's gender (male/female). 3. MUST write all 40 episodes structure. 4. Describe each character's appearance in detail. **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', []))} Format as follows: 📚 **Title:** [Impactful title] 🎨 **Visual Concept:** - Art style: [Suitable art style] - Color tone: [Main color palette] - Character design: [Visual characteristics] 👥 **Main Characters:** (Must specify gender and appearance for each!) - Protagonist: [Name] - Gender: [male/female] - Appearance: [height, build, hair color, eye color, features] - Personality: [traits] - Goal: [character goal] - Character2: [Name] - Gender: [male/female] - Appearance: [detailed description] - Role: [role] - Traits: [traits] - Character3: [Name] - Gender: [male/female] - Appearance: [detailed description] - Role: [role] - Traits: [traits] 📖 **Synopsis:** [3-4 line story summary] 📝 **40 Episode Structure:** (MUST write all 40 episodes!) Include key events and cliffhangers for each episode. Episode 1: [Title] - [Key event] - Cliffhanger: [Shocking ending] Episode 2: [Title] - [Key event] - Cliffhanger: [Shocking ending] ... (Don't skip, write all episodes) ... Episode 40: [Title] - [Key event] - [Grand finale] ⚠️ Don't abbreviate, write all 40 episodes!""" } return lang_prompts.get(language, lang_prompts["Korean"]) def create_storyboard_prompt(self, episode_num: int, plot_outline: str, genre: str, language: str, character_profiles: Dict[str, CharacterProfile]) -> str: """Create prompt for episode storyboard with story-driven focus""" genre_info = GENRE_ELEMENTS.get(genre, {}) char_descriptions = "\n".join([ f"- {name}: 항상 '{profile.celebrity_lookalike_kr} 닮은 얼굴'로 묘사. {profile.appearance}" for name, profile in character_profiles.items() ]) lang_prompts = { "Korean": f"""웹툰 {episode_num}화 스토리보드를 30개 패널로 작성하세요. **장르:** {genre} **{episode_num}화 내용:** {self._extract_episode_plan(plot_outline, episode_num)} **캐릭터 일관성 규칙:** {char_descriptions} ⚠️ **절대 규칙 - 스토리 중심 전개**: 1. 반드시 30개 패널을 모두 작성하세요! 2. 각 패널은 **스토리 전개의 한 순간**을 담아야 합니다! 3. 단순 인물 샷이 아닌 **사건과 행동 중심**으로 구성! 4. 배경, 상황, 액션을 구체적으로 묘사! 5. 캐릭터가 나올 때마다 "캐릭터이름(유명인 닮은 얼굴)" 형식 유지! **패널 구성 가이드:** - establishing shot (전체 상황/배경): 4-5개 - wide shot (전신/환경): 8-10개 - medium shot (상반신/대화): 10-12개 - close-up (얼굴/감정): 5-6개 - extreme close-up (디테일): 2-3개 **각 패널 작성 형식:** 패널 1: - 샷 타입: [establishing/wide/medium/close_up/extreme_close_up 중 하나] - 이미지 프롬프트: [구체적인 장면 묘사 - 배경, 행동, 분위기 포함] - 대사: [캐릭터 대사] - 나레이션: [해설] - 효과음: [효과음] - 배경: [구체적 배경] ...30개 패널 모두 작성 ⚠️ 스토리 진행이 자연스럽게 이어지도록 구성하세요!""", "English": f"""Create Episode {episode_num} storyboard with 30 panels. **Genre:** {genre} **Episode content:** {self._extract_episode_plan(plot_outline, episode_num)} **Character Consistency Rules:** {char_descriptions} ⚠️ **ABSOLUTE RULES - Story-Driven Focus**: 1. Must write all 30 panels! 2. Each panel must capture **a moment in story progression**! 3. Focus on **events and actions**, not just character shots! 4. Describe backgrounds, situations, actions specifically! 5. Always maintain "Name (celebrity lookalike)" format! **Panel Composition Guide:** - establishing shot (overall scene/setting): 4-5 panels - wide shot (full body/environment): 8-10 panels - medium shot (upper body/dialogue): 10-12 panels - close-up (face/emotion): 5-6 panels - extreme close-up (detail): 2-3 panels **Panel format:** Panel 1: - Shot type: [one of: establishing/wide/medium/close_up/extreme_close_up] - Image prompt: [Specific scene description - include background, action, atmosphere] - Dialogue: [Character dialogue] - Narration: [Narration] - Sound effects: [Effects] - Background: [Specific background] ...write all 30 panels ⚠️ Ensure natural story flow across panels!""" } return lang_prompts.get(language, lang_prompts["Korean"]) def _extract_episode_plan(self, plot_outline: str, episode_num: int) -> str: """Extract specific episode plan from outline""" lines = plot_outline.split('\n') episode_section = [] capturing = False patterns = [ f"{episode_num}화:", f"Episode {episode_num}:", f"제{episode_num}화:", f"EP{episode_num}:", f"{episode_num}.", f"[{episode_num}]" ] next_patterns = [ f"{episode_num+1}화:", f"Episode {episode_num+1}:", f"제{episode_num+1}화:", f"EP{episode_num+1}:", f"{episode_num+1}.", f"[{episode_num+1}]" ] for line in lines: if any(pattern in line for pattern in patterns): capturing = True episode_section.append(line) elif capturing and any(pattern in line for pattern in next_patterns): break elif capturing: episode_section.append(line) if episode_section: return '\n'.join(episode_section) return f"에피소드 {episode_num} 내용을 플롯에서 참조하여 작성하세요." def parse_characters_from_planning(self, planning_doc: str) -> List[Dict]: """Parse character information from planning document""" characters = [] lines = planning_doc.split('\n') in_character_section = False current_char = {} for line in lines: if '주요 캐릭터' in line or 'Main Characters' in line: in_character_section = True continue elif in_character_section and ('시놉시스' in line or 'Synopsis' in line): if current_char: characters.append(current_char) break elif in_character_section and line.strip(): if '성별:' in line or 'Gender:' in line: if current_char: characters.append(current_char) parts = line.split('-') if len(parts) >= 2: name = parts[0].strip().replace('주인공:', '').replace('캐릭터', '').strip() gender = 'male' if 'female' in line.lower() or '여' in line: gender = 'female' elif 'male' in line.lower() or '남' in line: gender = 'male' appearance = '' for part in parts: if '외모:' in part or 'Appearance:' in part: appearance = part.split(':', 1)[1].strip() if ':' in part else part.strip() current_char = { 'name': name, 'gender': gender, 'role': parts[1].strip() if len(parts) > 1 else '', 'personality': parts[2].strip() if len(parts) > 2 else '', 'appearance': appearance } if current_char and current_char not in characters: characters.append(current_char) while len(characters) < 3: characters.append({ 'name': f'캐릭터{len(characters)+1}', 'gender': 'male' if len(characters) % 2 == 0 else 'female', 'role': '조연', 'personality': '일반적', 'appearance': '평범한 외모' }) return characters def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str: full_content = "" for chunk in self.call_llm_streaming(messages, role, language): full_content += chunk if full_content.startswith("❌"): raise Exception(f"LLM Call Failed: {full_content}") return full_content def call_llm_streaming(self, messages: List[Dict[str, str]], role: str, language: str) -> Generator[str, None, None]: try: system_prompts = self.get_system_prompts(language) full_messages = [{"role": "system", "content": system_prompts.get(role, "")}, *messages] max_tokens = 15000 if role == "storyboarder" else 10000 if role == "translator": max_tokens = 2000 payload = { "model": self.model_id, "messages": full_messages, "max_tokens": max_tokens, "temperature": 0.7 if role != "translator" else 0.3, "top_p": 1, "top_k": 40, "presence_penalty": 0, "frequency_penalty": 0, "stream": True } response = requests.post( self.api_url, headers=self.create_headers(), json=payload, stream=True, timeout=180 ) if response.status_code != 200: yield f"❌ API Error (Status Code: {response.status_code})" return buffer = "" for line in response.iter_lines(): if not line: continue try: line_str = line.decode('utf-8').strip() if not line_str.startswith("data: "): continue data_str = line_str[6:] if data_str == "[DONE]": break data = json.loads(data_str) choices = data.get("choices", []) if choices and choices[0].get("delta", {}).get("content"): content = choices[0]["delta"]["content"] buffer += content if len(buffer) >= 50 or '\n' in buffer: yield buffer buffer = "" time.sleep(0.01) except Exception as e: logger.error(f"Chunk processing error: {str(e)}") continue if buffer: yield buffer except Exception as e: logger.error(f"Streaming error: {type(e).__name__}: {str(e)}") yield f"❌ Error occurred: {str(e)}" def get_system_prompts(self, language: str) -> Dict[str, str]: """System prompts for webtoon roles with story-driven emphasis""" base_prompts = { "Korean": { "planner": """당신은 한국 웹툰 시장을 완벽히 이해하는 웹툰 기획자입니다. 독자를 사로잡는 스토리와 비주얼 연출을 기획합니다. 40화 완결 구조로 완벽한 기승전결을 설계합니다. 각 화마다 강력한 클리프행어로 다음 화를 기대하게 만듭니다. 캐릭터의 성별과 외모를 명확히 지정합니다. ⚠️ 가장 중요한 원칙: 1. 사용자가 제공한 스토리 설정을 절대적으로 우선시하고, 이를 중심으로 모든 플롯을 구성합니다. 2. 반드시 40화 전체 구성안을 모두 작성합니다. 생략하지 않습니다. 3. 각 캐릭터의 외모를 구체적으로 묘사합니다.""", "storyboarder": """당신은 웹툰 스토리보드 전문가입니다. 30개 패널로 한 화를 완벽하게 구성합니다. 세로 스크롤에 최적화된 연출을 합니다. ⚠️ 가장 중요한 원칙 - 스토리 중심 전개: 1. 반드시 30개 패널을 모두 작성합니다. 2. 각 패널은 **스토리 진행의 한 순간**을 담습니다. 3. 단순 인물 샷이 아닌 **사건, 행동, 상황 중심**으로 구성합니다. 4. 배경과 환경을 구체적으로 묘사합니다. 5. 다양한 샷 타입을 활용하여 역동적으로 구성합니다. 6. 캐릭터가 나올 때마다 "캐릭터이름(유명인 닮은 얼굴)" 형식을 유지합니다.""", "translator": """You are a professional translator specializing in webtoon and visual content. Translate Korean webtoon panel descriptions to English while maintaining: - Focus on actions and scenes, not just characters - Visual details and camera angles - Environmental descriptions - Emotional nuances - Keep celebrity lookalike descriptions consistent""" }, "English": { "planner": """You perfectly understand the Korean webtoon market. Design stories and visual direction that captivate readers. Create perfect story structure in 40 episodes. Make readers anticipate next episode with strong cliffhangers. Clearly specify character genders and appearances. ⚠️ Most important principles: 1. Absolutely prioritize the user's story setting and build all plots around it. 2. Must write all 40 episodes structure. Don't skip. 3. Describe each character's appearance in detail.""", "storyboarder": """You are a webtoon storyboard specialist. Perfectly compose one episode with 30 panels. ⚠️ Most important principles - Story-Driven Focus: 1. Must write all 30 panels. 2. Each panel captures **a moment in story progression**. 3. Focus on **events, actions, situations**, not just character shots. 4. Describe backgrounds and environments specifically. 5. Use varied shot types for dynamic composition. 6. Always maintain "CharacterName (celebrity lookalike)" format.""", "translator": """You are a professional translator specializing in webtoon and visual content. Translate Korean webtoon panel descriptions to English while maintaining: - Focus on actions and scenes, not just characters - Visual details and camera angles - Environmental descriptions - Emotional nuances - Keep celebrity lookalike descriptions consistent""" } } return base_prompts.get(language, base_prompts["Korean"]) def process_webtoon_stream(self, query: str, genre: str, language: str, session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str, Dict], None, None]: """Webtoon planning and storyboard generation process""" try: 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}") self.original_query = query else: self.current_session_id = session_id yield "", "", f"🎬 웹툰 기획안 작성 중... (40화 전체 구성 포함) - 장르: {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 characters = self.parse_characters_from_planning(planning_doc) character_profiles = self.assign_celebrity_lookalikes(characters) WebtoonDatabase.save_character_profiles(self.current_session_id, character_profiles) WebtoonDatabase.save_character_consistency(self.current_session_id, self.character_consistency_map) yield planning_doc, "", "✅ 기획안 완성! (40화 구성 완료)", self.current_session_id, character_profiles yield planning_doc, "", "🎨 1화 스토리보드 작성 중... (30개 패널)", self.current_session_id, character_profiles storyboard_prompt = self.create_storyboard_prompt(1, planning_doc, genre, language, character_profiles) storyboard_content = self.call_llm_sync( [{"role": "user", "content": storyboard_prompt}], "storyboarder", language ) storyboard = self.parse_storyboard(storyboard_content, 1, character_profiles) WebtoonDatabase.save_storyboard(self.current_session_id, 1, storyboard) yield planning_doc, storyboard_content, "🎉 완성! (기획안 + 1화 스토리보드)", self.current_session_id, character_profiles except Exception as e: 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, character_profiles: Dict[str, CharacterProfile]) -> EpisodeStoryboard: """Parse storyboard text into structured format with scene types""" storyboard = EpisodeStoryboard(episode_number=episode_num, title=f"{episode_num}화") 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: if current_panel.image_prompt and not current_panel.image_prompt_en: current_panel.image_prompt_en = self.translate_prompt_to_english( current_panel.image_prompt, character_profiles ) panels.append(current_panel) panel_number += 1 panel_id = f"ep{episode_num}_panel{panel_number}" current_panel = StoryboardPanel( panel_number=panel_number, scene_type="medium", image_prompt="", panel_id=panel_id ) elif current_panel: if '샷 타입:' in line or 'Shot type:' in line: shot = line.split(':', 1)[1].strip().lower() if 'establishing' in shot: current_panel.scene_type = "establishing" elif 'wide' in shot: current_panel.scene_type = "wide" elif 'close' in shot and 'extreme' in shot: current_panel.scene_type = "extreme_close_up" elif 'close' in shot: current_panel.scene_type = "close_up" else: current_panel.scene_type = "medium" elif '이미지 프롬프트:' in line or 'Image prompt:' in line: prompt = line.split(':', 1)[1].strip() for char_name, consistency in self.character_consistency_map.items(): if char_name in prompt and consistency['kr'] not in prompt: prompt = prompt.replace(char_name, consistency['kr']) current_panel.image_prompt = prompt 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) if current_panel: if current_panel.image_prompt and not current_panel.image_prompt_en: current_panel.image_prompt_en = self.translate_prompt_to_english( current_panel.image_prompt, character_profiles ) panels.append(current_panel) storyboard.panels = panels[:30] return storyboard # Export functions def export_planning_to_txt(planning_doc: str, genre: str, title: str = "") -> str: """Export only planning document (40 episodes structure) to TXT""" content = f"{'=' * 50}\n" content += f"{title if title else genre + ' 웹툰 기획안'}\n" content += f"{'=' * 50}\n\n" content += f"장르: {genre}\n" content += f"총 40화 기획\n" content += f"{'=' * 50}\n\n" content += planning_doc return content def export_storyboard_to_txt(storyboard: str, genre: str, episode_num: int = 1) -> str: """Export only storyboard (30 panels) to TXT""" content = f"{'=' * 50}\n" content += f"{genre} 웹툰 - {episode_num}화 스토리보드\n" content += f"{'=' * 50}\n\n" content += f"총 30개 패널\n" content += f"{'=' * 50}\n\n" content += storyboard return content 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대 직장인의 퇴사 준비 일기", "우리 동네 고양이들의 비밀 회의" ], "개그": [ "이세계 용사인데 스탯이 이상하다", "우리 학교 선생님은 전직 마왕", "좀비 아포칼립스인데 나만 개그 캐릭터" ], "스포츠": [ "벤치 멤버에서 에이스가 되기까지", "여자 야구부 창설기", "은퇴 선수의 코치 도전기" ] } genre_themes = templates.get(genre, templates["로맨스"]) return random.choice(genre_themes) # Parse storyboard panels for display def parse_storyboard_panels(storyboard_content, character_profiles=None): """Parse storyboard content into structured panel data""" if not storyboard_content: return [] panels = [] lines = storyboard_content.split('\n') current_panel = None panel_num = 0 for i, line in enumerate(lines): if any(keyword in line for keyword in ['패널', 'Panel']) and any(char.isdigit() for char in line): if current_panel and current_panel.get('prompt'): panels.append(current_panel) numbers = re.findall(r'\d+', line) panel_num = int(numbers[0]) if numbers else panel_num + 1 current_panel = { 'number': panel_num, 'shot': '', 'prompt': '', 'prompt_en': '', 'dialogue': '', 'narration': '', 'effects': '', 'image_url': None, 'scene_type': 'medium' } elif current_panel: if '샷 타입:' in line or 'Shot type:' in line.lower(): shot = line.split(':', 1)[1].strip() if ':' in line else '' current_panel['shot'] = shot # Determine scene type if 'establishing' in shot.lower(): current_panel['scene_type'] = 'establishing' elif 'wide' in shot.lower(): current_panel['scene_type'] = 'wide' elif 'extreme' in shot.lower() and 'close' in shot.lower(): current_panel['scene_type'] = 'extreme_close_up' elif 'close' in shot.lower(): current_panel['scene_type'] = 'close_up' else: current_panel['scene_type'] = 'medium' elif '이미지 프롬프트:' in line or 'Image prompt:' in line.lower(): current_panel['prompt'] = line.split(':', 1)[1].strip() if ':' in line else '' elif '대사:' in line or 'Dialogue:' in line.lower(): current_panel['dialogue'] = line.split(':', 1)[1].strip() if ':' in line else '' elif '나레이션:' in line or 'Narration:' in line.lower(): current_panel['narration'] = line.split(':', 1)[1].strip() if ':' in line else '' elif '효과음:' in line or 'Sound effect:' in line.lower(): current_panel['effects'] = line.split(':', 1)[1].strip() if ':' in line else '' if current_panel and current_panel.get('prompt'): panels.append(current_panel) return panels[:30] # Gradio interface with side-by-side panel layout def create_interface(): with gr.Blocks(theme=gr.themes.Soft(), title="K-Webtoon Storyboard Generator", css=""" .main-header { text-align: center; margin-bottom: 2rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 2rem; border-radius: 15px; color: white; } .header-title { font-size: 3rem; margin-bottom: 1rem; text-shadow: 2px 2px 4px rgba(0,0,0,0.2); } .panel-container { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 20px 0; } .panel-text-box { border: 2px solid #e0e0e0; border-radius: 10px; padding: 15px; background: #f9f9f9; height: 400px; overflow-y: auto; } .panel-image-box { border: 2px solid #e0e0e0; border-radius: 10px; padding: 15px; background: white; height: 400px; display: flex; align-items: center; justify-content: center; } .panel-header { font-size: 18px; font-weight: bold; color: #764ba2; margin-bottom: 10px; border-bottom: 2px solid #764ba2; padding-bottom: 5px; } .panel-content { font-size: 14px; line-height: 1.8; } .panel-image { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px; } .shot-type { display: inline-block; background: #764ba2; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-bottom: 5px; } .panel-dialogue { background: #fff; padding: 8px; border-left: 3px solid #667eea; margin: 5px 0; } .panel-narration { font-style: italic; color: #666; margin: 5px 0; } .panel-effects { color: #ff6b6b; font-weight: bold; margin: 5px 0; } """) as interface: gr.HTML("""
한국형 웹툰 기획 및 스토리보드 자동 생성 시스템
📚 40화 전체 구성 + 🎬 1화 30패널 스토리보드
스토리보드를 생성하면 여기에 패널이 표시됩니다.
") # Helper functions def process_query(query, genre, language, session_id): system = WebtoonSystem() planning = "" storyboard = "" character_profiles = {} for planning_content, storyboard_content, status, new_session_id, profiles in system.process_webtoon_stream(query, genre, language, session_id): planning = planning_content storyboard = storyboard_content character_profiles = profiles yield planning, storyboard, status, new_session_id, character_profiles, system def format_character_profiles(profiles: Dict[str, CharacterProfile]) -> str: if not profiles: return "" html = "패널 데이터가 없습니다. 스토리보드를 먼저 생성하세요.
" html = "" for panel in panel_data: html += f"""이미지가 아직 생성되지 않았습니다
'}스토리보드가 비어있습니다.
", gr.update(visible=False), gr.update(visible=False) panel_data = parse_storyboard_panels(edited_text, character_profiles) if panel_data: html = display_panels_side_by_side(panel_data) panel_choices = [f"패널 {p['number']}" for p in panel_data] return panel_data, html, gr.update(visible=True, choices=panel_choices, value=[]), gr.update(visible=True, value=edited_text) return [], "패널을 파싱할 수 없습니다.
", gr.update(visible=False), gr.update(visible=True) def generate_selected_panel_images(panel_data, selected_panels, session_id, character_profiles, webtoon_system, progress=gr.Progress()): """Generate images for selected panels only""" if not REPLICATE_API_TOKEN: return display_panels_side_by_side(panel_data), gr.update(visible=True, value="⚠️ Replicate API 토큰이 설정되지 않았습니다.") if not panel_data: return display_panels_side_by_side(panel_data), gr.update(visible=True, value="⚠️ 패널 데이터가 없습니다.") if not selected_panels: return display_panels_side_by_side(panel_data), gr.update(visible=True, value="⚠️ 생성할 패널을 선택하세요.") if not webtoon_system: webtoon_system = WebtoonSystem() selected_numbers = [int(p.split()[1]) for p in selected_panels] total = len(selected_numbers) successful = 0 for i, panel in enumerate(panel_data): if panel['number'] in selected_numbers: idx = selected_numbers.index(panel['number']) progress((idx / total), desc=f"패널 {panel['number']} 생성 중...") if panel.get('prompt'): try: # Translate if needed if not panel.get('prompt_en'): panel['prompt_en'] = webtoon_system.translate_prompt_to_english( panel['prompt'], character_profiles ) # Generate with scene type result = webtoon_system.image_generator.generate_image( panel['prompt_en'], f"ep1_panel{panel['number']}", session_id, scene_type=panel.get('scene_type', 'medium') ) if result['status'] == 'success': panel['image_url'] = result['image_url'] successful += 1 except Exception as e: logger.error(f"Error generating panel {panel['number']}: {e}") time.sleep(0.5) progress(1.0, desc=f"완료! {successful}/{total} 패널 생성 성공") return display_panels_side_by_side(panel_data), gr.update(visible=False) def generate_all_panel_images(panel_data, session_id, character_profiles, webtoon_system, progress=gr.Progress()): """Generate images for all panels""" if not REPLICATE_API_TOKEN: return display_panels_side_by_side(panel_data), gr.update(visible=True, value="⚠️ Replicate API 토큰이 설정되지 않았습니다.") if not panel_data: return display_panels_side_by_side(panel_data), gr.update(visible=True, value="⚠️ 패널 데이터가 없습니다.") if not webtoon_system: webtoon_system = WebtoonSystem() total_panels = len(panel_data) successful = 0 failed = 0 for i, panel in enumerate(panel_data): progress((i / total_panels), desc=f"패널 {panel['number']}/{total_panels} 생성 중...") if panel.get('prompt'): try: # Translate if needed if not panel.get('prompt_en'): panel['prompt_en'] = webtoon_system.translate_prompt_to_english( panel['prompt'], character_profiles ) # Generate with scene type result = webtoon_system.image_generator.generate_image( panel['prompt_en'], f"ep1_panel{panel['number']}", session_id, scene_type=panel.get('scene_type', 'medium') ) if result['status'] == 'success': panel['image_url'] = result['image_url'] successful += 1 else: failed += 1 except Exception as e: logger.error(f"Error generating panel {panel['number']}: {e}") failed += 1 time.sleep(0.5) # Rate limiting progress(1.0, desc=f"완료! 성공: {successful}, 실패: {failed}") return display_panels_side_by_side(panel_data), gr.update(visible=False) def clear_all_images(panel_data): for panel in panel_data: panel['image_url'] = None return display_panels_side_by_side(panel_data) def handle_random_theme(genre, language): return generate_random_webtoon_theme(genre, language) def download_planning(session_id, planning, genre): try: title = f"{genre} 웹툰" content = export_planning_to_txt(planning, genre, title) with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='_planning.txt', delete=False) as f: f.write(content) return f.name except Exception as e: logger.error(f"Download error: {e}") return None def download_storyboard_from_editor(edited_text, genre): try: content = export_storyboard_to_txt(edited_text, genre, 1) with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='_storyboard.txt', delete=False) as f: f.write(content) return f.name except Exception as e: logger.error(f"Download error: {e}") return None # Connect events submit_btn.click( fn=process_query, inputs=[query_input, genre_select, language_select, current_session_id], outputs=[planning_state, storyboard_state, status_text, current_session_id, character_profiles_state, webtoon_system] ).then( fn=lambda x: x, inputs=[planning_state], outputs=[planning_display] ).then( fn=format_character_profiles, inputs=[character_profiles_state], outputs=[character_display] ).then( fn=lambda x: x, inputs=[storyboard_state], outputs=[storyboard_editor] ).then( fn=apply_edited_storyboard, inputs=[storyboard_state, current_session_id, character_profiles_state], outputs=[panel_data_state, panels_display, panel_selector, storyboard_editor] ) # Apply edits button apply_edits_btn.click( fn=apply_edited_storyboard, inputs=[storyboard_editor, current_session_id, character_profiles_state], outputs=[panel_data_state, panels_display, panel_selector, storyboard_editor] ).then( fn=lambda: gr.update(visible=True, value="편집 내용이 적용되었습니다."), outputs=[generation_progress] ) # Generate selected images generate_selected_btn.click( fn=lambda: gr.update(visible=True, value="선택한 패널 이미지 생성 시작..."), outputs=[generation_progress] ).then( fn=generate_selected_panel_images, inputs=[panel_data_state, panel_selector, current_session_id, character_profiles_state, webtoon_system], outputs=[panels_display, generation_progress] ) # Generate all images generate_all_images_btn.click( fn=lambda: gr.update(visible=True, value="모든 패널 이미지 생성 시작..."), outputs=[generation_progress] ).then( fn=generate_all_panel_images, inputs=[panel_data_state, current_session_id, character_profiles_state, webtoon_system], outputs=[panels_display, generation_progress] ) # Clear images clear_images_btn.click( fn=clear_all_images, inputs=[panel_data_state], outputs=[panels_display] ) # Random theme random_btn.click( fn=handle_random_theme, inputs=[genre_select, language_select], outputs=[query_input] ) # Downloads download_planning_btn.click( fn=download_planning, inputs=[current_session_id, planning_state, genre_select], outputs=[planning_download_file] ).then( fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False), inputs=[planning_download_file], outputs=[planning_download_file] ) download_storyboard_btn.click( fn=download_storyboard_from_editor, inputs=[storyboard_editor, genre_select], outputs=[storyboard_download_file] ).then( fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False), inputs=[storyboard_download_file], outputs=[storyboard_download_file] ) return interface # Main if __name__ == "__main__": 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())) if REPLICATE_API_TOKEN: logger.info("Replicate API: Configured ✓") else: logger.warning("Replicate API: Not configured (Image generation disabled)") 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 )