openfree's picture
Update app.py
737f655 verified
raw
history blame
65.8 kB
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__)
# --- Document export imports ---
try:
from docx import Document
from docx.shared import Inches, Pt, RGBColor, Mm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.style import WD_STYLE_TYPE
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
DOCX_AVAILABLE = True
except ImportError:
DOCX_AVAILABLE = False
logger.warning("python-docx not installed. DOCX export will be disabled.")
# --- 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 # 40ํ™” ์™„๊ฒฐ
PANELS_PER_EPISODE = 30 # ๊ฐ ํ™”๋‹น 30๊ฐœ ํŒจ๋„
TARGET_PANELS = TARGET_EPISODES * PANELS_PER_EPISODE # ์ด 1200 ํŒจ๋„
# 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": [
"ํ†ฐ ํฌ๋ฃจ์ฆˆ", "๋ธŒ๋ž˜๋“œ ํ”ผํŠธ", "๋ ˆ์˜ค๋‚˜๋ฅด๋„ ๋””์นดํ”„๋ฆฌ์˜ค", "๋ผ์ด์–ธ ๊ณ ์Šฌ๋ง",
"ํฌ๋ฆฌ์Šค ํ—ด์Šค์›Œ์Šค", "๋กœ๋ฒ„ํŠธ ๋‹ค์šฐ๋‹ˆ ์ฃผ๋‹ˆ์–ด", "ํฌ๋ฆฌ์Šค ์—๋ฐ˜์Šค", "ํ†ฐ ํžˆ๋“ค์Šคํ„ด",
"๋ฒ ๋„ค๋”•ํŠธ ์ปด๋ฒ„๋ฐฐ์น˜", "ํ‚ค์•„๋ˆ„ ๋ฆฌ๋ธŒ์Šค", "์ด๋ณ‘ํ—Œ", "๊ณต์œ ", "๋ฐ•์„œ์ค€", "์†ก์ค‘๊ธฐ"
],
"female": [
"์Šค์นผ๋ › ์š”ํ•œ์Šจ", "์— ๋งˆ ์™“์Šจ", "์ œ๋‹ˆํผ ๋กœ๋ Œ์Šค", "๊ฐค ๊ฐ€๋—",
"๋งˆ๊ณ  ๋กœ๋น„", "์— ๋งˆ ์Šคํ†ค", "์•ค ํ•ด์„œ์›จ์ด", "๋‚˜ํƒˆ๋ฆฌ ํฌํŠธ๋งŒ",
"์ „์ง€ํ˜„", "์†กํ˜œ๊ต", "๊น€ํƒœ๋ฆฌ", "์•„์ด์œ ", "์ˆ˜์ง€", "ํ•œ์†Œํฌ"
]
}
# --- 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 = {} # Cache for generated images
# --- Genre-specific prompts and elements ---
GENRE_ELEMENTS = {
"๋กœ๋งจ์Šค": {
"key_elements": ["๊ฐ์ •์„ ", "์˜คํ•ด์™€ ํ™”ํ•ด", "๋‹ฌ์ฝคํ•œ ์ˆœ๊ฐ„", "์งˆํˆฌ", "๊ณ ๋ฐฑ"],
"visual_styles": ["์†Œํ”„ํŠธ ํ†ค", "ํŒŒ์Šคํ…”", "๊ฝƒ ๋ฐฐ๊ฒฝ", "๋น›๋ง์šธ ํšจ๊ณผ", "๋ถ„ํ™๋น› ํ•„ํ„ฐ"],
"panel_types": ["ํด๋กœ์ฆˆ์—… ๊ฐ์ •์ƒท", "ํˆฌ์ƒท", "์† ํด๋กœ์ฆˆ์—…", "๋ˆˆ๋น› ๊ตํ™˜", "๋ฐฑํ—ˆ๊ทธ"],
"typical_scenes": ["์นดํŽ˜ ๋ฐ์ดํŠธ", "์šฐ์‚ฐ ์”ฌ", "๋ถˆ๊ฝƒ๋†€์ด", "์˜ฅ์ƒ ๊ณ ๋ฐฑ", "๊ณตํ•ญ ์ด๋ณ„"]
},
"๋กœํŒ": {
"key_elements": ["ํšŒ๊ท€/๋น™์˜", "๋“œ๋ ˆ์Šค", "๋ฌด๋„ํšŒ", "๋งˆ๋ฒ•", "์‹ ๋ถ„ ์ƒ์Šน"],
"visual_styles": ["ํ™”๋ คํ•œ ์˜์ƒ", "์œ ๋Ÿฝํ’ ๋ฐฐ๊ฒฝ", "๋ฐ˜์ง์ด ํšจ๊ณผ", "๋งˆ๋ฒ•์ง„", "์„ฑ ๋ฐฐ๊ฒฝ"],
"panel_types": ["์ „์‹ ์ƒท", "๋“œ๋ ˆ์Šค ๋””ํ…Œ์ผ", "๋งˆ๋ฒ• ์ดํŽ™ํŠธ", "ํšŒ์ƒ์”ฌ", "์ถฉ๊ฒฉ ๋ฆฌ์•ก์…˜"],
"typical_scenes": ["๋ฌด๋„ํšŒ์žฅ", "์ •์› ์‚ฐ์ฑ…", "์„œ์žฌ", "๋งˆ๋ฒ• ์ˆ˜์—…", "์•Œํ˜„์‹ค"]
},
"ํŒํƒ€์ง€": {
"key_elements": ["๋งˆ๋ฒ•์ฒด๊ณ„", "๋ ˆ๋ฒจ์—…", "๋˜์ „", "๊ธธ๋“œ", "๋ชจํ—˜"],
"visual_styles": ["๋‹ค์ด๋‚˜๋ฏน ์•ก์…˜", "์ดํŽ™ํŠธ ๊ฐ•์กฐ", "๋ชฌ์Šคํ„ฐ ๋””์ž์ธ", "ํŒํƒ€์ง€ ๋ฐฐ๊ฒฝ", "๋น› ํšจ๊ณผ"],
"panel_types": ["์•ก์…˜์”ฌ", "ํ’€์ƒท ์ „ํˆฌ", "์Šคํ‚ฌ ๋ฐœ๋™", "๋ชฌ์Šคํ„ฐ ๋“ฑ์žฅ", "ํŒŒ์›Œ์—…"],
"typical_scenes": ["๋˜์ „ ์ž…๊ตฌ", "๋ณด์Šค์ „", "๊ธธ๋“œ ํšŒ๊ด€", "์ˆ˜๋ จ์žฅ", "์•„์ดํ…œ ํš๋“"]
},
"ํ˜„ํŒ": {
"key_elements": ["๊ฒŒ์ดํŠธ", "ํ—Œํ„ฐ", "๊ฐ์„ฑ", "ํ˜„๋Œ€ ๋„์‹œ", "๋Šฅ๋ ฅ"],
"visual_styles": ["๋„์‹œ ๋ฐฐ๊ฒฝ", "๋„ค์˜จ ํšจ๊ณผ", "ํ˜„๋Œ€์  ์•ก์…˜", "ํŠน์ˆ˜ ํšจ๊ณผ", "์–ด๋ฐ˜ ํŒํƒ€์ง€"],
"panel_types": ["๋„์‹œ ์ „๊ฒฝ", "๋Šฅ๋ ฅ ๋ฐœํ˜„", "๊ฒŒ์ดํŠธ ์ถœํ˜„", "์ „ํˆฌ ์•ก์…˜", "์ผ์ƒ ๋Œ€๋น„"],
"typical_scenes": ["๊ฒŒ์ดํŠธ ํ˜„์žฅ", "ํ—Œํ„ฐ ํ˜‘ํšŒ", "ํ›ˆ๋ จ์žฅ", "๋ณ‘์›", "ํ•™๊ต"]
},
"๋ฌดํ˜‘": {
"key_elements": ["๋ฌด๊ณต", "๋ฌธํŒŒ", "๊ฐ•ํ˜ธ", "๋ณต์ˆ˜", "์˜ํ˜‘"],
"visual_styles": ["๋™์–‘ํ’", "๋จน ํšจ๊ณผ", "๊ธฐ ํ‘œํ˜„", "์ค‘๊ตญํ’ ์˜์ƒ", "์‚ฐ์ˆ˜ํ™” ๋ฐฐ๊ฒฝ"],
"panel_types": ["๊ฒ€์ˆ  ๋™์ž‘", "๊ฒฝ๊ณต์ˆ ", "๊ธฐ๊ณต ์ˆ˜๋ จ", "๋Œ€๊ฒฐ ๊ตฌ๋„", "ํญ๋ฐœ ์ดํŽ™ํŠธ"],
"typical_scenes": ["๋ฌด๋ฆผ๋งน", "๊ฐ์ž”", "์ ˆ๋ฒฝ", "ํญํฌ ์ˆ˜๋ จ", "๋น„๋ฌด๋Œ€ํšŒ"]
},
"์Šค๋ฆด๋Ÿฌ": {
"key_elements": ["์„œ์ŠคํŽœ์Šค", "๊ณตํฌ", "์ถ”๊ฒฉ", "์‹ฌ๋ฆฌ์ „", "๋ฐ˜์ „"],
"visual_styles": ["์–ด๋‘์šด ํ†ค", "๊ทธ๋ฆผ์ž ๊ฐ•์กฐ", "๋Œ€๋น„ ํšจ๊ณผ", "๋ถˆ์•ˆํ•œ ๊ตฌ๋„", "๋ถ‰์€์ƒ‰ ๊ฐ•์กฐ"],
"panel_types": ["๊ทน๋‹จ ํด๋กœ์ฆˆ์—…", "Dutch angle", "์‹ค๋ฃจ์—ฃ", "์ถฉ๊ฒฉ ์ปท", "๊ณตํฌ ์—ฐ์ถœ"],
"typical_scenes": ["์–ด๋‘์šด ๊ณจ๋ชฉ", "ํ๊ฑด๋ฌผ", "์ง€ํ•˜์‹ค", "์ถ”๊ฒฉ์”ฌ", "๋Œ€์น˜ ์ƒํ™ฉ"]
},
"์ผ์ƒ": {
"key_elements": ["์ผ์ƒ", "๊ณต๊ฐ", "์†Œ์†Œํ•œ ์žฌ๋ฏธ", "๊ด€๊ณ„", "์„ฑ์žฅ"],
"visual_styles": ["๋”ฐ๋œปํ•œ ์ƒ‰๊ฐ", "๋ถ€๋“œ๋Ÿฌ์šด ์„ ", "์ผ์ƒ ๋ฐฐ๊ฒฝ", "์บ์ฃผ์–ผ", "ํŽธ์•ˆํ•œ ๊ตฌ๋„"],
"panel_types": ["์ผ์ƒ ์ปท", "๋ฆฌ์•ก์…˜", "๋Œ€ํ™”์”ฌ", "๋ฐฐ๊ฒฝ ๋ฌ˜์‚ฌ", "๊ฐ์ • ํ‘œํ˜„"],
"typical_scenes": ["์ง‘", "ํ•™๊ต", "ํšŒ์‚ฌ", "๋™๋„ค", "ํŽธ์˜์ "]
},
"๊ฐœ๊ทธ": {
"key_elements": ["๊ฐœ๊ทธ", "ํŒจ๋Ÿฌ๋””", "๊ณผ์žฅ", "๋ฐ˜์ „", "์Šฌ๋žฉ์Šคํ‹ฑ"],
"visual_styles": ["๊ณผ์žฅ๋œ ํ‘œ์ •", "๋ฐํฌ๋ฅด๋ฉ”", "ํšจ๊ณผ์„ ", "๋งํ’์„  ์—ฐ์ถœ", "ํŒŒ๊ฒฉ ๊ตฌ๋„"],
"panel_types": ["๊ณผ์žฅ ๋ฆฌ์•ก์…˜", "๊ฐœ๊ทธ ์ปท", "ํŒจ๋Ÿฌ๋””", "์ถฉ๊ฒฉ ํ‘œ์ •", "๋ง๊ฐ€์ง"],
"typical_scenes": ["๊ฐœ๊ทธ ์ƒํ™ฉ", "์ผ์ƒ ๋ถ•๊ดด", "์˜คํ•ด ์ƒํ™ฉ", "์ถ”๊ฒฉ์ „", "๋‹จ์ฒด ๊ฐœ๊ทธ"]
},
"์Šคํฌ์ธ ": {
"key_elements": ["๊ฒฝ๊ธฐ", "ํ›ˆ๋ จ", "ํŒ€์›Œํฌ", "๋ผ์ด๋ฒŒ", "์„ฑ์žฅ"],
"visual_styles": ["๋‹ค์ด๋‚˜๋ฏน", "์Šคํ”ผ๋“œ์„ ", "๋•€ ํ‘œํ˜„", "๊ทผ์œก ๋ฌ˜์‚ฌ", "๊ฒฝ๊ธฐ์žฅ"],
"panel_types": ["์•ก์…˜ ์ปท", "๊ฒฐ์ •์  ์ˆœ๊ฐ„", "์ „์‹  ๋™์ž‘", "ํ‘œ์ • ํด๋กœ์ฆˆ์—…", "๊ฒฝ๊ธฐ ์ „๊ฒฝ"],
"typical_scenes": ["๊ฒฝ๊ธฐ์žฅ", "ํ›ˆ๋ จ์žฅ", "๋ผ์ปค๋ฃธ", "๋ฒค์น˜", "์‹œ์ƒ๋Œ€"]
}
}
# --- Data classes ---
@dataclass
class CharacterProfile:
"""Character profile with celebrity lookalike"""
name: str
role: str
personality: str
appearance: str
celebrity_lookalike: str
gender: str
@dataclass
class WebtoonBible:
"""Webtoon story bible for maintaining consistency"""
genre: str = ""
title: str = ""
characters: Dict[str, CharacterProfile] = field(default_factory=dict)
settings: Dict[str, str] = field(default_factory=dict)
plot_points: List[Dict[str, Any]] = field(default_factory=list)
episode_hooks: Dict[int, str] = field(default_factory=dict)
genre_elements: Dict[str, Any] = field(default_factory=dict)
visual_style: Dict[str, Any] = field(default_factory=dict)
panel_compositions: List[str] = field(default_factory=list)
@dataclass
class StoryboardPanel:
"""Individual storyboard panel with unique ID"""
panel_number: int
scene_type: str # wide, close-up, medium, establishing
image_prompt: str # Image generation prompt with character descriptions
panel_id: str = "" # Unique panel identifier
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 = "" # URL of generated image
@dataclass
class EpisodeStoryboard:
"""Complete storyboard for one episode"""
episode_number: int
title: str
panels: List[StoryboardPanel] = field(default_factory=list)
total_panels: int = 30
cliffhanger: str = ""
# --- Core logic classes ---
class WebtoonTracker:
"""Webtoon narrative and storyboard tracker"""
def __init__(self):
self.story_bible = WebtoonBible()
self.episode_storyboards: Dict[int, EpisodeStoryboard] = {}
self.episodes: Dict[int, str] = {}
self.total_panel_count = 0
self.character_profiles: Dict[str, CharacterProfile] = {}
def set_genre(self, genre: str):
"""Set the webtoon genre"""
self.story_bible.genre = genre
self.story_bible.genre_elements = GENRE_ELEMENTS.get(genre, {})
def add_character(self, character: CharacterProfile):
"""Add character with celebrity lookalike"""
self.character_profiles[character.name] = character
self.story_bible.characters[character.name] = character
def add_storyboard(self, episode_num: int, storyboard: EpisodeStoryboard):
"""Add episode storyboard"""
self.episode_storyboards[episode_num] = storyboard
self.total_panel_count += len(storyboard.panels)
class WebtoonDatabase:
"""Database management for webtoon system"""
@staticmethod
def init_db():
with sqlite3.connect(DB_PATH) as conn:
conn.execute("PRAGMA journal_mode=WAL")
cursor = conn.cursor()
# Sessions table with genre
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
)
''')
# Storyboards table
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)
)
''')
# Panels table with image data
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,
dialogue TEXT,
narration TEXT,
sound_effects TEXT,
generated_image TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
)
''')
conn.commit()
@staticmethod
@contextmanager
def get_db():
with db_lock:
conn = sqlite3.connect(DB_PATH, timeout=30.0)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
@staticmethod
def create_session(user_query: str, genre: str, language: str) -> str:
session_id = hashlib.md5(f"{user_query}{genre}{datetime.now()}".encode()).hexdigest()
with WebtoonDatabase.get_db() as conn:
conn.cursor().execute(
'''INSERT INTO sessions (session_id, user_query, genre, language)
VALUES (?, ?, ?, ?)''',
(session_id, user_query, genre, language)
)
conn.commit()
return session_id
@staticmethod
def save_storyboard(session_id: str, episode_num: int, storyboard: EpisodeStoryboard):
with WebtoonDatabase.get_db() as conn:
cursor = conn.cursor()
# Save storyboard
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)))
# Save individual panels
for panel in storyboard.panels:
cursor.execute('''
INSERT INTO panels (session_id, episode_number, panel_number,
scene_type, image_prompt, dialogue, narration, sound_effects)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (session_id, episode_num, panel.panel_number,
panel.scene_type, panel.image_prompt,
json.dumps(panel.dialogue), panel.narration,
json.dumps(panel.sound_effects)))
conn.commit()
@staticmethod
def 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()
# --- Image Generation ---
class ImageGenerator:
"""Handle image generation using Replicate API with multi-threading"""
def __init__(self):
self.generation_lock = Lock()
self.active_generations = {}
def generate_image(self, prompt: str, panel_id: str, session_id: str, progress_callback=None) -> Dict[str, Any]:
"""Generate image using Replicate API with progress callback"""
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}")
if progress_callback:
progress_callback(f"ํŒจ๋„ {panel_id.split('_panel')[1]} ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์ค‘...")
# Run the model
input_params = {
"prompt": prompt,
"num_inference_steps": 25,
"guidance_scale": 7.5
}
output = replicate.run(
"stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b",
input=input_params
)
# Get the image URL
if output and len(output) > 0:
image_url = output[0] if isinstance(output[0], str) else str(output[0])
# Cache the image
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": 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)}
def generate_multiple_images(self, panel_prompts: List[Dict], session_id: str,
max_workers: int = 3, progress_callback=None) -> List[Dict]:
"""Generate multiple images in parallel with progress tracking"""
results = []
total = len(panel_prompts)
completed = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all tasks
future_to_panel = {}
for i, panel_data in enumerate(panel_prompts):
panel_id = panel_data['panel_id']
prompt = panel_data['prompt']
future = executor.submit(
self.generate_image,
prompt,
panel_id,
session_id,
None # Individual progress not tracked in parallel
)
future_to_panel[future] = panel_data
# Collect results as they complete
for future in concurrent.futures.as_completed(future_to_panel):
panel_data = future_to_panel[future]
try:
result = future.result(timeout=60)
results.append(result)
completed += 1
if progress_callback:
progress_callback(f"์ง„ํ–‰์ค‘: {completed}/{total} ํŒจ๋„ ์™„๋ฃŒ")
except concurrent.futures.TimeoutError:
results.append({
"panel_id": panel_data['panel_id'],
"status": "error",
"message": "Generation timeout"
})
completed += 1
except Exception as e:
results.append({
"panel_id": panel_data['panel_id'],
"status": "error",
"message": str(e)
})
completed += 1
# Sort results by panel_id to maintain order
results.sort(key=lambda x: int(x['panel_id'].split('_panel')[1]))
return results
# --- 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()
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"""
profiles = {}
used_celebrities = []
for char in characters:
gender = char.get('gender', 'male')
available_celebrities = [c for c in CELEBRITY_FACES.get(gender, [])
if c not in used_celebrities]
if not available_celebrities:
available_celebrities = CELEBRITY_FACES.get(gender, [])
celebrity = random.choice(available_celebrities)
used_celebrities.append(celebrity)
profile = CharacterProfile(
name=char.get('name', ''),
role=char.get('role', ''),
personality=char.get('personality', ''),
appearance=char.get('appearance', ''),
celebrity_lookalike=celebrity,
gender=gender
)
profiles[profile.name] = profile
self.tracker.add_character(profile)
return profiles
# --- Prompt generation functions ---
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ํ™” ์ „์ฒด ๊ตฌ์„ฑ์•ˆ์„ ๋ชจ๋‘ ์ž‘์„ฑํ•˜์„ธ์š”.
**์žฅ๋ฅด ํ•„์ˆ˜ ์š”์†Œ:**
- ํ•ต์‹ฌ ์š”์†Œ: {', '.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.
**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 for each!)
- Protagonist: [Name] - Gender: [male/female] - [Appearance, personality, goal]
- Character2: [Name] - Gender: [male/female] - [Role, traits]
- Character3: [Name] - Gender: [male/female] - [Role, 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 character descriptions"""
genre_info = GENRE_ELEMENTS.get(genre, {})
# Create character description string
char_descriptions = "\n".join([
f"- {name}: {profile.celebrity_lookalike} ๋‹ฎ์€ ์–ผ๊ตด์˜ {profile.gender}"
for name, profile in character_profiles.items()
])
lang_prompts = {
"Korean": f"""์›นํˆฐ {episode_num}ํ™” ์Šคํ† ๋ฆฌ๋ณด๋“œ๋ฅผ 30๊ฐœ ํŒจ๋„๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
**์žฅ๋ฅด:** {genre}
**1ํ™” ๋‚ด์šฉ:** {self._extract_episode_plan(plot_outline, episode_num)}
**์บ๋ฆญํ„ฐ ์–ผ๊ตด ์„ค์ •:**
{char_descriptions}
โš ๏ธ **์ค‘์š”**:
1. ๋ฐ˜๋“œ์‹œ 30๊ฐœ ํŒจ๋„์„ ๋ชจ๋‘ ์ž‘์„ฑํ•˜์„ธ์š”!
2. ์บ๋ฆญํ„ฐ๊ฐ€ ๋“ฑ์žฅํ•  ๋•Œ๋งˆ๋‹ค "์บ๋ฆญํ„ฐ์ด๋ฆ„(์œ ๋ช…์ธ ๋‹ฎ์€ ์–ผ๊ตด์˜ ์„ฑ๋ณ„)" ํ˜•์‹์œผ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”!
์˜ˆ์‹œ: "ํ™๊ธธ๋™(ํ†ฐ ํฌ๋ฃจ์ฆˆ ๋‹ฎ์€ ์–ผ๊ตด์˜ ๋‚จ์ž)์ด ๊ฑฐ๋ฆฌ๋ฅผ ๊ฑท๊ณ  ์žˆ๋‹ค"
**ํŒจ๋„ ๊ตฌ์„ฑ ์ง€์นจ:**
- ์ด 30๊ฐœ ํŒจ๋„๋กœ ๊ตฌ์„ฑ
- ๋‹ค์–‘ํ•œ ์ƒท ์‚ฌ์ด์ฆˆ ํ™œ์šฉ
- ์žฅ๋ฅด ํŠน์„ฑ์— ๋งž๋Š” ์—ฐ์ถœ: {', '.join(genre_info.get('panel_types', []))}
**๊ฐ ํŒจ๋„๋ณ„๋กœ ๋‹ค์Œ์„ ํฌํ•จํ•˜์—ฌ ์ž‘์„ฑ:**
ํŒจ๋„ 1:
- ์ƒท ํƒ€์ž…: [establishing/wide/medium/close_up ๋“ฑ]
- ์ด๋ฏธ์ง€ ํ”„๋กฌํ”„ํŠธ: [์บ๋ฆญํ„ฐ ์„ค๋ช… ํฌํ•จํ•œ ์ƒ์„ธํ•œ ํ•œ๊ธ€ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ]
- ๋Œ€์‚ฌ: [์บ๋ฆญํ„ฐ ๋Œ€์‚ฌ๊ฐ€ ์žˆ๋‹ค๋ฉด]
- ๋‚˜๋ ˆ์ด์…˜: [ํ•ด์„ค์ด ์žˆ๋‹ค๋ฉด]
- ํšจ๊ณผ์Œ: [ํ•„์š”ํ•œ ํšจ๊ณผ์Œ]
- ๋ฐฐ๊ฒฝ: [๋ฐฐ๊ฒฝ ์„ค๋ช…]
ํŒจ๋„ 2:
(์œ„์™€ ๊ฐ™์€ ํ˜•์‹)
...์ด๋Ÿฐ ์‹์œผ๋กœ 30๊ฐœ ํŒจ๋„ ๋ชจ๋‘ ์ž‘์„ฑ
ํŒจ๋„ 30:
(์œ„์™€ ๊ฐ™์€ ํ˜•์‹)
โš ๏ธ ๋ฐ˜๋“œ์‹œ 30๊ฐœ ํŒจ๋„์„ ๋ชจ๋‘ ์ž‘์„ฑํ•˜์„ธ์š”. ์ƒ๋žตํ•˜์ง€ ๋งˆ์„ธ์š”!""",
"English": f"""Create Episode {episode_num} storyboard with 30 panels.
**Genre:** {genre}
**Episode content:** {self._extract_episode_plan(plot_outline, episode_num)}
**Character Face Settings:**
{char_descriptions}
โš ๏ธ **IMPORTANT**:
1. Must write all 30 panels!
2. Always describe characters as "CharacterName (celebrity lookalike face gender)"!
Example: "John (Tom Cruise lookalike male) walking down the street"
**Panel composition guidelines:**
- Total 30 panels
- Various shot sizes
- Genre-appropriate directing: {', '.join(genre_info.get('panel_types', []))}
**For each panel include:**
Panel 1:
- Shot type: [establishing/wide/medium/close_up etc]
- Image prompt: [Detailed prompt with character descriptions]
- Dialogue: [Character dialogue if any]
- Narration: [Narration if any]
- Sound effects: [Required sound effects]
- Background: [Background description]
Panel 2:
(Same format)
...continue for all 30 panels
Panel 30:
(Same format)
โš ๏ธ Must write all 30 panels. Don't skip any!"""
}
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():
# Parse character line
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()
# Extract gender
gender = 'male' # default
if 'female' in line.lower() or '์—ฌ' in line:
gender = 'female'
elif 'male' in line.lower() or '๋‚จ' in line:
gender = 'male'
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': parts[3].strip() if len(parts) > 3 else ''
}
if current_char and current_char not in characters:
characters.append(current_char)
# Ensure at least 3 characters
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
# --- LLM call functions ---
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
payload = {
"model": self.model_id,
"messages": full_messages,
"max_tokens": max_tokens,
"temperature": 0.7,
"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"""
base_prompts = {
"Korean": {
"planner": """๋‹น์‹ ์€ ํ•œ๊ตญ ์›นํˆฐ ์‹œ์žฅ์„ ์™„๋ฒฝํžˆ ์ดํ•ดํ•˜๋Š” ์›นํˆฐ ๊ธฐํš์ž์ž…๋‹ˆ๋‹ค.
๋…์ž๋ฅผ ์‚ฌ๋กœ์žก๋Š” ์Šคํ† ๋ฆฌ์™€ ๋น„์ฃผ์–ผ ์—ฐ์ถœ์„ ๊ธฐํšํ•ฉ๋‹ˆ๋‹ค.
40ํ™” ์™„๊ฒฐ ๊ตฌ์กฐ๋กœ ์™„๋ฒฝํ•œ ๊ธฐ์Šน์ „๊ฒฐ์„ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค.
๊ฐ ํ™”๋งˆ๋‹ค ๊ฐ•๋ ฅํ•œ ํด๋ฆฌํ”„ํ–‰์–ด๋กœ ๋‹ค์Œ ํ™”๋ฅผ ๊ธฐ๋Œ€ํ•˜๊ฒŒ ๋งŒ๋“ญ๋‹ˆ๋‹ค.
์บ๋ฆญํ„ฐ์˜ ์„ฑ๋ณ„์„ ๋ช…ํ™•ํžˆ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
โš ๏ธ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ์›์น™:
1. ์‚ฌ์šฉ์ž๊ฐ€ ์ œ๊ณตํ•œ ์Šคํ† ๋ฆฌ ์„ค์ •์„ ์ ˆ๋Œ€์ ์œผ๋กœ ์šฐ์„ ์‹œํ•˜๊ณ , ์ด๋ฅผ ์ค‘์‹ฌ์œผ๋กœ ๋ชจ๋“  ํ”Œ๋กฏ์„ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค.
2. ๋ฐ˜๋“œ์‹œ 40ํ™” ์ „์ฒด ๊ตฌ์„ฑ์•ˆ์„ ๋ชจ๋‘ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ๋žตํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.""",
"storyboarder": """๋‹น์‹ ์€ ์›นํˆฐ ์Šคํ† ๋ฆฌ๋ณด๋“œ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
30๊ฐœ ํŒจ๋„๋กœ ํ•œ ํ™”๋ฅผ ์™„๋ฒฝํ•˜๊ฒŒ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค.
์„ธ๋กœ ์Šคํฌ๋กค์— ์ตœ์ ํ™”๋œ ์—ฐ์ถœ์„ ํ•ฉ๋‹ˆ๋‹ค.
๊ฐ ํŒจ๋„๋งˆ๋‹ค ์บ๋ฆญํ„ฐ์˜ ์œ ๋ช…์ธ ๋‹ฎ์€๊ผด ์„ค์ •์„ ํฌํ•จํ•œ ์ƒ์„ธํ•œ ์ด๋ฏธ์ง€ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.
โš ๏ธ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ์›์น™:
1. ๋ฐ˜๋“œ์‹œ 30๊ฐœ ํŒจ๋„์„ ๋ชจ๋‘ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.
2. ์บ๋ฆญํ„ฐ๊ฐ€ ๋“ฑ์žฅํ•  ๋•Œ๋งˆ๋‹ค "์บ๋ฆญํ„ฐ์ด๋ฆ„(์œ ๋ช…์ธ ๋‹ฎ์€ ์–ผ๊ตด์˜ ์„ฑ๋ณ„)" ํ˜•์‹์œผ๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค."""
},
"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.
โš ๏ธ 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.""",
"storyboarder": """You are a webtoon storyboard specialist.
Perfectly compose one episode with 30 panels.
Write detailed image prompts including celebrity lookalike descriptions.
โš ๏ธ Most important principles:
1. Must write all 30 panels.
2. Always describe characters as "CharacterName (celebrity lookalike face gender)"."""
}
}
return base_prompts.get(language, base_prompts["Korean"])
# --- Main process ---
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
# Phase 1: Generate planning document (40ํ™” ๊ตฌ์„ฑ ํฌํ•จ)
yield "๐ŸŽฌ ์›นํˆฐ ๊ธฐํš์•ˆ ์ž‘์„ฑ ์ค‘... (40ํ™” ์ „์ฒด ๊ตฌ์„ฑ ํฌํ•จ)", "", f"์žฅ๋ฅด: {genre}", self.current_session_id, {}
planning_prompt = self.create_planning_prompt(query, genre, language)
planning_doc = self.call_llm_sync(
[{"role": "user", "content": planning_prompt}],
"planner", language
)
self.planning_doc = planning_doc
# Parse characters and assign celebrity lookalikes
characters = self.parse_characters_from_planning(planning_doc)
character_profiles = self.assign_celebrity_lookalikes(characters)
# Save character profiles
WebtoonDatabase.save_character_profiles(self.current_session_id, character_profiles)
yield "โœ… ๊ธฐํš์•ˆ ์™„์„ฑ! (40ํ™” ๊ตฌ์„ฑ ์™„๋ฃŒ)", planning_doc, "40ํ™” ๊ตฌ์„ฑ ์™„๋ฃŒ", self.current_session_id, character_profiles
# Phase 2: Generate Episode 1 Storyboard
yield "๐ŸŽจ 1ํ™” ์Šคํ† ๋ฆฌ๋ณด๋“œ ์ž‘์„ฑ ์ค‘... (30๊ฐœ ํŒจ๋„)", planning_doc, "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
)
# Parse storyboard into structured format
storyboard = self.parse_storyboard(storyboard_content, 1, character_profiles)
# Save to database
WebtoonDatabase.save_storyboard(self.current_session_id, 1, storyboard)
yield "๐ŸŽ‰ ์™„์„ฑ! (๊ธฐํš์•ˆ + 1ํ™” ์Šคํ† ๋ฆฌ๋ณด๋“œ)", planning_doc, storyboard_content, 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 unique panel IDs"""
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:
panels.append(current_panel)
panel_number += 1
# Create unique panel ID
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 # Add unique panel_id
)
elif current_panel:
if '์ด๋ฏธ์ง€ ํ”„๋กฌํ”„ํŠธ:' in line or 'Image prompt:' in line:
current_panel.image_prompt = line.split(':', 1)[1].strip()
elif '๋Œ€์‚ฌ:' in line or 'Dialogue:' in line:
dialogue = line.split(':', 1)[1].strip()
if dialogue:
current_panel.dialogue.append(dialogue)
elif '๋‚˜๋ ˆ์ด์…˜:' in line or 'Narration:' in line:
current_panel.narration = line.split(':', 1)[1].strip()
elif 'ํšจ๊ณผ์Œ:' in line or 'Sound effects:' in line:
effects = line.split(':', 1)[1].strip()
if effects:
current_panel.sound_effects.append(effects)
elif '์ƒท ํƒ€์ž…:' in line or 'Shot type:' in line:
current_panel.scene_type = line.split(':', 1)[1].strip()
if current_panel:
panels.append(current_panel)
storyboard.panels = panels[:30]
return storyboard
# --- Format storyboard for display ---
def format_storyboard_for_display(storyboard_content: str, character_profiles: Dict[str, CharacterProfile], session_id: str) -> str:
"""Format storyboard content for panel display"""
formatted_panels = []
panel_texts = []
current_panel = {}
lines = storyboard_content.split('\n')
for line in lines:
if 'ํŒจ๋„' in line or 'Panel' in line:
if current_panel:
panel_texts.append(current_panel)
current_panel = {'number': len(panel_texts) + 1}
elif '์ด๋ฏธ์ง€ ํ”„๋กฌํ”„ํŠธ:' in line or 'Image prompt:' in line:
current_panel['prompt'] = line.split(':', 1)[1].strip()
elif '๋Œ€์‚ฌ:' in line or 'Dialogue:' in line:
current_panel['dialogue'] = line.split(':', 1)[1].strip()
elif '๋‚˜๋ ˆ์ด์…˜:' in line or 'Narration:' in line:
current_panel['narration'] = line.split(':', 1)[1].strip()
elif 'ํšจ๊ณผ์Œ:' in line or 'Sound effects:' in line:
current_panel['effects'] = line.split(':', 1)[1].strip()
elif '์ƒท ํƒ€์ž…:' in line or 'Shot type:' in line:
current_panel['shot'] = line.split(':', 1)[1].strip()
if current_panel:
panel_texts.append(current_panel)
# Format each panel for display
for panel in panel_texts[:30]:
panel_num = panel.get('number', '?')
panel_html = f"""
<div style="border: 1px solid #ddd; padding: 15px; margin-bottom: 15px; border-radius: 8px; background: #f9f9f9;">
<h4 style="color: #764ba2; margin-top: 0;">๐ŸŽฌ ํŒจ๋„ {panel_num}</h4>
<p><strong>์ƒท ํƒ€์ž…:</strong> {panel.get('shot', 'N/A')}</p>
<p><strong>์ด๋ฏธ์ง€ ํ”„๋กฌํ”„ํŠธ:</strong><br>
<code style="background: #fff; padding: 8px; display: block; border-radius: 4px; font-size: 12px;">
{panel.get('prompt', 'N/A')}
</code></p>
{f"<p><strong>๋Œ€์‚ฌ:</strong> {panel.get('dialogue')}</p>" if panel.get('dialogue') else ""}
{f"<p><strong>๋‚˜๋ ˆ์ด์…˜:</strong> {panel.get('narration')}</p>" if panel.get('narration') else ""}
{f"<p><strong>ํšจ๊ณผ์Œ:</strong> {panel.get('effects')}</p>" if panel.get('effects') else ""}
</div>
"""
formatted_panels.append(panel_html)
return ''.join(formatted_panels)
# --- Export functions ---
def export_to_txt(planning_doc: str, storyboard: str, genre: str, title: str = "") -> str:
"""Export webtoon planning and storyboard to TXT format"""
content = f"{'=' * 50}\n"
content += f"{title if title else genre + ' ์›นํˆฐ'}\n"
content += f"{'=' * 50}\n\n"
content += f"์žฅ๋ฅด: {genre}\n"
content += f"์ด 40ํ™” ๊ธฐํš\n"
content += f"{'=' * 50}\n\n"
content += "๐Ÿ“š ๊ธฐํš์•ˆ (40ํ™” ์ „์ฒด ๊ตฌ์„ฑ)\n\n"
content += planning_doc
content += f"\n\n{'=' * 50}\n\n"
content += "๐ŸŽจ 1ํ™” ์Šคํ† ๋ฆฌ๋ณด๋“œ (30 ํŒจ๋„)\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)
# --- Gradio interface ---
def create_interface():
with gr.Blocks(theme=gr.themes.Soft(), title="K-Webtoon Storyboard Generator") as interface:
gr.HTML("""
<style>
.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);
}
.character-profile {
background: #f0f0f0;
padding: 10px;
margin: 5px 0;
border-radius: 8px;
}
.panel-image-container {
min-height: 400px;
background: #fff;
border: 2px dashed #ddd;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.episode-structure {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
margin: 10px 0;
max-height: 500px;
overflow-y: auto;
}
</style>
<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
current_session_id = gr.State(None)
planning_state = gr.State("")
storyboard_state = gr.State("")
character_profiles_state = gr.State({})
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ํ™” ์ „์ฒด ๊ตฌ์„ฑ์•ˆ
- ๊ฐ ํ™”๋ณ„ ํด๋ฆฌํ”„ํ–‰์–ด
- ์บ๋ฆญํ„ฐ ํ”„๋กœํ•„
- 1ํ™” 30ํŒจ๋„ ์Šคํ† ๋ฆฌ๋ณด๋“œ
""")
status_text = gr.Textbox(
label="์ง„ํ–‰ ์ƒํ™ฉ",
interactive=False,
value="์žฅ๋ฅด๋ฅผ ์„ ํƒํ•˜๊ณ  ์ฝ˜์…‰ํŠธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”"
)
# Planning output with better visibility for 40 episodes
gr.Markdown("### ๐Ÿ“– ์›นํˆฐ ๊ธฐํš์•ˆ (40ํ™” ์ „์ฒด ๊ตฌ์„ฑ)")
planning_display = gr.Markdown("*๊ธฐํš์•ˆ์ด ์—ฌ๊ธฐ์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค (40ํ™” ์ „์ฒด ๊ตฌ์„ฑ ํฌํ•จ)*")
# Character profiles display
character_display = gr.HTML(label="์บ๋ฆญํ„ฐ ํ”„๋กœํ•„")
with gr.Row():
download_format = gr.Radio(
choices=["TXT"],
value="TXT",
label="๋‹ค์šด๋กœ๋“œ ํ˜•์‹"
)
download_btn = gr.Button("๐Ÿ“ฅ ๋‹ค์šด๋กœ๋“œ", variant="secondary")
download_file = gr.File(visible=False)
with gr.Tab("๐ŸŽฌ 1ํ™” ์Šคํ† ๋ฆฌ๋ณด๋“œ (30ํŒจ๋„)"):
gr.Markdown("""
### ๐Ÿ“‹ 1ํ™” ์Šคํ† ๋ฆฌ๋ณด๋“œ (30 ํŒจ๋„)
์ขŒ์ธก: ํ…์ŠคํŠธ ๋ฐ ์ด๋ฏธ์ง€ ํ”„๋กฌํ”„ํŠธ | ์šฐ์ธก: ์ƒ์„ฑ๋œ ์ด๋ฏธ์ง€
""")
# Progress bar for image generation
progress_bar = gr.Progress(visible=False)
generation_status = gr.Textbox(
label="์ด๋ฏธ์ง€ ์ƒ์„ฑ ์ƒํƒœ",
value="๋Œ€๊ธฐ ์ค‘...",
interactive=False,
visible=False
)
with gr.Row():
with gr.Column():
storyboard_text_display = gr.HTML(
value="<p style='color: #999; text-align: center; padding: 50px;'>๊ธฐํš์•ˆ ์ž‘์„ฑ ํ›„ ์Šคํ† ๋ฆฌ๋ณด๋“œ๊ฐ€ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค</p>",
label="ํŒจ๋„ ํ…์ŠคํŠธ & ํ”„๋กฌํ”„ํŠธ"
)
with gr.Column():
image_display_area = gr.HTML(
value="""
<div style="padding: 20px; background: #fafafa; border: 2px dashed #ddd; border-radius: 10px; min-height: 800px;">
<p style="color: #999; text-align: center;">๐Ÿ–ผ๏ธ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๊ณต๊ฐ„</p>
<p style="color: #aaa; text-align: center; font-size: 12px;">๊ฐ ํŒจ๋„์˜ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๋ ค๋ฉด ์•„๋ž˜ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์„ธ์š”</p>
</div>
""",
label="์ƒ์„ฑ๋œ ์ด๋ฏธ์ง€"
)
# Image generation controls with progress
with gr.Row():
generate_all_btn = gr.Button("๐ŸŽจ ๋ชจ๋“  ์ด๋ฏธ์ง€ ์ƒ์„ฑ (30๊ฐœ ํŒจ๋„)", variant="primary")
clear_images_btn = gr.Button("๐Ÿ—‘๏ธ ์ด๋ฏธ์ง€ ์ดˆ๊ธฐํ™”", variant="secondary")
generation_progress = gr.Number(value=0, label="์ƒ์„ฑ ์ง„ํ–‰๋ฅ  (%)", visible=False)
# Event handlers
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
def format_character_profiles(profiles: Dict[str, CharacterProfile]) -> str:
"""Format character profiles for display"""
if not profiles:
return ""
html = "<h3>๐ŸŽญ ์บ๋ฆญํ„ฐ ํ”„๋กœํ•„</h3>"
for name, profile in profiles.items():
html += f"""
<div class="character-profile">
<strong>{name}</strong> - {profile.celebrity_lookalike} ๋‹ฎ์€ ์–ผ๊ตด
<br>์—ญํ• : {profile.role}
<br>์„ฑ๊ฒฉ: {profile.personality}
</div>
"""
return html
def update_storyboard_display(storyboard_content, character_profiles, session_id):
"""Update storyboard display with formatted panels"""
if not storyboard_content or storyboard_content == "":
return "<p style='color: #999; text-align: center; padding: 50px;'>๊ธฐํš์•ˆ ์ž‘์„ฑ ํ›„ ์Šคํ† ๋ฆฌ๋ณด๋“œ๊ฐ€ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค</p>"
return format_storyboard_for_display(storyboard_content, character_profiles, session_id)
def handle_random_theme(genre, language):
return generate_random_webtoon_theme(genre, language)
def handle_download(download_format, session_id, planning, storyboard, genre):
"""Handle download request"""
try:
title = f"{genre} ์›นํˆฐ"
content = export_to_txt(planning, storyboard, genre, title)
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
suffix='.txt', delete=False) as f:
f.write(content)
return f.name
except Exception as e:
logger.error(f"Download error: {e}")
gr.Warning(f"๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {str(e)}")
return None
def generate_all_images_with_progress(session_id, storyboard_content, character_profiles, progress=gr.Progress()):
"""Generate images for all panels with progress tracking"""
if not REPLICATE_API_TOKEN:
return "<p style='color: red; text-align: center; padding: 20px;'>โš ๏ธ Replicate API ํ† ํฐ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.</p>"
if not storyboard_content:
return "<p style='color: orange; text-align: center; padding: 20px;'>โš ๏ธ ๋จผ์ € ์Šคํ† ๋ฆฌ๋ณด๋“œ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.</p>"
# Parse storyboard to extract prompts
panel_prompts = []
lines = storyboard_content.split('\n')
current_panel_num = 0
current_prompt = ""
for line in lines:
if 'ํŒจ๋„' in line or 'Panel' in line:
if current_prompt and current_panel_num > 0:
panel_prompts.append({
'panel_id': f"ep1_panel{current_panel_num}",
'panel_num': current_panel_num,
'prompt': current_prompt
})
current_panel_num += 1
current_prompt = ""
elif '์ด๋ฏธ์ง€ ํ”„๋กฌํ”„ํŠธ:' in line or 'Image prompt:' in line:
current_prompt = line.split(':', 1)[1].strip()
# Add last panel if exists
if current_prompt and current_panel_num > 0:
panel_prompts.append({
'panel_id': f"ep1_panel{current_panel_num}",
'panel_num': current_panel_num,
'prompt': current_prompt
})
# Limit to 30 panels
panel_prompts = panel_prompts[:30]
total_panels = len(panel_prompts)
if total_panels == 0:
return "<p style='color: orange; text-align: center; padding: 20px;'>โš ๏ธ ํŒจ๋„ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.</p>"
# Initialize progress
progress(0, desc=f"์ด๋ฏธ์ง€ ์ƒ์„ฑ ์ค€๋น„ ์ค‘... (์ด {total_panels}๊ฐœ ํŒจ๋„)")
# Generate images with progress tracking
image_generator = ImageGenerator()
image_html = "<div style='padding: 20px;'>"
image_html += f"<h3>๐Ÿ–ผ๏ธ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๊ฒฐ๊ณผ</h3>"
results = []
# Generate images one by one with progress updates
for i, panel_data in enumerate(panel_prompts):
progress((i / total_panels), desc=f"ํŒจ๋„ {panel_data['panel_num']}/{total_panels} ์ƒ์„ฑ ์ค‘...")
try:
result = image_generator.generate_image(
panel_data['prompt'],
panel_data['panel_id'],
session_id
)
results.append(result)
except Exception as e:
results.append({
"panel_id": panel_data['panel_id'],
"status": "error",
"message": str(e)
})
# Small delay to prevent API rate limiting
time.sleep(0.5)
progress(1.0, desc="์ด๋ฏธ์ง€ ์ƒ์„ฑ ์™„๋ฃŒ!")
# Display results
success_count = sum(1 for r in results if r['status'] == 'success')
image_html += f"<p style='color: green; font-weight: bold;'>โœ… ์„ฑ๊ณต: {success_count}/{len(results)} ํŒจ๋„</p>"
# Grid layout for images
image_html += "<div style='display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin-top: 20px;'>"
for result in results:
panel_num = int(result['panel_id'].split('_panel')[1])
if result['status'] == 'success':
image_html += f"""
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 8px; background: white;">
<h4 style="margin-top: 0; color: #764ba2;">ํŒจ๋„ {panel_num}</h4>
<img src="{result['image_url']}" style="width: 100%; border-radius: 8px; margin-bottom: 10px;">
<details style="margin-top: 10px;">
<summary style="cursor: pointer; color: #667eea;">ํ”„๋กฌํ”„ํŠธ ๋ณด๊ธฐ</summary>
<p style="font-size: 11px; color: #666; margin-top: 5px; padding: 5px; background: #f5f5f5; border-radius: 4px;">
{result.get('prompt', '')[:150]}...
</p>
</details>
</div>
"""
else:
image_html += f"""
<div style="border: 1px solid #fee; padding: 15px; border-radius: 8px; background: #fff5f5;">
<h4 style="margin-top: 0; color: #d00;">ํŒจ๋„ {panel_num} - ์ƒ์„ฑ ์‹คํŒจ</h4>
<p style="color: #d00; font-size: 14px;">โŒ {result.get('message', 'Unknown error')}</p>
</div>
"""
image_html += "</div></div>"
return image_html
def clear_all_images():
"""Clear all generated images"""
return """
<div style="padding: 20px; background: #fafafa; border: 2px dashed #ddd; border-radius: 10px; min-height: 800px;">
<p style="color: #999; text-align: center;">๐Ÿ–ผ๏ธ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๊ณต๊ฐ„</p>
<p style="color: #aaa; text-align: center; font-size: 12px;">์ด๋ฏธ์ง€๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค</p>
</div>
"""
# 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]
).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=update_storyboard_display,
inputs=[storyboard_state, character_profiles_state, current_session_id],
outputs=[storyboard_text_display]
)
random_btn.click(
fn=handle_random_theme,
inputs=[genre_select, language_select],
outputs=[query_input]
)
download_btn.click(
fn=handle_download,
inputs=[download_format, current_session_id, planning_state, storyboard_state, genre_select],
outputs=[download_file]
).then(
fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False),
inputs=[download_file],
outputs=[download_file]
)
generate_all_btn.click(
fn=generate_all_images_with_progress,
inputs=[current_session_id, storyboard_state, character_profiles_state],
outputs=[image_display_area]
)
clear_images_btn.click(
fn=clear_all_images,
inputs=[],
outputs=[image_display_area]
)
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
)