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 | |
| # 1. import ์น์ ์ ์ถ๊ฐ (๊ธฐ์กด import ๋ค์ ์ถ๊ฐ) | |
| import zipfile | |
| import shutil | |
| # --- 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" | |
| # --- ์ด๋ฏธ์ง ํฌ๊ธฐ ์์ ์ถ๊ฐ --- | |
| WEBTOON_IMAGE_WIDTH = 690 | |
| WEBTOON_IMAGE_HEIGHT = 1227 | |
| # 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 with Auto Resize --- | |
| class ImageGenerator: | |
| """Handle image generation using Replicate API with webtoon-focused prompts and auto-resize""" | |
| def __init__(self): | |
| self.generation_lock = Lock() | |
| self.active_generations = {} | |
| def resize_image_from_url(self, image_url: str, target_width: int = WEBTOON_IMAGE_WIDTH, | |
| target_height: int = WEBTOON_IMAGE_HEIGHT) -> str: | |
| """URL์ ์ด๋ฏธ์ง๋ฅผ ๋ค์ด๋ก๋ํ์ฌ ์ง์ ๋ ํฌ๊ธฐ๋ก ๋ฆฌ์ฌ์ด์ฆ ํ base64๋ก ๋ฐํ""" | |
| try: | |
| # ์ด๋ฏธ์ง ๋ค์ด๋ก๋ | |
| response = requests.get(image_url, timeout=30) | |
| response.raise_for_status() | |
| # PIL Image๋ก ์ด๊ธฐ | |
| img = Image.open(io_module.BytesIO(response.content)) | |
| # RGBA๋ฅผ RGB๋ก ๋ณํ (ํ์์) | |
| if img.mode in ('RGBA', 'LA', 'P'): | |
| # ํฐ์ ๋ฐฐ๊ฒฝ ์์ฑ | |
| background = Image.new('RGB', img.size, (255, 255, 255)) | |
| if img.mode == 'P': | |
| img = img.convert('RGBA') | |
| background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) | |
| img = background | |
| elif img.mode != 'RGB': | |
| img = img.convert('RGB') | |
| # ์๋ณธ ๋น์จ ๊ณ์ฐ | |
| original_ratio = img.width / img.height | |
| target_ratio = target_width / target_height | |
| # ํฌ๋กญ ๋ฐฉ์์ผ๋ก ๋ฆฌ์ฌ์ด์ฆ (์นํฐ ํจ๋์ ์ ํฉ) | |
| if original_ratio > target_ratio: | |
| # ์๋ณธ์ด ๋ ๋์ ๊ฒฝ์ฐ - ์ข์ฐ๋ฅผ ํฌ๋กญ | |
| new_height = img.height | |
| new_width = int(img.height * target_ratio) | |
| left = (img.width - new_width) // 2 | |
| top = 0 | |
| right = left + new_width | |
| bottom = img.height | |
| else: | |
| # ์๋ณธ์ด ๋ ๋์ ๊ฒฝ์ฐ - ์ํ๋ฅผ ํฌ๋กญ | |
| new_width = img.width | |
| new_height = int(img.width / target_ratio) | |
| left = 0 | |
| top = (img.height - new_height) // 2 | |
| right = img.width | |
| bottom = top + new_height | |
| # ํฌ๋กญ | |
| img = img.crop((left, top, right, bottom)) | |
| # ์ต์ข ํฌ๊ธฐ๋ก ๋ฆฌ์ฌ์ด์ฆ (๊ณ ํ์ง Lanczos ํํฐ ์ฌ์ฉ) | |
| img = img.resize((target_width, target_height), Image.Resampling.LANCZOS) | |
| # ์ด๋ฏธ์ง๋ฅผ base64๋ก ์ธ์ฝ๋ฉ | |
| buffered = io_module.BytesIO() | |
| img.save(buffered, format="JPEG", quality=95, optimize=True) | |
| img_base64 = base64.b64encode(buffered.getvalue()).decode() | |
| # data URL ํ์์ผ๋ก ๋ฐํ | |
| return f"data:image/jpeg;base64,{img_base64}" | |
| except Exception as e: | |
| logger.error(f"Image resize error: {e}") | |
| # ๋ฆฌ์ฌ์ด์ฆ ์คํจ์ ์๋ณธ URL ๋ฐํ | |
| return image_url | |
| def enhance_prompt_for_webtoon(self, prompt: str, panel_number: int, scene_type: str = "medium", genre: str = "๋ก๋งจ์ค") -> str: | |
| """Enhanced prompt for webtoon-style panels - ๋จ์ผ ํ๋ ์ ๊ฐ์กฐ""" | |
| # ์นํฐ ์คํ์ผ ๊ธฐ๋ณธ ์ค์ - ์ธ๋กํ ํฌ๋งท ๊ฐ์กฐ | |
| base_style = "manhwa illustration, clean line art, vibrant colors, vertical format, 9:16 aspect ratio, single unified scene" | |
| # ์ฅ๋ฅด๋ณ ์คํ์ผ ์กฐ์ | |
| genre_styles = { | |
| "๋ก๋งจ์ค": "soft colors, romantic atmosphere, emotional lighting, shoujo manga style", | |
| "ํํ์ง": "dynamic action, magical effects, epic atmosphere, detailed backgrounds", | |
| "์ค๋ฆด๋ฌ": "dark tones, dramatic shadows, suspenseful mood, noir style", | |
| "์ผ์": "warm colors, everyday scenes, comfortable atmosphere, slice of life", | |
| "๊ฐ๊ทธ": "exaggerated expressions, comedic style, bright colors, cartoon style", | |
| "์คํฌ์ธ ": "dynamic motion, athletic poses, energetic atmosphere, speed lines", | |
| "๋ฌดํ": "martial arts action, eastern style, dramatic poses, wuxia style", | |
| "๋กํ": "fantasy romance, elegant costumes, magical atmosphere, aristocratic", | |
| "ํํ": "modern fantasy, urban setting, supernatural effects, contemporary" | |
| } | |
| # ์ฌ ํ์ ๋ณ ์นด๋ฉ๋ผ ์ต๊ธ๊ณผ ๊ตฌ๋ | |
| scene_compositions = { | |
| "establishing": "wide establishing shot, environmental focus, detailed background, scene setting, panoramic view", | |
| "wide": "full body shot, complete scene, character in environment, full action view", | |
| "medium": "waist-up shot, character interaction, balanced composition, dialogue scene", | |
| "close_up": "face close-up, emotional expression, detailed facial features, intimate moment", | |
| "extreme_close_up": "extreme detail shot, eyes or hands focus, dramatic emphasis, intense emotion" | |
| } | |
| # ์ก์ /๊ฐ์ ํค์๋ ๊ฐ์กฐ | |
| action_keywords = ["running", "jumping", "fighting", "crying", "laughing", "shocked", "surprised", | |
| "angry", "happy", "sad", "walking", "sitting", "standing", "falling", "flying"] | |
| # ํ๋กฌํํธ์์ ์ก์ ํค์๋ ์ฐพ๊ธฐ | |
| action_found = any(keyword in prompt.lower() for keyword in action_keywords) | |
| # ์ฅ๋ฅด ์คํ์ผ ๊ฐ์ ธ์ค๊ธฐ | |
| genre_style = genre_styles.get(genre, "") | |
| # ์ฌ ๊ตฌ๋ ๊ฐ์ ธ์ค๊ธฐ | |
| scene_comp = scene_compositions.get(scene_type, scene_compositions["medium"]) | |
| # ์ก์ ์ค์ฌ ๊ฐ์กฐ | |
| if action_found: | |
| action_emphasis = "dynamic pose, motion blur effect, action lines, movement emphasis" | |
| else: | |
| action_emphasis = "clear composition, focused scene" | |
| # ์นํฐ ํจ๋ ํน์ฑ - ๋จ์ผ ํ๋ ์ ๊ฐ์กฐ | |
| panel_style = "single illustration, one continuous scene, no comic strips, no split frames, no divided panels, unified composition" | |
| # ๋ฐฐ๊ฒฝ๊ณผ ํ๊ฒฝ ๊ฐ์กฐ | |
| if "establishing" in scene_type or "wide" in scene_type: | |
| environment_focus = "detailed environment, atmospheric perspective, scene context" | |
| else: | |
| environment_focus = "" | |
| # ์ต์ข ํ๋กฌํํธ ๊ตฌ์ฑ (์์ ์ค์) | |
| enhanced_prompt = f"Single Scene Illustration: {scene_comp}, {prompt}, {base_style}, {genre_style}, {action_emphasis}, {panel_style}" | |
| if environment_focus: | |
| enhanced_prompt += f", {environment_focus}" | |
| # "panel"์ด๋ผ๋ ๋จ์ด๋ฅผ "scene"์ผ๋ก ์นํ | |
| enhanced_prompt = enhanced_prompt.replace("panel", "scene") | |
| enhanced_prompt = enhanced_prompt.replace("panels", "scenes") | |
| # ๋ถํ ๋ฐฉ์ง ๊ฐ์กฐ๊ตฌ๋ฌธ ์ถ๊ฐ | |
| enhanced_prompt += ", single unified image, no frame divisions, continuous scene, not a comic strip" | |
| # ๋ถํ์ํ ์ค๋ณต ์ ๊ฑฐ | |
| enhanced_prompt = ", ".join(dict.fromkeys(enhanced_prompt.split(", "))) | |
| # ๊ธธ์ด ์ ํ | |
| if len(enhanced_prompt) > 500: | |
| # ํต์ฌ ์์๋ง ์ ์ง | |
| core_elements = f"{scene_comp}, {prompt[:250]}, {base_style}, {action_emphasis}, single unified image, no frame divisions" | |
| enhanced_prompt = core_elements | |
| return enhanced_prompt # ์ฌ๋ฐ๋ฅธ ์ธ๋ดํ ์ด์ | |
| def generate_image(self, prompt: str, panel_id: str, session_id: str, | |
| scene_type: str = "medium", genre: str = "๋ก๋งจ์ค", | |
| force_regenerate: bool = False, # ์ฌ์์ฑ ๊ฐ์ ํ๋๊ทธ ์ถ๊ฐ | |
| progress_callback=None) -> Dict[str, Any]: | |
| """Generate image using qwen/qwen-image with regeneration support""" | |
| try: | |
| if not REPLICATE_API_TOKEN: | |
| logger.warning("No Replicate API token") | |
| return {"panel_id": panel_id, "status": "error", "message": "No API token"} | |
| # ์บ์ ํค ์์ฑ | |
| cache_key = f"{session_id}_{panel_id}" | |
| # force_regenerate๊ฐ True์ด๋ฉด ์บ์ ๋ฌด์ํ๊ณ ์๋ก ์์ฑ | |
| if not force_regenerate and cache_key in generated_images_cache: | |
| logger.info(f"Using cached image for {panel_id}") | |
| # ์บ์๋ ์ด๋ฏธ์ง ๋ฐํํ์ง ์๊ณ ์๋ก ์์ฑํ๋๋ก ์์ | |
| # return {"panel_id": panel_id, "status": "success", "image_url": generated_images_cache[cache_key]} | |
| pass # ์บ์ ๋ฌด์ํ๊ณ ๊ณ์ ์งํ | |
| # Panel number ์ถ์ถ | |
| panel_number = int(re.findall(r'\d+', panel_id)[-1]) if re.findall(r'\d+', panel_id) else 1 | |
| # ์นํฐ ์คํ์ผ ํ๋กฌํํธ ๊ฐํ | |
| enhanced_prompt = self.enhance_prompt_for_webtoon(prompt, panel_number, scene_type, genre) | |
| # ์ฌ์์ฑ ์ ํ๋กฌํํธ์ ๋ณํ ์ถ๊ฐ (๋ ๋ค์ํ ๊ฒฐ๊ณผ๋ฅผ ์ํด) | |
| if force_regenerate or cache_key in generated_images_cache: | |
| # ํ๋กฌํํธ์ ์ฝ๊ฐ์ ๋ณํ ์ถ๊ฐ | |
| variation_keywords = [ | |
| "different angle", "alternative view", "varied perspective", | |
| "new composition", "fresh approach", "revised scene", | |
| "different lighting", "alternative mood", "varied atmosphere" | |
| ] | |
| variation = random.choice(variation_keywords) | |
| enhanced_prompt = f"{enhanced_prompt}, {variation}" | |
| logger.info(f"Regenerating with variation: {variation}") | |
| # ์ฌ์์ฑ ์ ๋ค๋ฅธ seed ์ฌ์ฉํ์ฌ ๋ค๋ฅธ ์ด๋ฏธ์ง ์์ฑ ๋ณด์ฅ | |
| if force_regenerate or cache_key in generated_images_cache: | |
| # ๋๋ค seed ์์ฑ (1-1000000 ๋ฒ์) | |
| random_seed = random.randint(1, 1000000) | |
| logger.info(f"Using random seed for regeneration: {random_seed}") | |
| else: | |
| # ์ฒซ ์์ฑ ์์๋ ๋๋ค seed ์ฌ์ฉ | |
| random_seed = random.randint(1, 1000000) | |
| # qwen/qwen-image ํ๋ผ๋ฏธํฐ ์ค์ | |
| input_params = { | |
| "prompt": enhanced_prompt, | |
| "aspect_ratio": "9:16", | |
| "num_outputs": 1, | |
| "guidance_scale": 4.5 + random.uniform(-0.5, 0.5), # ์ฝ๊ฐ์ ๋๋ค์ฑ ์ถ๊ฐ | |
| "num_inference_steps": 50, | |
| "output_format": "jpg", | |
| "output_quality": 95, | |
| "disable_safety_checker": False, | |
| "negative_prompt": "comic strip, multiple panels, divided frames, split screen, grid layout, manga panels", | |
| "seed": random_seed # ๋ช ์์ ์ผ๋ก ๋๋ค seed ์ค์ | |
| } | |
| logger.info(f"Generating {'new variant' if force_regenerate else 'image'} for panel {panel_id}") | |
| logger.info(f"Seed: {random_seed}, Guidance: {input_params['guidance_scale']:.2f}") | |
| try: | |
| # Replicate API ํธ์ถ | |
| output = replicate.run( | |
| "qwen/qwen-image", | |
| input=input_params | |
| ) | |
| if output: | |
| # ์ด๋ฏธ์ง URL ์ถ์ถ | |
| if isinstance(output, list) and len(output) > 0: | |
| image_item = output[0] | |
| if hasattr(image_item, 'url'): | |
| if callable(image_item.url): | |
| image_url = image_item.url() | |
| else: | |
| image_url = image_item.url | |
| else: | |
| image_url = str(image_item) | |
| elif isinstance(output, str): | |
| image_url = output | |
| else: | |
| image_url = str(output) | |
| # URL ์ ํจ์ฑ ํ์ธ | |
| if not image_url or not (image_url.startswith('http') or image_url.startswith('data:')): | |
| logger.error(f"Invalid image URL: {image_url}") | |
| return {"panel_id": panel_id, "status": "error", "message": "Invalid image URL format"} | |
| # ์ด๋ฏธ์ง ๋ฆฌ์ฌ์ด์ฆ | |
| logger.info(f"Resizing image to {WEBTOON_IMAGE_WIDTH}x{WEBTOON_IMAGE_HEIGHT}px") | |
| resized_image_url = self.resize_image_from_url( | |
| image_url, | |
| WEBTOON_IMAGE_WIDTH, | |
| WEBTOON_IMAGE_HEIGHT | |
| ) | |
| # ์บ์ ์ ๋ฐ์ดํธ (์ฌ์์ฑ ์์๋ ์ ์ด๋ฏธ์ง๋ก ๋ฎ์ด์ฐ๊ธฐ) | |
| generated_images_cache[cache_key] = resized_image_url | |
| logger.info(f"Successfully generated {'variant' if force_regenerate else 'image'} for panel {panel_id}") | |
| return { | |
| "panel_id": panel_id, | |
| "status": "success", | |
| "image_url": resized_image_url, | |
| "original_url": image_url, | |
| "prompt": enhanced_prompt, | |
| "size": f"{WEBTOON_IMAGE_WIDTH}x{WEBTOON_IMAGE_HEIGHT}", | |
| "seed": random_seed, # seed ์ ๋ณด ๋ฐํ | |
| "regenerated": force_regenerate # ์ฌ์์ฑ ์ฌ๋ถ ํ์ | |
| } | |
| else: | |
| logger.error(f"No output from qwen/qwen-image for panel {panel_id}") | |
| return {"panel_id": panel_id, "status": "error", "message": "No output from model"} | |
| except replicate.exceptions.ModelError as e: | |
| logger.error(f"Replicate Model Error: {e}") | |
| return {"panel_id": panel_id, "status": "error", "message": f"Model error: {str(e)}"} | |
| except replicate.exceptions.ReplicateError as e: | |
| logger.error(f"Replicate API Error: {e}") | |
| return {"panel_id": panel_id, "status": "error", "message": f"API error: {str(e)}"} | |
| except Exception as e: | |
| logger.error(f"Image generation error: {e}", exc_info=True) | |
| 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 = {} | |
| self.current_genre = "๋ก๋งจ์ค" # ๊ธฐ๋ณธ ์ฅ๋ฅด | |
| 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: | |
| - Actions and movements (what characters are doing) | |
| - Facial expressions and emotions | |
| - Environment and background details | |
| - Objects and props in the scene | |
| - Camera angles and composition | |
| Korean: {english_prompt} | |
| English translation (focus on visual elements):""" | |
| 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 action-focused panels""" | |
| 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 - Action and Scene Focus**: | |
| 1. Must write all 30 panels! | |
| 2. Each panel must include **specific actions, expressions, environments**! | |
| 3. Focus on **what they're doing, their expressions, where they are** rather than just character descriptions! | |
| 4. Describe backgrounds, props, atmosphere 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 action and scene description - e.g., "protagonist looking out window with sad expression watching rain. Dark room interior, raindrops on window"] | |
| - Dialogue: [Character dialogue] | |
| - Narration: [Narration] | |
| - Sound effects: [Effects] | |
| - Background: [Specific background] | |
| โ ๏ธ Image prompts must be complete scene descriptions including actions, expressions, and environment! | |
| ...write all 30 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 action-focused 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, movements, and what characters are doing | |
| - Facial expressions and emotions | |
| - Environmental details and backgrounds | |
| - Objects and props in the scene | |
| - Camera angles and shot types | |
| - Keep celebrity lookalike descriptions consistent | |
| Make the translation suitable for image generation AI.""" | |
| }, | |
| "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 - Action and Scene Focus: | |
| 1. Must write all 30 panels. | |
| 2. Each panel describes **specific actions, expressions, environments**. | |
| 3. Focus on "what they're doing, their expressions, where they are". | |
| 4. Include backgrounds, props, atmosphere 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, movements, and what characters are doing | |
| - Facial expressions and emotions | |
| - Environmental details and backgrounds | |
| - Objects and props in the scene | |
| - Camera angles and shot types | |
| - Keep celebrity lookalike descriptions consistent | |
| Make the translation suitable for image generation AI.""" | |
| } | |
| } | |
| 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: | |
| self.current_genre = genre # ์ฅ๋ฅด ์ ์ฅ | |
| 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 = "" | |
| for chunk in self.call_llm_streaming( | |
| [{"role": "user", "content": planning_prompt}], | |
| "planner", language | |
| ): | |
| planning_doc += chunk | |
| # ์ฒญํฌ๋ง๋ค ์ค๊ฐ ๊ฒฐ๊ณผ ์ ์ก | |
| yield planning_doc, "", f"๐ฌ ๊ธฐํ์ ์์ฑ ์ค... (์์ฑ๋ ๊ธ์: {len(planning_doc)}์)", self.current_session_id, {} | |
| 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 = "" | |
| for chunk in self.call_llm_streaming( | |
| [{"role": "user", "content": storyboard_prompt}], | |
| "storyboarder", language | |
| ): | |
| storyboard_content += chunk | |
| # ์คํ ๋ฆฌ๋ณด๋ ํ ์คํธ๋ฅผ ์ค์๊ฐ์ผ๋ก ์ ๋ฐ์ดํธ | |
| yield planning_doc, storyboard_content, f"๐ฌ ์คํ ๋ฆฌ๋ณด๋ ์์ฑ ์ค... ({len(storyboard_content)}์)", self.current_session_id, character_profiles | |
| # ์คํธ๋ฆฌ๋ฐ ์๋ฃ ํ ํ์ฑ ๋ฐ ์ ์ฅ | |
| 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) | |
| # CSS ์คํ์ผ ์ ์ - ์ด๋ฏธ์ง ํ์ ์์ญ ์ต์ ํ | |
| CSS_STYLES = """ | |
| .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; | |
| border: 2px solid #e0e0e0; | |
| border-radius: 10px; | |
| padding: 15px; | |
| background: #f9f9f9; | |
| } | |
| .panel-text-box { | |
| border: 2px solid #e0e0e0; | |
| border-radius: 10px; | |
| padding: 15px; | |
| background: white; | |
| min-height: 400px; | |
| } | |
| .panel-image-box { | |
| border: 2px solid #e0e0e0; | |
| border-radius: 10px; | |
| padding: 15px; | |
| background: white; | |
| min-height: 400px; | |
| display: flex; | |
| flex-direction: column; | |
| 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 { | |
| width: 100%; | |
| max-width: 345px; /* 690px์ ์ ๋ฐ */ | |
| height: auto; | |
| object-fit: contain; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .image-size-info { | |
| margin-top: 10px; | |
| font-size: 12px; | |
| color: #666; | |
| text-align: center; | |
| } | |
| .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; | |
| } | |
| .prompt-display { | |
| background: #f5f5f5; | |
| padding: 8px; | |
| border-radius: 5px; | |
| margin: 10px 0; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| } | |
| .panel-info { | |
| margin-top: 10px; | |
| padding: 5px; | |
| background: #fffbf0; | |
| border-radius: 5px; | |
| font-size: 12px; | |
| color: #666; | |
| } | |
| """ | |
| # 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', | |
| 'genre': '๋ก๋งจ์ค' # ๊ธฐ๋ณธ ์ฅ๋ฅด | |
| } | |
| 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] | |
| # 2. ์ ์ฒด ์ด๋ฏธ์ง ๋ค์ด๋ก๋ ํจ์ ์ถ๊ฐ (helper functions ์น์ ์ ์ถ๊ฐ) | |
| def download_all_panel_images(panel_data, session_id, genre): | |
| """๋ชจ๋ ํจ๋ ์ด๋ฏธ์ง๋ฅผ ZIP ํ์ผ๋ก ๋ค์ด๋ก๋""" | |
| try: | |
| if not panel_data: | |
| logger.warning("No panel data available") | |
| return None | |
| # ์ด๋ฏธ์ง๊ฐ ์๋ ํจ๋๋ง ํํฐ๋ง | |
| panels_with_images = [p for p in panel_data if p.get('image_url')] | |
| if not panels_with_images: | |
| logger.warning("No images to download") | |
| return None | |
| # ์์ ๋๋ ํ ๋ฆฌ ์์ฑ | |
| with tempfile.TemporaryDirectory() as temp_dir: | |
| image_dir = Path(temp_dir) / "webtoon_images" | |
| image_dir.mkdir(exist_ok=True) | |
| # ๊ฐ ์ด๋ฏธ์ง ์ ์ฅ | |
| for panel in panels_with_images: | |
| panel_num = panel['number'] | |
| image_url = panel['image_url'] | |
| try: | |
| # base64 ์ด๋ฏธ์ง ์ฒ๋ฆฌ | |
| if image_url.startswith('data:image'): | |
| # data URL์์ base64 ๋ฐ์ดํฐ ์ถ์ถ | |
| header, data = image_url.split(',', 1) | |
| image_data = base64.b64decode(data) | |
| # ํ์ผ๋ก ์ ์ฅ | |
| image_path = image_dir / f"panel_{panel_num:03d}.jpg" | |
| with open(image_path, 'wb') as f: | |
| f.write(image_data) | |
| else: | |
| # URL์์ ์ด๋ฏธ์ง ๋ค์ด๋ก๋ | |
| response = requests.get(image_url, timeout=30) | |
| response.raise_for_status() | |
| image_path = image_dir / f"panel_{panel_num:03d}.jpg" | |
| with open(image_path, 'wb') as f: | |
| f.write(response.content) | |
| logger.info(f"Saved panel {panel_num} image") | |
| except Exception as e: | |
| logger.error(f"Failed to save panel {panel_num}: {e}") | |
| continue | |
| # ๋ฉํ๋ฐ์ดํฐ ํ์ผ ์์ฑ | |
| metadata_path = image_dir / "metadata.txt" | |
| with open(metadata_path, 'w', encoding='utf-8') as f: | |
| f.write(f"์นํฐ ์ด๋ฏธ์ง ์ ๋ณด\n") | |
| f.write(f"{'=' * 50}\n") | |
| f.write(f"์ฅ๋ฅด: {genre}\n") | |
| f.write(f"์ธ์ ID: {session_id}\n") | |
| f.write(f"์ด ์ด๋ฏธ์ง ์: {len(panels_with_images)}๊ฐ\n") | |
| f.write(f"์ด๋ฏธ์ง ํฌ๊ธฐ: {WEBTOON_IMAGE_WIDTH}ร{WEBTOON_IMAGE_HEIGHT}px\n") | |
| f.write(f"์์ฑ ๋ ์ง: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") | |
| f.write(f"{'=' * 50}\n\n") | |
| for panel in panels_with_images: | |
| f.write(f"ํจ๋ {panel['number']:03d}\n") | |
| f.write(f" - ์ท ํ์ : {panel.get('shot', 'N/A')}\n") | |
| f.write(f" - ํ๋กฌํํธ: {panel.get('prompt', 'N/A')}\n") | |
| if panel.get('dialogue'): | |
| f.write(f" - ๋์ฌ: {panel['dialogue']}\n") | |
| if panel.get('narration'): | |
| f.write(f" - ๋๋ ์ด์ : {panel['narration']}\n") | |
| f.write("\n") | |
| # ZIP ํ์ผ ์์ฑ | |
| zip_path = Path(temp_dir) / f"webtoon_{genre}_episode1_images.zip" | |
| shutil.make_archive(str(zip_path.with_suffix('')), 'zip', image_dir) | |
| # ์์ฑ๋ ZIP ํ์ผ์ ์์ ํ์ผ๋ก ๋ณต์ฌ | |
| with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as final_zip: | |
| shutil.copy2(zip_path, final_zip.name) | |
| logger.info(f"Created ZIP file with {len(panels_with_images)} images: {final_zip.name}") | |
| return final_zip.name | |
| except Exception as e: | |
| logger.error(f"Failed to create image ZIP: {e}") | |
| return None | |
| # Gradio interface with enhanced panel editing and regeneration | |
| # Gradio interface with enhanced panel editing and regeneration | |
| def create_interface(): | |
| with gr.Blocks(theme=gr.themes.Soft(), title="K-Webtoon Storyboard Generator", css=CSS_STYLES) 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 with enhanced panel editing | |
| 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) | |
| panel_prompts_state = gr.State({}) # Store editable prompts for each panel | |
| current_genre = gr.State("๋ก๋งจ์ค") # Store current genre | |
| 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") | |
| with gr.Row(): | |
| download_storyboard_btn = gr.Button("๐ฅ ์คํ ๋ฆฌ๋ณด๋ ๋ค์ด๋ก๋", variant="secondary") | |
| download_all_images_btn = gr.Button("๐ผ๏ธ ์ ์ฒด ์ด๋ฏธ์ง ๋ค์ด๋ก๋ (ZIP)", variant="secondary") | |
| clear_images_btn = gr.Button("๐๏ธ ์ด๋ฏธ์ง ์ด๊ธฐํ", variant="secondary") | |
| storyboard_download_file = gr.File(visible=False) | |
| images_download_file = gr.File(visible=False, label="์ด๋ฏธ์ง ZIP ํ์ผ") | |
| 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=True | |
| ) | |
| # Panel selector | |
| panel_selector = gr.CheckboxGroup( | |
| label="์ด๋ฏธ์ง ์์ฑํ ํจ๋ ์ ํ", | |
| choices=[f"ํจ๋ {i}" for i in range(1, 31)], | |
| value=[], | |
| visible=False | |
| ) | |
| # Panels display area with editable prompts and individual regeneration | |
| panels_display = gr.HTML(label="ํจ๋ ํ์", value="<p>์คํ ๋ฆฌ๋ณด๋๋ฅผ ์์ฑํ๋ฉด ์ฌ๊ธฐ์ ํจ๋์ด ํ์๋ฉ๋๋ค.</p>") | |
| with gr.Tab("๐ญ ์นํฐ ํธ์ง ์คํ๋์ค"): | |
| gr.Markdown(""" | |
| ### ๐ ์นํฐ ํธ์ง ์คํ๋์ค | |
| ์์ฑ๋ ์คํ ๋ฆฌ๋ณด๋์ ์ด๋ฏธ์ง๋ฅผ ํธ์งํ๊ณ ์ต์ข ์นํฐ์ ์์ฑํ์ธ์. | |
| """) | |
| # index.html์ iframe์ผ๋ก ์๋ฒ ๋ฉ | |
| studio_html = gr.HTML( | |
| value=""" | |
| <iframe | |
| src="/file=index.html" | |
| width="100%" | |
| height="100%" | |
| style="border: 2px solid #e0e0e0; border-radius: 10px; background: white;" | |
| frameborder="0" | |
| scrolling="auto"> | |
| </iframe> | |
| """, | |
| label="ํธ์ง ์คํ๋์ค" | |
| ) | |
| # ํธ์ง ์คํ๋์ค ์ค๋ช | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown(""" | |
| **๐ ํธ์ง ์คํ๋์ค ๊ธฐ๋ฅ:** | |
| - ํจ๋ ์์ ๋ณ๊ฒฝ | |
| - ํ ์คํธ ๋ฐ ๋์ฌ ํธ์ง | |
| - ์ด๋ฏธ์ง ํฌ๊ธฐ ์กฐ์ | |
| - ํจ๊ณผ ์ถ๊ฐ | |
| - ์ต์ข ์นํฐ ๋ด๋ณด๋ด๊ธฐ | |
| """) | |
| with gr.Column(): | |
| gr.Markdown(""" | |
| **๐ก ์ฌ์ฉ ํ:** | |
| - ๋จผ์ 1ํ ์คํ ๋ฆฌ๋ณด๋๋ฅผ ์์ฑํ์ธ์ | |
| - ์ด๋ฏธ์ง๋ฅผ ์์ฑํ ํ ํธ์ง ์คํ๋์ค๋ก ์ด๋ํ์ธ์ | |
| - ํธ์ง์ด ์๋ฃ๋๋ฉด ์นํฐ์ ๋ด๋ณด๋ผ ์ ์์ต๋๋ค | |
| """) | |
| # Helper functions | |
| def process_query(query, genre, language, session_id): | |
| system = WebtoonSystem() | |
| for planning_content, storyboard_content, status, new_session_id, profiles in system.process_webtoon_stream(query, genre, language, session_id): | |
| # ๋งค ์คํธ๋ฆฌ๋ฐ๋ง๋ค ์ฆ์ ํ๋ฉด ์ ๋ฐ์ดํธ | |
| yield planning_content, storyboard_content, status, new_session_id, profiles, system, genre | |
| time.sleep(0.01) # UI ์ ๋ฐ์ดํธ๋ฅผ ์ํ ์งง์ ๋๊ธฐ | |
| # ์์ ๋ ํจ์: state ์ ๋ฐ์ดํธ์ ํจ๋ UI ์ค์๊ฐ ๊ฐฑ์ ์ ํฌํจ | |
| def process_query_with_state_update(query, genre, language, session_id): | |
| """process_query๋ฅผ ๋ํํ์ฌ state ์ ๋ฐ์ดํธ ๋ฐ ํจ๋ UI ์ค์๊ฐ ๊ฐฑ์ """ | |
| system = WebtoonSystem() | |
| final_planning = "" | |
| final_storyboard = "" | |
| final_status = "" | |
| final_session_id = None | |
| final_profiles = {} | |
| final_system = None | |
| final_genre = genre | |
| # ํจ๋ ๊ด๋ จ ์ํ ์ถ๊ฐ | |
| panel_prompts_local = {} | |
| panel_data_local = [] | |
| for planning_content, storyboard_content, status, new_session_id, profiles, system_obj, genre_val in process_query(query, genre, language, session_id): | |
| final_planning = planning_content | |
| final_storyboard = storyboard_content | |
| final_status = status | |
| final_session_id = new_session_id | |
| final_profiles = profiles | |
| final_system = system_obj | |
| final_genre = genre_val | |
| # ์คํ ๋ฆฌ๋ณด๋๊ฐ ์์ฑ๋๋ ์ค์ด๋ฉด ํจ๋ ํ์ฑ ์๋ | |
| if final_storyboard and final_profiles: | |
| try: | |
| panel_data_local = parse_storyboard_panels(final_storyboard, final_profiles) | |
| if panel_data_local: | |
| # ํจ๋ HTML ์์ฑ | |
| html, panel_prompts_local = display_panels_with_editable_prompts( | |
| panel_data_local, panel_prompts_local, final_session_id, | |
| final_profiles, final_system, final_genre | |
| ) | |
| panel_choices = [f"ํจ๋ {p['number']}" for p in panel_data_local] | |
| panel_selector_update = gr.update(visible=True, choices=panel_choices, value=[]) | |
| else: | |
| html = "<p>์คํ ๋ฆฌ๋ณด๋ ํ์ฑ ์ค...</p>" | |
| panel_selector_update = gr.update(visible=False) | |
| except: | |
| html = "<p>์คํ ๋ฆฌ๋ณด๋ ์์ฑ ์ค...</p>" | |
| panel_selector_update = gr.update(visible=False) | |
| else: | |
| html = "<p>๊ธฐํ์ ์์ฑ ์ค์ ๋๋ค. ์คํ ๋ฆฌ๋ณด๋๋ ์ ์ ํ ์์ฑ๋ฉ๋๋ค.</p>" | |
| panel_selector_update = gr.update(visible=False) | |
| # ๋ชจ๋ ์ถ๋ ฅ ํฌํจํ์ฌ yield | |
| yield ( | |
| final_planning, # planning_display | |
| final_storyboard, # storyboard_editor | |
| final_status, # status_text | |
| final_session_id, # current_session_id | |
| final_profiles, # character_profiles_state | |
| final_system, # webtoon_system | |
| final_genre, # current_genre | |
| final_planning, # planning_state | |
| final_storyboard, # storyboard_state | |
| panel_data_local, # panel_data_state (์ถ๊ฐ) | |
| html, # panels_display (์ถ๊ฐ) | |
| panel_selector_update, # panel_selector (์ถ๊ฐ) | |
| panel_prompts_local # panel_prompts_state (์ถ๊ฐ) | |
| ) | |
| 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_with_editable_prompts(panel_data, panel_prompts, session_id, character_profiles, webtoon_system, genre): | |
| """Display panels with editable prompts and size info (simplified version without JavaScript)""" | |
| if not panel_data: | |
| return "<p>ํจ๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ์คํ ๋ฆฌ๋ณด๋๋ฅผ ๋จผ์ ์์ฑํ์ธ์.</p>", panel_prompts | |
| # Initialize panel_prompts if empty | |
| if not panel_prompts: | |
| panel_prompts = {f"panel_{panel['number']}": panel.get('prompt', '') for panel in panel_data} | |
| html = "" | |
| for panel in panel_data: | |
| panel_id = f"panel_{panel['number']}" | |
| current_prompt = panel_prompts.get(panel_id, panel.get('prompt', '')) | |
| html += f""" | |
| <div class="panel-container" id="panel_container_{panel['number']}"> | |
| <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></div> | |
| <div class="prompt-display">{current_prompt}</div> | |
| <div class="panel-info"> | |
| <small>๐ก ํธ์งํ๋ ค๋ฉด ์๋จ ํธ์ง๊ธฐ์์ ์์ ํ 'ํธ์ง ๋ด์ฉ ์ ์ฉ' ๋ฒํผ์ ํด๋ฆญํ์ธ์</small> | |
| </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"> | |
| <div id="image_container_{panel['number']}" style="width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;"> | |
| {f'<img src="{panel["image_url"]}" class="panel-image" alt="Panel {panel["number"]}" /><div class="image-size-info">๐ ํฌ๊ธฐ: 690ร1227px</div>' if panel.get('image_url') else '<p style="color: #999;">์ด๋ฏธ์ง๊ฐ ์์ง ์์ฑ๋์ง ์์์ต๋๋ค<br><small>์์ฑ ์ 690ร1227px๋ก ์๋ ์กฐ์ ๋ฉ๋๋ค</small></p>'} | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| return html, panel_prompts | |
| # ์์ ๋ ํจ์: ์คํ ๋ฆฌ๋ณด๋ ์ ์ฉ | |
| def apply_edited_storyboard_fixed(edited_text, session_id, character_profiles, genre): | |
| """์์ ๋ ์คํ ๋ฆฌ๋ณด๋ ์ ์ฉ ํจ์""" | |
| logger.info(f"Applying storyboard - Text length: {len(edited_text) if edited_text else 0}") | |
| logger.info(f"Character profiles available: {len(character_profiles) if character_profiles else 0}") | |
| if not edited_text: | |
| logger.warning("Storyboard text is empty") | |
| return [], "<p>์คํ ๋ฆฌ๋ณด๋๊ฐ ๋น์ด์์ต๋๋ค. ๊ธฐํ์ ์์ฑ์ ๋จผ์ ์๋ฃํด์ฃผ์ธ์.</p>", gr.update(visible=False), gr.update(visible=True, value="์คํ ๋ฆฌ๋ณด๋๋ฅผ ๋จผ์ ์์ฑํ์ธ์."), {} | |
| panel_data = parse_storyboard_panels(edited_text, character_profiles) | |
| logger.info(f"Parsed {len(panel_data)} panels from storyboard") | |
| if not panel_data: | |
| logger.error("Failed to parse any panels from storyboard") | |
| return [], "<p>ํจ๋์ ํ์ฑํ ์ ์์ต๋๋ค. ์คํ ๋ฆฌ๋ณด๋ ํ์์ ํ์ธํ์ธ์.</p>", gr.update(visible=False), gr.update(visible=True, value=edited_text), {} | |
| # Add genre to panel data | |
| for panel in panel_data: | |
| panel['genre'] = genre | |
| panel_prompts = {f"panel_{p['number']}": p.get('prompt', '') for p in panel_data} | |
| html, _ = display_panels_with_editable_prompts(panel_data, panel_prompts, session_id, character_profiles, None, genre) | |
| panel_choices = [f"ํจ๋ {p['number']}" for p in panel_data] | |
| logger.info(f"Successfully applied storyboard with {len(panel_data)} panels") | |
| return panel_data, html, gr.update(visible=True, choices=panel_choices, value=[]), gr.update(visible=True, value=edited_text), panel_prompts | |
| # ์ฃผ์ ์์ ๋ถ๋ถ๋ง ํฌํจํ ํจ์น ์ฝ๋ | |
| # generate_selected_panel_images ํจ์ ์์ | |
| def generate_selected_panel_images_fixed(panel_data, selected_panels, session_id, | |
| character_profiles, webtoon_system, | |
| panel_prompts, genre, progress=gr.Progress()): | |
| """Generate images for selected panels with regeneration support""" | |
| if not REPLICATE_API_TOKEN: | |
| html, _ = display_panels_with_editable_prompts(panel_data, panel_prompts, | |
| session_id, character_profiles, | |
| webtoon_system, genre) | |
| return panel_data, html, gr.update(visible=True, value="โ ๏ธ Replicate API ํ ํฐ์ด ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| if not panel_data: | |
| html, _ = display_panels_with_editable_prompts(panel_data, panel_prompts, | |
| session_id, character_profiles, | |
| webtoon_system, genre) | |
| return panel_data, html, gr.update(visible=True, value="โ ๏ธ ํจ๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.") | |
| if not selected_panels: | |
| html, _ = display_panels_with_editable_prompts(panel_data, panel_prompts, | |
| session_id, character_profiles, | |
| webtoon_system, genre) | |
| return panel_data, html, gr.update(visible=True, value="โ ๏ธ ์์ฑํ ํจ๋์ ์ ํํ์ธ์.") | |
| if not webtoon_system: | |
| webtoon_system = WebtoonSystem() | |
| webtoon_system.current_genre = genre | |
| selected_numbers = [int(p.split()[1]) for p in selected_panels] | |
| total = len(selected_numbers) | |
| successful = 0 | |
| regenerated = 0 | |
| # panel_data๋ฅผ ๋ณต์ฌํ์ฌ ์์ | |
| updated_panel_data = panel_data.copy() | |
| for i, panel in enumerate(updated_panel_data): | |
| if panel['number'] in selected_numbers: | |
| idx = selected_numbers.index(panel['number']) | |
| progress((idx / total), desc=f"ํจ๋ {panel['number']} ์์ฑ ์ค...") | |
| panel_id = f"panel_{panel['number']}" | |
| prompt = panel_prompts.get(panel_id, panel.get('prompt', '')) | |
| # ์ด๋ฏธ ์ด๋ฏธ์ง๊ฐ ์๋์ง ํ์ธ | |
| has_existing_image = bool(panel.get('image_url')) | |
| if prompt: | |
| try: | |
| panel['prompt'] = prompt | |
| # ์์ด ํ๋กฌํํธ ๋ฒ์ญ | |
| if not panel.get('prompt_en') or has_existing_image: | |
| # ์ฌ์์ฑ ์ ํ๋กฌํํธ ๋ค์ ๋ฒ์ญ | |
| panel['prompt_en'] = webtoon_system.translate_prompt_to_english( | |
| prompt, character_profiles | |
| ) | |
| # ์ด๋ฏธ์ง ์์ฑ (์ฌ์์ฑ ํ๋๊ทธ ํฌํจ) | |
| result = webtoon_system.image_generator.generate_image( | |
| panel['prompt_en'], | |
| f"ep1_panel{panel['number']}", | |
| session_id, | |
| scene_type=panel.get('scene_type', 'medium'), | |
| genre=genre, | |
| force_regenerate=has_existing_image # ๊ธฐ์กด ์ด๋ฏธ์ง๊ฐ ์์ผ๋ฉด ์ฌ์์ฑ | |
| ) | |
| if result['status'] == 'success': | |
| panel['image_url'] = result['image_url'] | |
| successful += 1 | |
| if result.get('regenerated'): | |
| regenerated += 1 | |
| # seed ์ ๋ณด ์ ์ฅ (๋๋ฒ๊น ์ฉ) | |
| if 'seed' in result: | |
| panel['last_seed'] = result['seed'] | |
| except Exception as e: | |
| logger.error(f"Error generating panel {panel['number']}: {e}") | |
| # API ๋ถํ ๋ฐฉ์ง๋ฅผ ์ํ ๋๋ ์ด | |
| time.sleep(0.5) | |
| status_msg = f"์๋ฃ! {successful}/{total} ํจ๋ ์์ฑ ์ฑ๊ณต" | |
| if regenerated > 0: | |
| status_msg += f" (์ฌ์์ฑ: {regenerated}๊ฐ)" | |
| progress(1.0, desc=status_msg) | |
| html, _ = display_panels_with_editable_prompts(updated_panel_data, panel_prompts, | |
| session_id, character_profiles, | |
| webtoon_system, genre) | |
| return updated_panel_data, html, gr.update(visible=False) | |
| def clear_image_cache(session_id=None): | |
| """์ด๋ฏธ์ง ์บ์ ํด๋ฆฌ์ด""" | |
| global generated_images_cache | |
| if session_id: | |
| # ํน์ ์ธ์ ์ ์บ์๋ง ํด๋ฆฌ์ด | |
| keys_to_remove = [k for k in generated_images_cache.keys() if k.startswith(f"{session_id}_")] | |
| for key in keys_to_remove: | |
| del generated_images_cache[key] | |
| logger.info(f"Cleared {len(keys_to_remove)} cached images for session {session_id}") | |
| else: | |
| # ์ ์ฒด ์บ์ ํด๋ฆฌ์ด | |
| cache_size = len(generated_images_cache) | |
| generated_images_cache.clear() | |
| logger.info(f"Cleared all {cache_size} cached images") | |
| def clear_all_images_with_cache(panel_data, panel_prompts, session_id, | |
| character_profiles, webtoon_system, genre): | |
| """Clear all images and cache""" | |
| # ์บ์ ํด๋ฆฌ์ด | |
| clear_image_cache(session_id) | |
| # panel_data๋ฅผ ๋ณต์ฌํ์ฌ ์์ | |
| updated_panel_data = panel_data.copy() | |
| for panel in updated_panel_data: | |
| panel['image_url'] = None | |
| panel.pop('last_seed', None) # seed ์ ๋ณด๋ ์ ๊ฑฐ | |
| html, _ = display_panels_with_editable_prompts(updated_panel_data, panel_prompts, | |
| session_id, character_profiles, | |
| webtoon_system, genre) | |
| return updated_panel_data, html | |
| # generate_all_panel_images ํจ์ ์์ | |
| def generate_all_panel_images(panel_data, session_id, character_profiles, webtoon_system, panel_prompts, genre, progress=gr.Progress()): | |
| """Generate images for all panels - ์์ ๋ ๋ฒ์ """ | |
| if not REPLICATE_API_TOKEN: | |
| html, _ = display_panels_with_editable_prompts(panel_data, panel_prompts, session_id, character_profiles, webtoon_system, genre) | |
| return panel_data, html, gr.update(visible=True, value="โ ๏ธ Replicate API ํ ํฐ์ด ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| if not panel_data: | |
| html, _ = display_panels_with_editable_prompts(panel_data, panel_prompts, session_id, character_profiles, webtoon_system, genre) | |
| return panel_data, html, gr.update(visible=True, value="โ ๏ธ ํจ๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.") | |
| if not webtoon_system: | |
| webtoon_system = WebtoonSystem() | |
| webtoon_system.current_genre = genre | |
| # panel_data๋ฅผ ๋ณต์ฌํ์ฌ ์์ | |
| updated_panel_data = panel_data.copy() | |
| total_panels = len(updated_panel_data) | |
| successful = 0 | |
| failed = 0 | |
| for i, panel in enumerate(updated_panel_data): | |
| progress((i / total_panels), desc=f"ํจ๋ {panel['number']}/{total_panels} ์์ฑ ์ค...") | |
| panel_id = f"panel_{panel['number']}" | |
| prompt = panel_prompts.get(panel_id, panel.get('prompt', '')) | |
| if prompt: | |
| try: | |
| panel['prompt'] = prompt | |
| if not panel.get('prompt_en'): | |
| panel['prompt_en'] = webtoon_system.translate_prompt_to_english( | |
| prompt, character_profiles | |
| ) | |
| result = webtoon_system.image_generator.generate_image( | |
| panel['prompt_en'], | |
| f"ep1_panel{panel['number']}", | |
| session_id, | |
| scene_type=panel.get('scene_type', 'medium'), | |
| genre=genre | |
| ) | |
| 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) | |
| progress(1.0, desc=f"์๋ฃ! ์ฑ๊ณต: {successful}, ์คํจ: {failed}") | |
| html, _ = display_panels_with_editable_prompts(updated_panel_data, panel_prompts, session_id, character_profiles, webtoon_system, genre) | |
| return updated_panel_data, html, gr.update(visible=False) | |
| # clear_all_images ํจ์ ์์ | |
| def clear_all_images(panel_data, panel_prompts, session_id, character_profiles, webtoon_system, genre): | |
| """Clear all images - ์์ ๋ ๋ฒ์ """ | |
| # panel_data๋ฅผ ๋ณต์ฌํ์ฌ ์์ | |
| updated_panel_data = panel_data.copy() | |
| for panel in updated_panel_data: | |
| panel['image_url'] = None | |
| html, _ = display_panels_with_editable_prompts(updated_panel_data, panel_prompts, session_id, character_profiles, webtoon_system, genre) | |
| return updated_panel_data, html | |
| # download_all_panel_images ํจ์ ์์ | |
| def download_all_panel_images(panel_data, session_id, genre): | |
| """๋ชจ๋ ํจ๋ ์ด๋ฏธ์ง๋ฅผ ZIP ํ์ผ๋ก ๋ค์ด๋ก๋ - ์์ ๋ ๋ฒ์ """ | |
| try: | |
| logger.info(f"Starting image download - Panel data count: {len(panel_data) if panel_data else 0}") | |
| if not panel_data: | |
| logger.warning("No panel data available") | |
| return None | |
| # ์ด๋ฏธ์ง๊ฐ ์๋ ํจ๋๋ง ํํฐ๋ง | |
| panels_with_images = [p for p in panel_data if p.get('image_url')] | |
| logger.info(f"Found {len(panels_with_images)} panels with images") | |
| if not panels_with_images: | |
| logger.warning("No images to download") | |
| return None | |
| # ์์ ๋๋ ํ ๋ฆฌ ์์ฑ | |
| with tempfile.TemporaryDirectory() as temp_dir: | |
| image_dir = Path(temp_dir) / "webtoon_images" | |
| image_dir.mkdir(exist_ok=True) | |
| # ๊ฐ ์ด๋ฏธ์ง ์ ์ฅ | |
| saved_count = 0 | |
| for panel in panels_with_images: | |
| panel_num = panel['number'] | |
| image_url = panel['image_url'] | |
| try: | |
| # base64 ์ด๋ฏธ์ง ์ฒ๋ฆฌ | |
| if image_url.startswith('data:image'): | |
| # data URL์์ base64 ๋ฐ์ดํฐ ์ถ์ถ | |
| header, data = image_url.split(',', 1) | |
| image_data = base64.b64decode(data) | |
| # ํ์ผ๋ก ์ ์ฅ | |
| image_path = image_dir / f"panel_{panel_num:03d}.jpg" | |
| with open(image_path, 'wb') as f: | |
| f.write(image_data) | |
| saved_count += 1 | |
| else: | |
| # URL์์ ์ด๋ฏธ์ง ๋ค์ด๋ก๋ | |
| response = requests.get(image_url, timeout=30) | |
| response.raise_for_status() | |
| image_path = image_dir / f"panel_{panel_num:03d}.jpg" | |
| with open(image_path, 'wb') as f: | |
| f.write(response.content) | |
| saved_count += 1 | |
| logger.info(f"Saved panel {panel_num} image") | |
| except Exception as e: | |
| logger.error(f"Failed to save panel {panel_num}: {e}") | |
| continue | |
| logger.info(f"Successfully saved {saved_count} images") | |
| if saved_count == 0: | |
| logger.warning("No images were saved successfully") | |
| return None | |
| # ๋ฉํ๋ฐ์ดํฐ ํ์ผ ์์ฑ | |
| metadata_path = image_dir / "metadata.txt" | |
| with open(metadata_path, 'w', encoding='utf-8') as f: | |
| f.write(f"์นํฐ ์ด๋ฏธ์ง ์ ๋ณด\n") | |
| f.write(f"{'=' * 50}\n") | |
| f.write(f"์ฅ๋ฅด: {genre}\n") | |
| f.write(f"์ธ์ ID: {session_id}\n") | |
| f.write(f"์ด ์ด๋ฏธ์ง ์: {saved_count}๊ฐ\n") | |
| f.write(f"์ด๋ฏธ์ง ํฌ๊ธฐ: {WEBTOON_IMAGE_WIDTH}ร{WEBTOON_IMAGE_HEIGHT}px\n") | |
| f.write(f"์์ฑ ๋ ์ง: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") | |
| f.write(f"{'=' * 50}\n\n") | |
| for panel in panels_with_images: | |
| f.write(f"ํจ๋ {panel['number']:03d}\n") | |
| f.write(f" - ์ท ํ์ : {panel.get('shot', 'N/A')}\n") | |
| f.write(f" - ํ๋กฌํํธ: {panel.get('prompt', 'N/A')}\n") | |
| if panel.get('dialogue'): | |
| f.write(f" - ๋์ฌ: {panel['dialogue']}\n") | |
| if panel.get('narration'): | |
| f.write(f" - ๋๋ ์ด์ : {panel['narration']}\n") | |
| f.write("\n") | |
| # ZIP ํ์ผ ์์ฑ | |
| zip_path = Path(temp_dir) / f"webtoon_{genre}_episode1_images.zip" | |
| shutil.make_archive(str(zip_path.with_suffix('')), 'zip', image_dir) | |
| # ์์ฑ๋ ZIP ํ์ผ์ ์์ ํ์ผ๋ก ๋ณต์ฌ | |
| with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as final_zip: | |
| shutil.copy2(zip_path, final_zip.name) | |
| logger.info(f"Created ZIP file with {saved_count} images: {final_zip.name}") | |
| return final_zip.name | |
| except Exception as e: | |
| logger.error(f"Failed to create image ZIP: {e}", exc_info=True) | |
| return None | |
| def handle_random_theme(genre, language): | |
| return generate_random_webtoon_theme(genre, language) | |
| # ์์ ๋ ํจ์: ๊ธฐํ์ ๋ค์ด๋ก๋ (์์ ์ฑ ๊ฐํ) | |
| def download_planning_fixed(planning_content, genre): | |
| """์์ ๋ ๊ธฐํ์ ๋ค์ด๋ก๋ ํจ์""" | |
| try: | |
| if not planning_content or len(planning_content) < 100: | |
| logger.warning("Planning content not ready") | |
| return None | |
| # ์ค์ ๋ด์ฉ์ด ์๋์ง ํ์ธ | |
| if "ํ:" not in planning_content and "Episode" not in planning_content: | |
| logger.warning("Planning content incomplete") | |
| return None | |
| title = f"{genre} ์นํฐ" | |
| content = export_planning_to_txt(planning_content, genre, title) | |
| with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', | |
| suffix='_planning.txt', delete=False) as f: | |
| f.write(content) | |
| logger.info(f"Planning file created: {f.name}") | |
| return f.name | |
| except Exception as e: | |
| logger.error(f"Download error: {e}") | |
| return None | |
| # ์์ ๋ ํจ์: ์คํ ๋ฆฌ๋ณด๋ ๋ค์ด๋ก๋ (์์ ์ฑ ๊ฐํ) | |
| def download_storyboard_fixed(storyboard_content, genre): | |
| """์์ ๋ ์คํ ๋ฆฌ๋ณด๋ ๋ค์ด๋ก๋ ํจ์""" | |
| try: | |
| if not storyboard_content or len(storyboard_content) < 100: | |
| logger.warning("Storyboard not ready") | |
| return None | |
| # ํจ๋์ด ์ค์ ๋ก ์๋์ง ํ์ธ | |
| if not re.search(r'(ํจ๋|Panel)\s*\d', storyboard_content): | |
| logger.warning("No panels found in storyboard") | |
| return None | |
| content = export_storyboard_to_txt(storyboard_content, genre, 1) | |
| with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', | |
| suffix='_storyboard.txt', delete=False) as f: | |
| f.write(content) | |
| logger.info(f"Storyboard file created: {f.name}") | |
| return f.name | |
| except Exception as e: | |
| logger.error(f"Download error: {e}") | |
| return None | |
| # ===== ์ด๋ฒคํธ ํธ๋ค๋ฌ ์ฐ๊ฒฐ (์ฌ๋ฐ๋ฅธ ๋ค์ฌ์ฐ๊ธฐ) ===== | |
| # ๊ธฐํ ์์ ๋ฒํผ - ๋ชจ๋ ์ถ๋ ฅ ํฌํจ | |
| submit_btn.click( | |
| fn=process_query_with_state_update, | |
| inputs=[query_input, genre_select, language_select, current_session_id], | |
| outputs=[ | |
| planning_display, | |
| storyboard_editor, | |
| status_text, | |
| current_session_id, | |
| character_profiles_state, | |
| webtoon_system, | |
| current_genre, | |
| planning_state, | |
| storyboard_state, | |
| panel_data_state, # ์ถ๊ฐ | |
| panels_display, # ์ถ๊ฐ | |
| panel_selector, # ์ถ๊ฐ | |
| panel_prompts_state # ์ถ๊ฐ | |
| ] | |
| ).then( | |
| fn=format_character_profiles, | |
| inputs=[character_profiles_state], | |
| outputs=[character_display] | |
| ) | |
| # ํธ์ง ๋ด์ฉ ์ ์ฉ | |
| apply_edits_btn.click( | |
| fn=apply_edited_storyboard_fixed, | |
| inputs=[storyboard_editor, current_session_id, character_profiles_state, current_genre], | |
| outputs=[panel_data_state, panels_display, panel_selector, storyboard_editor, panel_prompts_state] | |
| ).then( | |
| fn=lambda: gr.update(visible=True, value="ํธ์ง ๋ด์ฉ์ด ์ ์ฉ๋์์ต๋๋ค."), | |
| outputs=[generation_progress] | |
| ) | |
| # create_interface ํจ์ ๋ด์ ์ด๋ฒคํธ ํธ๋ค๋ฌ ์์ ๋ถ๋ถ | |
| # ์ ํํ ํจ๋ ์ด๋ฏธ์ง ์์ฑ - panel_data_state ์ ๋ฐ์ดํธ ์ถ๊ฐ | |
| generate_selected_btn.click( | |
| fn=lambda pd: (pd, gr.update(visible=True, value="์ ํํ ํจ๋ ์ด๋ฏธ์ง ์์ฑ ์์...")), | |
| inputs=[panel_data_state], | |
| outputs=[panel_data_state, generation_progress] | |
| ).then( | |
| fn=generate_selected_panel_images_fixed, # ์์ ๋ ํจ์ ์ด๋ฆ ์ฌ์ฉ | |
| inputs=[panel_data_state, panel_selector, current_session_id, | |
| character_profiles_state, webtoon_system, panel_prompts_state, current_genre], | |
| outputs=[panel_data_state, panels_display, generation_progress] | |
| ) | |
| # clear_all_images_with_cache ํจ์๋ ์ด๋ฒคํธ ํธ๋ค๋ฌ์์ ์ฌ์ฉ | |
| clear_images_btn.click( | |
| fn=clear_all_images_with_cache, # ์บ์ ํด๋ฆฌ์ด ๊ธฐ๋ฅ์ด ํฌํจ๋ ๋ฒ์ ์ฌ์ฉ | |
| inputs=[panel_data_state, panel_prompts_state, current_session_id, | |
| character_profiles_state, webtoon_system, current_genre], | |
| outputs=[panel_data_state, panels_display] | |
| ) | |
| # ๋ชจ๋ ํจ๋ ์ด๋ฏธ์ง ์์ฑ - panel_data_state ์ ๋ฐ์ดํธ ์ถ๊ฐ | |
| generate_all_images_btn.click( | |
| fn=lambda pd: (pd, gr.update(visible=True, value="๋ชจ๋ ํจ๋ ์ด๋ฏธ์ง ์์ฑ ์์...")), | |
| inputs=[panel_data_state], | |
| outputs=[panel_data_state, generation_progress] | |
| ).then( | |
| fn=generate_all_panel_images, | |
| inputs=[panel_data_state, current_session_id, character_profiles_state, webtoon_system, panel_prompts_state, current_genre], | |
| outputs=[panel_data_state, panels_display, generation_progress] # panel_data_state ์ถ๋ ฅ ์ถ๊ฐ | |
| ) | |
| # ์ ์ฒด ์ด๋ฏธ์ง ๋ค์ด๋ก๋ - ์์ ๋ ๋ฉ์์ง | |
| download_all_images_btn.click( | |
| fn=lambda pd, sid, genre: ( | |
| download_all_panel_images(pd, sid, genre), | |
| gr.update(visible=True, value="์ด๋ฏธ์ง ZIP ํ์ผ ์์ฑ ์ค...") | |
| ), | |
| inputs=[panel_data_state, current_session_id, current_genre], | |
| outputs=[images_download_file, generation_progress] | |
| ).then( | |
| fn=lambda x: ( | |
| gr.update(visible=True, value=x) if x else gr.update(visible=False), | |
| gr.update( | |
| visible=True, | |
| value="โ ์ด๋ฏธ์ง ๋ค์ด๋ก๋ ์ค๋น ์๋ฃ!" if x else "โ ๏ธ ๋ค์ด๋ก๋ํ ์ด๋ฏธ์ง๊ฐ ์์ต๋๋ค. ๋จผ์ ์ด๋ฏธ์ง๋ฅผ ์์ฑํ์ธ์." | |
| ) | |
| ), | |
| inputs=[images_download_file], | |
| outputs=[images_download_file, generation_progress] | |
| ) | |
| # ๋๋ค ํ ๋ง | |
| random_btn.click( | |
| fn=handle_random_theme, | |
| inputs=[genre_select, language_select], | |
| outputs=[query_input] | |
| ) | |
| # ๊ธฐํ์ ๋ค์ด๋ก๋ | |
| download_planning_btn.click( | |
| fn=download_planning_fixed, | |
| inputs=[planning_state, genre_select], | |
| outputs=[planning_download_file] | |
| ).then( | |
| fn=lambda x: gr.update(visible=True, value=x) if x else gr.update(visible=False), | |
| inputs=[planning_download_file], | |
| outputs=[planning_download_file] | |
| ) | |
| # ์คํ ๋ฆฌ๋ณด๋ ๋ค์ด๋ก๋ | |
| download_storyboard_btn.click( | |
| fn=download_storyboard_fixed, | |
| inputs=[storyboard_editor, genre_select], | |
| outputs=[storyboard_download_file] | |
| ).then( | |
| fn=lambda x: gr.update(visible=True, value=x) 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(f"Image Size: {WEBTOON_IMAGE_WIDTH}ร{WEBTOON_IMAGE_HEIGHT}px (auto-resize)") | |
| logger.info("Genres: " + ", ".join(WEBTOON_GENRES.keys())) | |
| if REPLICATE_API_TOKEN: | |
| logger.info("Replicate API: Configured โ") | |
| logger.info(f"Images will be auto-resized to {WEBTOON_IMAGE_WIDTH}ร{WEBTOON_IMAGE_HEIGHT}px") | |
| 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 | |
| ) |