Spaces:
Running
Running
| 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 --- | |
| 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 = "" | |
| 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) | |
| 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 = "" | |
| 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""" | |
| 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() | |
| 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() | |
| 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 | |
| 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() | |
| 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() | |
| 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(""" | |
| <div class="main-header"> | |
| <h1 class="header-title">๐จ K-Webtoon Storyboard Generator</h1> | |
| <p class="header-subtitle">ํ๊ตญํ ์นํฐ ๊ธฐํ ๋ฐ ์คํ ๋ฆฌ๋ณด๋ ์๋ ์์ฑ ์์คํ </p> | |
| <p style="font-size: 14px; opacity: 0.9;">๐ 40ํ ์ ์ฒด ๊ตฌ์ฑ + ๐ฌ 1ํ 30ํจ๋ ์คํ ๋ฆฌ๋ณด๋</p> | |
| </div> | |
| """) | |
| # State variables | |
| current_session_id = gr.State(None) | |
| planning_state = gr.State("") | |
| storyboard_state = gr.State("") | |
| character_profiles_state = gr.State({}) | |
| panel_data_state = gr.State([]) | |
| webtoon_system = gr.State(None) | |
| with gr.Tab("๐ ๊ธฐํ์ ์์ฑ (40ํ ๊ตฌ์ฑ)"): | |
| 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(""" | |
| **๐ญ ์บ๋ฆญํฐ ์ผ๊ตด ์ค์ ** | |
| ๊ฐ ์บ๋ฆญํฐ์ ์ ๋ช ์ธ ๋ฎ์๊ผด์ด | |
| ์๋์ผ๋ก ๋ฐฐ์ ๋ฉ๋๋ค. | |
| **๐ ์์ฑ ๋ด์ฉ** | |
| - 40ํ ์ ์ฒด ๊ตฌ์ฑ์ | |
| - ๊ฐ ํ๋ณ ํด๋ฆฌํํ์ด | |
| - ์บ๋ฆญํฐ ํ๋กํ | |
| """) | |
| status_text = gr.Textbox( | |
| label="์งํ ์ํฉ", | |
| interactive=False, | |
| value="์ฅ๋ฅด๋ฅผ ์ ํํ๊ณ ์ฝ์ ํธ๋ฅผ ์ ๋ ฅํ์ธ์" | |
| ) | |
| # Planning output | |
| gr.Markdown("### ๐ ์นํฐ ๊ธฐํ์ (40ํ ์ ์ฒด ๊ตฌ์ฑ)") | |
| planning_display = gr.Textbox( | |
| label="๊ธฐํ์", | |
| lines=20, | |
| max_lines=50, | |
| interactive=False, | |
| value="๊ธฐํ์์ด ์ฌ๊ธฐ์ ํ์๋ฉ๋๋ค (40ํ ์ ์ฒด ๊ตฌ์ฑ ํฌํจ)" | |
| ) | |
| character_display = gr.HTML(label="์บ๋ฆญํฐ ํ๋กํ") | |
| with gr.Row(): | |
| download_planning_btn = gr.Button("๐ฅ ๊ธฐํ์ ๋ค์ด๋ก๋ (40ํ ๊ตฌ์ฑ)", variant="secondary") | |
| planning_download_file = gr.File(visible=False) | |
| with gr.Tab("๐ฌ 1ํ ์คํ ๋ฆฌ๋ณด๋ (30ํจ๋)"): | |
| gr.Markdown(""" | |
| ### ๐ 1ํ ์คํ ๋ฆฌ๋ณด๋ - 30๊ฐ ํจ๋ | |
| ๊ฐ ํจ๋์ ์คํ ๋ฆฌ ์ ๊ฐ์ ๋ฐ๋ผ ๊ตฌ์ฑ๋๋ฉฐ, ์ข์ธก์ ํ ์คํธ, ์ฐ์ธก์ ์ด๋ฏธ์ง๊ฐ ํ์๋ฉ๋๋ค. | |
| """) | |
| # Control buttons | |
| with gr.Row(): | |
| apply_edits_btn = gr.Button("โ ํธ์ง ๋ด์ฉ ์ ์ฉ", variant="secondary") | |
| generate_selected_btn = gr.Button("๐จ ์ ํํ ํจ๋ ์ด๋ฏธ์ง ์์ฑ", variant="secondary") | |
| generate_all_images_btn = gr.Button("๐จ ๋ชจ๋ ํจ๋ ์ด๋ฏธ์ง ์์ฑ", variant="primary", size="lg") | |
| download_storyboard_btn = gr.Button("๐ฅ ์คํ ๋ฆฌ๋ณด๋ ๋ค์ด๋ก๋", variant="secondary") | |
| clear_images_btn = gr.Button("๐๏ธ ์ด๋ฏธ์ง ์ด๊ธฐํ", variant="secondary") | |
| storyboard_download_file = gr.File(visible=False) | |
| generation_progress = gr.Textbox(label="์งํ ์ํฉ", interactive=False, visible=False) | |
| # Editable storyboard display | |
| storyboard_editor = gr.Textbox( | |
| label="์คํ ๋ฆฌ๋ณด๋ ํธ์ง๊ธฐ", | |
| lines=15, | |
| max_lines=30, | |
| interactive=True, | |
| placeholder="์คํ ๋ฆฌ๋ณด๋๊ฐ ์์ฑ๋๋ฉด ์ฌ๊ธฐ์ ํ์๋ฉ๋๋ค. ์์ ๋กญ๊ฒ ํธ์งํ์ธ์.", | |
| visible=False | |
| ) | |
| # Panel selector | |
| panel_selector = gr.CheckboxGroup( | |
| label="์ด๋ฏธ์ง ์์ฑํ ํจ๋ ์ ํ", | |
| choices=[f"ํจ๋ {i}" for i in range(1, 31)], | |
| value=[], | |
| visible=False | |
| ) | |
| # Panels display area with side-by-side layout | |
| panels_display = gr.HTML(label="ํจ๋ ํ์", value="<p>์คํ ๋ฆฌ๋ณด๋๋ฅผ ์์ฑํ๋ฉด ์ฌ๊ธฐ์ ํจ๋์ด ํ์๋ฉ๋๋ค.</p>") | |
| # 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 = "<h3>๐ญ ์บ๋ฆญํฐ ํ๋กํ</h3>" | |
| for name, profile in profiles.items(): | |
| html += f""" | |
| <div style="background: #f0f0f0; padding: 10px; margin: 5px 0; border-radius: 8px;"> | |
| <strong>{name}</strong> - {profile.celebrity_lookalike_kr} ๋ฎ์ ์ผ๊ตด ({profile.celebrity_lookalike_en}) | |
| <br>์ญํ : {profile.role} | |
| <br>์ฑ๊ฒฉ: {profile.personality} | |
| <br>์ธ๋ชจ: {profile.appearance} | |
| </div> | |
| """ | |
| return html | |
| def display_panels_side_by_side(panel_data): | |
| """Display panels in side-by-side format""" | |
| if not panel_data: | |
| return "<p>ํจ๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ์คํ ๋ฆฌ๋ณด๋๋ฅผ ๋จผ์ ์์ฑํ์ธ์.</p>" | |
| html = "" | |
| for panel in panel_data: | |
| html += f""" | |
| <div class="panel-container"> | |
| <div class="panel-text-box"> | |
| <div class="panel-header">ํจ๋ {panel['number']}</div> | |
| <div class="panel-content"> | |
| <span class="shot-type">{panel.get('shot', 'medium')}</span> | |
| <div><strong>์ฅ๋ฉด:</strong> {panel.get('prompt', '')}</div> | |
| {f'<div class="panel-dialogue"><strong>๋์ฌ:</strong> {panel["dialogue"]}</div>' if panel.get('dialogue') else ''} | |
| {f'<div class="panel-narration"><strong>๋๋ ์ด์ :</strong> {panel["narration"]}</div>' if panel.get('narration') else ''} | |
| {f'<div class="panel-effects"><strong>ํจ๊ณผ์:</strong> {panel["effects"]}</div>' if panel.get('effects') else ''} | |
| </div> | |
| </div> | |
| <div class="panel-image-box"> | |
| {f'<img src="{panel["image_url"]}" class="panel-image" alt="Panel {panel["number"]}" />' if panel.get('image_url') else '<p style="color: #999;">์ด๋ฏธ์ง๊ฐ ์์ง ์์ฑ๋์ง ์์์ต๋๋ค</p>'} | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| def apply_edited_storyboard(edited_text, session_id, character_profiles): | |
| """Parse and apply edited storyboard text""" | |
| if not edited_text: | |
| return [], "<p>์คํ ๋ฆฌ๋ณด๋๊ฐ ๋น์ด์์ต๋๋ค.</p>", 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 [], "<p>ํจ๋์ ํ์ฑํ ์ ์์ต๋๋ค.</p>", 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 | |
| ) |