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 | |
# --- 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.") | |
import io # Add io import for DOCX export | |
# --- Environment variables and constants --- | |
FIREWORKS_API_KEY = os.getenv("FIREWORKS_API_KEY", "") | |
BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "") | |
API_URL = "https://api.fireworks.ai/inference/v1/chat/completions" | |
MODEL_ID = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" | |
DB_PATH = "webtoon_sessions_v1.db" | |
# Target settings for webtoon - UPDATED 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" | |
} | |
# --- 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 BRAVE_SEARCH_API_KEY: | |
logger.warning("BRAVE_SEARCH_API_KEY not set. Web search features will be disabled.") | |
# --- Global variables --- | |
db_lock = threading.Lock() | |
# --- Data classes --- | |
class WebtoonBible: | |
"""Webtoon story bible for maintaining consistency""" | |
genre: str = "" | |
title: str = "" | |
characters: Dict[str, Dict[str, Any]] = 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""" | |
panel_number: int | |
scene_type: str # wide, close-up, medium, establishing | |
image_prompt: str # Image generation prompt | |
dialogue: List[str] = field(default_factory=list) | |
narration: str = "" | |
sound_effects: List[str] = field(default_factory=list) | |
emotion_notes: str = "" | |
camera_angle: str = "" | |
background: str = "" | |
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 = "" | |
# --- 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": ["κ²½κΈ°μ₯", "νλ ¨μ₯", "λΌμ»€λ£Έ", "λ²€μΉ", "μμλ"] | |
} | |
} | |
# Panel composition types | |
PANEL_COMPOSITIONS = { | |
"establishing": "μ 체 λ°°κ²½κ³Ό λΆμκΈ°λ₯Ό 보μ¬μ£Όλ μ거리 μ·", | |
"wide": "μΊλ¦ν°μ λ°°κ²½μ΄ λͺ¨λ 보μ΄λ μμ΄λ μ·", | |
"medium": "μΊλ¦ν° μλ°μ μ΄ λ³΄μ΄λ λ―Έλμ μ·", | |
"close_up": "μΌκ΅΄μ΄λ νμ μ μ§μ€νλ ν΄λ‘μ¦μ ", | |
"extreme_close_up": "λ, μ λ± νΉμ λΆμ κ·Ήλ¨ ν΄λ‘μ¦μ ", | |
"over_shoulder": "μ΄κΉ¨ λλ¨Έλ‘ λ³΄λ μ€λ²μλ μ·", | |
"dutch_angle": "κΈ΄μ₯κ°μ μν κΈ°μΈμ΄μ§ μ΅κΈ", | |
"bird_eye": "μμμ λ΄λ €λ€λ³΄λ λΆκ° μ·", | |
"worm_eye": "μλμ μ¬λ €λ€λ³΄λ μκ° μ·", | |
"action": "μμ§μμ κ°μ‘°νλ μ‘μ μ·" | |
} | |
# --- Core logic classes --- | |
class 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 | |
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_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() | |
# 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 | |
) | |
''') | |
# 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 | |
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, | |
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() | |
# 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() | |
# --- 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 | |
WebtoonDatabase.init_db() | |
def create_headers(self): | |
return { | |
"Accept": "application/json", | |
"Content-Type": "application/json", | |
"Authorization": f"Bearer {self.api_key}" | |
} | |
# --- Prompt generation functions --- | |
def create_planning_prompt(self, query: str, genre: str, language: str) -> str: | |
"""Create initial planning prompt for webtoon""" | |
genre_info = GENRE_ELEMENTS.get(genre, {}) | |
lang_prompts = { | |
"Korean": f"""νκ΅ μΉν° μμ₯μ 겨λ₯ν {genre} μ₯λ₯΄ μΉν°μ κΈ°ννμΈμ. | |
**[ν΅μ¬ μ€ν 리 μ€μ - λ°λμ μ΄ λ΄μ©μ μ€μ¬μΌλ‘ μ κ°νμΈμ]** | |
{query} | |
**μ₯λ₯΄:** {genre} | |
**λͺ©ν:** 40ν μκ²° μΉν° | |
β οΈ **μ€μ**: μμ μ μλ μ€ν 리 μ€μ μ λ°λμ κΈ°λ°μΌλ‘ νμ¬ νλ‘―μ ꡬμ±νμΈμ. | |
**μ₯λ₯΄ νμ μμ:** | |
- ν΅μ¬ μμ: {', '.join(genre_info.get('key_elements', []))} | |
- λΉμ£ΌμΌ μ€νμΌ: {', '.join(genre_info.get('visual_styles', []))} | |
- μ£Όμ μ¬: {', '.join(genre_info.get('typical_scenes', []))} | |
**μ 체 κ΅¬μ± (40ν κΈ°μ€):** | |
1. **1-5ν**: μΈκ³κ΄ μκ°, μ£ΌμΈκ³΅ λ±μ₯, ν΅μ¬ κ°λ± μ μ | |
2. **6-15ν**: κ°λ± μ¬ν, μΊλ¦ν° κ΄κ³ λ°μ , μλΈνλ‘― μ κ° | |
3. **16-25ν**: μ€κ° ν΄λΌμ΄λ§₯μ€, λ°μ , μλ‘μ΄ μκΈ° | |
4. **26-35ν**: μ΅μ’ κ°λ± κ³ μ‘°, κ²°μ μ€λΉ | |
5. **36-40ν**: ν΄λΌμ΄λ§₯μ€, ν΄κ²°, μνλ‘κ·Έ | |
**μΉν° νΉν μμ:** | |
- λ§€ ν λ§μ§λ§ κ°λ ₯ν ν΄λ¦¬ννμ΄ | |
- μΈλ‘ μ€ν¬λ‘€μ μ ν©ν μ°μΆ | |
- μν©νΈ μλ λμ¬μ μ‘μ | |
- λ μ λκΈμ μ λ°νλ μ κ° | |
λ€μ νμμΌλ‘ μμ±νμΈμ: | |
π **μν μ λͺ©:** [μν©νΈ μλ μ λͺ©] | |
π¨ **λΉμ£ΌμΌ 컨μ :** | |
- 그림체: [μνμ μ΄μΈλ¦¬λ 그림체] | |
- μκ°: [μ£Όμ μμ ν€] | |
- μΊλ¦ν° λμμΈ νΉμ§: [μ£ΌμΈκ³΅λ€μ λΉμ£ΌμΌ νΉμ§] | |
π₯ **μ£Όμ μΊλ¦ν°:** | |
- μ£ΌμΈκ³΅: [μ΄λ¦] - [μΈλͺ¨ νΉμ§, μ±κ²©, λͺ©ν] | |
- μΊλ¦ν°2: [μ΄λ¦] - [μν , νΉμ§] | |
- μΊλ¦ν°3: [μ΄λ¦] - [μν , νΉμ§] | |
π **μλμμ€:** | |
[3-4μ€λ‘ μ 체 μ€ν 리 μμ½] | |
π **40ν μ 체 ꡬμ±μ:** | |
κ° νλ³λ‘ ν΅μ¬ μ¬κ±΄κ³Ό ν΄λ¦¬ννμ΄λ₯Ό ν¬ν¨νμ¬ μμ±νμΈμ. | |
1ν: [μ λͺ©] - [ν΅μ¬ μ¬κ±΄] - ν΄λ¦¬ννμ΄: [좩격μ μΈ λ§λ¬΄λ¦¬] | |
2ν: [μ λͺ©] - [ν΅μ¬ μ¬κ±΄] - ν΄λ¦¬ννμ΄: [좩격μ μΈ λ§λ¬΄λ¦¬] | |
... | |
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**: You MUST base the plot on the story setting provided above. | |
**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', []))} | |
**Overall Structure (40 episodes):** | |
1. **Episodes 1-5**: World introduction, protagonist debut, core conflict | |
2. **Episodes 6-15**: Deepening conflict, character development, subplots | |
3. **Episodes 16-25**: Mid-climax, plot twist, new crisis | |
4. **Episodes 26-35**: Final conflict escalation, preparation for showdown | |
5. **Episodes 36-40**: Climax, resolution, epilogue | |
**Webtoon-specific elements:** | |
- Strong cliffhanger at end of each episode | |
- Vertical scroll-optimized directing | |
- Impactful dialogue and action | |
- Comment-inducing development | |
Format as follows: | |
π **Title:** [Impactful title] | |
π¨ **Visual Concept:** | |
- Art style: [Suitable art style] | |
- Color tone: [Main color palette] | |
- Character design: [Visual characteristics] | |
π₯ **Main Characters:** | |
- Protagonist: [Name] - [Appearance, personality, goal] | |
- Character2: [Name] - [Role, traits] | |
- Character3: [Name] - [Role, traits] | |
π **Synopsis:** | |
[3-4 line story summary] | |
π **40 Episode Structure:** | |
Include key events and cliffhangers for each episode. | |
Episode 1: [Title] - [Key event] - Cliffhanger: [Shocking ending] | |
Episode 2: [Title] - [Key event] - Cliffhanger: [Shocking ending] | |
... | |
Episode 40: [Title] - [Key event] - [Grand finale]""" | |
} | |
return lang_prompts.get(language, lang_prompts["Korean"]) | |
def create_storyboard_prompt(self, episode_num: int, plot_outline: str, | |
genre: str, language: str) -> str: | |
"""Create prompt for episode 1 storyboard with 30 panels""" | |
genre_info = GENRE_ELEMENTS.get(genre, {}) | |
lang_prompts = { | |
"Korean": f"""μΉν° {episode_num}ν μ€ν 리보λλ₯Ό 30κ° ν¨λλ‘ μμ±νμΈμ. | |
**μ₯λ₯΄:** {genre} | |
**1ν λ΄μ©:** {self._extract_episode_plan(plot_outline, episode_num)} | |
**ν¨λ κ΅¬μ± μ§μΉ¨:** | |
- μ΄ 30κ° ν¨λλ‘ κ΅¬μ± | |
- λ€μν μ· μ¬μ΄μ¦ νμ© (μμ΄λμ·, ν΄λ‘μ¦μ , λ―Έλμμ· λ±) | |
- μ₯λ₯΄ νΉμ±μ λ§λ μ°μΆ: {', '.join(genre_info.get('panel_types', []))} | |
- μΈλ‘ μ€ν¬λ‘€ μΉν°μ μ΅μ νλ κ΅¬μ± | |
**κ° ν¨λλ³λ‘ λ€μμ ν¬ν¨νμ¬ μμ±:** | |
ν¨λ 1: | |
- μ· νμ : [establishing/wide/medium/close_up λ±] | |
- μ΄λ―Έμ§ ν둬ννΈ: [μμΈν νκΈ μ΄λ―Έμ§ μμ± ν둬ννΈ] | |
- λμ¬: [μΊλ¦ν° λμ¬κ° μλ€λ©΄] | |
- λλ μ΄μ : [ν΄μ€μ΄ μλ€λ©΄] | |
- ν¨κ³Όμ: [νμν ν¨κ³Όμ] | |
- λ°°κ²½: [λ°°κ²½ μ€λͺ ] | |
...μ΄λ° μμΌλ‘ 30κ° ν¨λ λͺ¨λ μμ± | |
**μ€μ μ°μΆ ν¬μΈνΈ:** | |
1. 첫 ν¨λμ μν©νΈ μλ establishing shot | |
2. κ°μ μ μ ν΄λ‘μ¦μ μΌλ‘ κ°μ‘° | |
3. μ‘μ μ λ€μ΄λλ―Ήν μ΅κΈ νμ© | |
4. λ§μ§λ§ ν¨λμ κ°λ ₯ν ν΄λ¦¬ννμ΄ | |
5. λμ¬μ μ΄λ―Έμ§κ° μ‘°νλ‘κ² κ΅¬μ± | |
λ°λμ 30κ° ν¨λμ λͺ¨λ μμ±νμΈμ.""", | |
"English": f"""Create Episode {episode_num} storyboard with 30 panels. | |
**Genre:** {genre} | |
**Episode 1 content:** {self._extract_episode_plan(plot_outline, episode_num)} | |
**Panel composition guidelines:** | |
- Total 30 panels | |
- Various shot sizes (wide, close-up, medium, etc.) | |
- Genre-appropriate directing: {', '.join(genre_info.get('panel_types', []))} | |
- Optimized for vertical scroll webtoon | |
**For each panel include:** | |
Panel 1: | |
- Shot type: [establishing/wide/medium/close_up etc] | |
- Image prompt: [Detailed Korean image generation prompt] | |
- Dialogue: [Character dialogue if any] | |
- Narration: [Narration if any] | |
- Sound effects: [Required sound effects] | |
- Background: [Background description] | |
...continue for all 30 panels | |
**Key directing points:** | |
1. First panel: impactful establishing shot | |
2. Emphasize emotions with close-ups | |
3. Dynamic angles for action | |
4. Last panel: powerful cliffhanger | |
5. Harmonious dialogue and image composition | |
Must write all 30 panels.""" | |
} | |
return lang_prompts.get(language, lang_prompts["Korean"]) | |
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} λ΄μ©μ νλ‘―μμ μ°Έμ‘°νμ¬ μμ±νμΈμ." | |
# --- 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ν μκ²° κ΅¬μ‘°λ‘ μλ²½ν κΈ°μΉμ κ²°μ μ€κ³ν©λλ€. | |
κ° νλ§λ€ κ°λ ₯ν ν΄λ¦¬ννμ΄λ‘ λ€μ νλ₯Ό κΈ°λνκ² λ§λλλ€. | |
μ₯λ₯΄λ³ νΉμ±κ³Ό λ μμΈ΅μ μ νν νμ ν©λλ€. | |
β οΈ κ°μ₯ μ€μν μμΉ: μ¬μ©μκ° μ 곡ν μ€ν 리 μ€μ μ μ λμ μΌλ‘ μ°μ μνκ³ , μ΄λ₯Ό μ€μ¬μΌλ‘ λͺ¨λ νλ‘―μ ꡬμ±ν©λλ€.""", | |
"storyboarder": """λΉμ μ μΉν° μ€ν 리보λ μ λ¬Έκ°μ λλ€. | |
30κ° ν¨λλ‘ ν νλ₯Ό μλ²½νκ² κ΅¬μ±ν©λλ€. | |
μΈλ‘ μ€ν¬λ‘€μ μ΅μ νλ μ°μΆμ ν©λλ€. | |
κ° ν¨λλ§λ€ μμΈν μ΄λ―Έμ§ ν둬ννΈλ₯Ό νκΈλ‘ μμ±ν©λλ€. | |
λμ¬, λλ μ΄μ , ν¨κ³Όμμ μ μ ν λ°°μΉν©λλ€. | |
λ€μν μΉ΄λ©λΌ μ΅κΈκ³Ό μ· μ¬μ΄μ¦λ₯Ό νμ©ν©λλ€. | |
κ°μ μ κ³Ό μ‘μ μ μκ°μ μΌλ‘ κ·Ήλνν©λλ€. | |
β οΈ κ°μ₯ μ€μν μμΉ: λ°λμ 30κ° ν¨λμ λͺ¨λ μμ±νκ³ , κ° ν¨λλ§λ€ μ΄λ―Έμ§ μμ±μ΄ κ°λ₯ν ꡬ체μ μΈ νκΈ ν둬ννΈλ₯Ό μ 곡ν©λλ€.""" | |
}, | |
"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. | |
Accurately understand genre characteristics and readership. | |
β οΈ Most important principle: Absolutely prioritize the user's story setting and build all plots around it.""", | |
"storyboarder": """You are a webtoon storyboard specialist. | |
Perfectly compose one episode with 30 panels. | |
Optimize direction for vertical scrolling. | |
Write detailed image prompts in Korean for each panel. | |
Properly place dialogue, narration, and sound effects. | |
Use various camera angles and shot sizes. | |
Visually maximize emotions and action. | |
β οΈ Most important principle: Must write all 30 panels and provide specific Korean prompts for image generation for each panel.""" | |
} | |
} | |
return base_prompts.get(language, base_prompts["Korean"]) | |
# --- Main process --- | |
def process_webtoon_stream(self, query: str, genre: str, language: str, | |
session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], 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 episodes structure) | |
yield "π¬ μΉν° κΈ°νμ μμ± μ€...", "", f"μ₯λ₯΄: {genre}", self.current_session_id | |
planning_prompt = self.create_planning_prompt(query, genre, language) | |
planning_doc = self.call_llm_sync( | |
[{"role": "user", "content": planning_prompt}], | |
"planner", language | |
) | |
self.planning_doc = planning_doc | |
yield "β κΈ°νμ μμ±!", planning_doc, "40ν κ΅¬μ± μλ£", self.current_session_id | |
# Phase 2: Generate Episode 1 Storyboard (30 panels) | |
yield "π¨ 1ν μ€ν 리보λ μμ± μ€...", planning_doc, "30κ° ν¨λ κ΅¬μ± μ€", self.current_session_id | |
storyboard_prompt = self.create_storyboard_prompt(1, planning_doc, genre, language) | |
storyboard_content = self.call_llm_sync( | |
[{"role": "user", "content": storyboard_prompt}], | |
"storyboarder", language | |
) | |
# Parse storyboard into structured format | |
storyboard = self.parse_storyboard(storyboard_content, 1) | |
# Save to database | |
WebtoonDatabase.save_storyboard(self.current_session_id, 1, storyboard) | |
# Format final output | |
final_output = f"{planning_doc}\n\n{'='*50}\n\n## π 1ν μ€ν 리보λ (30 ν¨λ)\n\n{storyboard_content}" | |
yield "π μμ±!", final_output, "κΈ°νμ + 1ν μ€ν 리보λ μλ£", self.current_session_id | |
except Exception as e: | |
logger.error(f"Webtoon generation error: {e}", exc_info=True) | |
yield f"β μ€λ₯ λ°μ: {e}", "", "μ€λ₯", self.current_session_id | |
def parse_storyboard(self, content: str, episode_num: int) -> EpisodeStoryboard: | |
"""Parse storyboard text into structured format""" | |
storyboard = EpisodeStoryboard(episode_number=episode_num, title=f"{episode_num}ν") | |
# Simple parsing - can be enhanced | |
panels = [] | |
current_panel = None | |
panel_number = 0 | |
lines = content.split('\n') | |
for line in lines: | |
if 'ν¨λ' in line or 'Panel' in line: | |
if current_panel: | |
panels.append(current_panel) | |
panel_number += 1 | |
# Initialize with empty image_prompt as required argument | |
current_panel = StoryboardPanel( | |
panel_number=panel_number, | |
scene_type="medium", | |
image_prompt="" # Initialize with empty string | |
) | |
elif current_panel: | |
if 'μ΄λ―Έμ§ ν둬ννΈ:' in line or 'Image prompt:' in line: | |
current_panel.image_prompt = line.split(':', 1)[1].strip() | |
elif 'λμ¬:' in line or 'Dialogue:' in line: | |
dialogue = line.split(':', 1)[1].strip() | |
if dialogue: | |
current_panel.dialogue.append(dialogue) | |
elif 'λλ μ΄μ :' in line or 'Narration:' in line: | |
current_panel.narration = line.split(':', 1)[1].strip() | |
elif 'ν¨κ³Όμ:' in line or 'Sound effects:' in line: | |
effects = line.split(':', 1)[1].strip() | |
if effects: | |
current_panel.sound_effects.append(effects) | |
elif 'μ· νμ :' in line or 'Shot type:' in line: | |
current_panel.scene_type = line.split(':', 1)[1].strip() | |
if current_panel: | |
panels.append(current_panel) | |
storyboard.panels = panels[:30] # Limit to 30 panels | |
return storyboard | |
# --- Export functions --- | |
def export_to_txt(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 += "π κΈ°νμ\n\n" | |
content += planning_doc | |
content += f"\n\n{'=' * 50}\n\n" | |
content += "π¨ 1ν μ€ν 리보λ (30 ν¨λ)\n\n" | |
content += storyboard | |
return content | |
def export_to_docx(planning_doc: str, storyboard: str, genre: str, title: str = "") -> bytes: | |
"""Export webtoon planning and storyboard to DOCX format""" | |
if not DOCX_AVAILABLE: | |
raise Exception("python-docx is not installed") | |
doc = Document() | |
# Title | |
doc.add_heading(title if title else f"{genre} μΉν°", 0) | |
# Info | |
doc.add_paragraph(f"μ₯λ₯΄: {genre}") | |
doc.add_paragraph("μ΄ 40ν κΈ°ν") | |
doc.add_page_break() | |
# Planning document | |
doc.add_heading("π κΈ°νμ", 1) | |
for line in planning_doc.split('\n'): | |
if line.strip(): | |
if line.startswith('#'): | |
doc.add_heading(line.replace('#', '').strip(), 2) | |
else: | |
doc.add_paragraph(line) | |
doc.add_page_break() | |
# Storyboard | |
doc.add_heading("π¨ 1ν μ€ν 리보λ (30 ν¨λ)", 1) | |
for line in storyboard.split('\n'): | |
if line.strip(): | |
if 'ν¨λ' in line or 'Panel' in line: | |
doc.add_heading(line, 2) | |
else: | |
doc.add_paragraph(line) | |
# Save to bytes | |
bytes_io = io.BytesIO() | |
doc.save(bytes_io) | |
bytes_io.seek(0) | |
return bytes_io.getvalue() | |
def generate_random_webtoon_theme(genre: str, language: str) -> str: | |
"""Generate random webtoon theme""" | |
templates = { | |
"λ‘맨μ€": [ | |
"μ¬λ² 3μΈ μμ¬μ μ μ μ¬μμ λΉλ° κ³μ½μ°μ ", | |
"κ³ λ±νκ΅ λ 첫μ¬λκ³Ό 10λ λ§μ μ¬ν", | |
"λν κ²μ¬μ μ΄ν λ³νΈμ¬μ λ²μ λ‘맨μ€" | |
], | |
"λ‘ν": [ | |
"μ λ λ‘ λΉμνλλ° 1λ ν μ²ν μμ ", | |
"νκ·ν ν©λ , λ²λ €μ§ μμμ μμ‘λ€", | |
"κ³μ½κ²°νΌν λΆλΆ 곡μμ΄ μ§μ°©λ¨μ΄ λμλ€" | |
], | |
"ννμ§": [ | |
"FκΈ νν°κ° SSSκΈ λ€ν¬λ‘맨μλ‘ κ°μ±", | |
"100μΈ΅ νμ μμ£Όννλ νκ·μ", | |
"λ²κ·Έλ‘ μ΅κ°μ΄ λ κ²μ μ NPC" | |
], | |
"νν": [ | |
"무λ₯λ ₯μμΈ μ€ μμλλ° SSSκΈ μμ°μ§", | |
"κ²μ΄νΈ μμμ 10λ , λμμ¨ μ΅κ°μ", | |
"νν° κ³ λ±νκ΅μ μ¨κ²¨μ§ λνΉ 1μ" | |
], | |
"무ν": [ | |
"μ²νμ μΌλ¬Έ λ§λ΄κ° λ§κ΅ κ΅μ£Ό μ μκ° λλ€", | |
"100λ μ μΌλ‘ νκ·ν νμ°ν μ₯λ¬ΈμΈ", | |
"νκΈ λ¬΄κ³΅μΌλ‘ μ²νλ₯Ό μ ν¨νλ€" | |
], | |
"μ€λ¦΄λ¬": [ | |
"νκ΅μ κ°ν λμ°½ν, ν λͺ μ© μ¬λΌμ§λ€", | |
"νμ루ν μ μ°μμ΄μΈλ² μ°ΎκΈ°", | |
"λ΄ λ¨νΈμ΄ μ¬μ΄μ½ν¨μ€μλ€" | |
], | |
"μΌμ": [ | |
"νΈμμ μλ°μμ μμν μΌμ", | |
"30λ μ§μ₯μΈμ ν΄μ¬ μ€λΉ μΌκΈ°", | |
"μ°λ¦¬ λλ€ κ³ μμ΄λ€μ λΉλ° νμ" | |
], | |
"κ°κ·Έ": [ | |
"μ΄μΈκ³ μ©μ¬μΈλ° μ€ν―μ΄ μ΄μνλ€", | |
"μ°λ¦¬ νκ΅ μ μλμ μ μ§ λ§μ", | |
"μ’λΉ μν¬μΉΌλ¦½μ€μΈλ° λλ§ κ°κ·Έ μΊλ¦ν°" | |
], | |
"μ€ν¬μΈ ": [ | |
"λ²€μΉ λ©€λ²μμ μμ΄μ€κ° λκΈ°κΉμ§", | |
"μ¬μ μΌκ΅¬λΆ μ°½μ€κΈ°", | |
"μν΄ μ μμ μ½μΉ λμ κΈ°" | |
] | |
} | |
genre_themes = templates.get(genre, templates["λ‘맨μ€"]) | |
return random.choice(genre_themes) | |
# --- UI functions --- | |
def format_planning_display(planning_doc: str) -> str: | |
"""Format planning document for display""" | |
return planning_doc | |
def format_storyboard_display(storyboard: str) -> str: | |
"""Format storyboard for display""" | |
return storyboard | |
# --- Gradio interface --- | |
def create_interface(): | |
with gr.Blocks(theme=gr.themes.Soft(), title="K-Webtoon Storyboard Generator") as interface: | |
gr.HTML(""" | |
<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); | |
} | |
.header-subtitle { | |
font-size: 1.2rem; | |
margin-bottom: 0.5rem; | |
} | |
.header-description { | |
margin-bottom: 1.5rem; | |
} | |
</style> | |
<div class="main-header"> | |
<h1 class="header-title">π¨ K-Webtoon Storyboard Generator</h1> | |
<p class="header-subtitle">νκ΅ν μΉν° κΈ°ν λ° μ€ν 리보λ μλ μμ± μμ€ν </p> | |
<p class="header-description">40ν μ 체 κΈ°νμκ³Ό 1ν μ€ν 리보λ(30ν¨λ)λ₯Ό μμ±ν©λλ€</p> | |
</div> | |
""") | |
# State | |
current_session_id = gr.State(None) | |
with gr.Tab("π¨ μΉν° κΈ°ννκΈ°"): | |
with gr.Group(): | |
gr.Markdown("### π― μΉν° μ€μ ") | |
with gr.Row(): | |
with gr.Column(scale=2): | |
genre_select = gr.Radio( | |
choices=list(WEBTOON_GENRES.keys()), | |
value="λ‘맨μ€", | |
label="μ₯λ₯΄ μ ν", | |
info="μνλ μ₯λ₯΄λ₯Ό μ ννμΈμ" | |
) | |
query_input = gr.Textbox( | |
label="μ€ν 리 μ½μ νΈ", | |
placeholder="μΉν°μ κΈ°λ³Έ μ€μ μ΄λ μ£Όμ λ₯Ό μ λ ₯νμΈμ...", | |
lines=3 | |
) | |
with gr.Row(): | |
random_btn = gr.Button("π² λλ€ ν λ§", variant="secondary") | |
submit_btn = gr.Button("π κΈ°ν μμ", variant="primary", size="lg") | |
with gr.Column(scale=1): | |
language_select = gr.Radio( | |
choices=["Korean", "English"], | |
value="Korean", | |
label="μΈμ΄" | |
) | |
gr.Markdown(""" | |
**μ₯λ₯΄λ³ νΉμ§:** | |
- λ‘맨μ€: κ°μ μ , κ΄κ³ μ€μ¬ | |
- λ‘ν: νκ·/λΉμ ννμ§ | |
- ννμ§: λͺ¨νκ³Ό μ±μ₯ | |
- νν: νλ λ°°κ²½ λ₯λ ₯λ¬Ό | |
- 무ν: 무곡과 볡μ | |
- μ€λ¦΄λ¬: μμ€νμ€μ λ°μ | |
- μΌμ: 곡κ°κ³Ό νλ§ | |
- κ°κ·Έ: μμκ³Ό ν¨λ¬λ | |
- μ€ν¬μΈ : μ΄μ κ³Ό μ±μ₯ | |
""") | |
status_text = gr.Textbox( | |
label="μ§ν μν©", | |
interactive=False, | |
value="μ₯λ₯΄λ₯Ό μ ννκ³ μ½μ νΈλ₯Ό μ λ ₯νμΈμ" | |
) | |
# Output | |
with gr.Row(): | |
with gr.Column(): | |
planning_display = gr.Markdown("*κΈ°νμμ΄ μ¬κΈ°μ νμλ©λλ€*") | |
with gr.Column(): | |
storyboard_display = gr.Markdown("*1ν μ€ν 리보λκ° μ¬κΈ°μ νμλ©λλ€*") | |
with gr.Row(): | |
download_format = gr.Radio( | |
choices=["TXT", "DOCX"], | |
value="TXT", | |
label="λ€μ΄λ‘λ νμ" | |
) | |
download_btn = gr.Button("π₯ λ€μ΄λ‘λ", variant="secondary") | |
download_file = gr.File(visible=False) | |
with gr.Tab("π μ½μ νΈ μμ"): | |
gr.Markdown(""" | |
### μ₯λ₯΄λ³ μΈκΈ° μ½μ νΈ | |
**λ‘맨μ€** | |
- κ³μ½κ²°νΌν μ¬λ²κ³Ό νλ²ν νμ¬μ | |
- 첫μ¬λκ³Όμ μ΄λͺ μ μ¬ν | |
- λΌμ΄λ²μμ μ°μΈμ΄ λκΈ°κΉμ§ | |
**ννμ§** | |
- FκΈμμ SSSκΈμΌλ‘ κ°μ± | |
- νκ·μμ λ λ²μ§Έ κΈ°ν | |
- λ²κ·Έ μΊλ¦ν°μ μΈκ³ μ 볡 | |
**νν** | |
- μ¨κ²¨μ§ μ΅κ°μμ μΌμ | |
- κ²μ΄νΈμ νν°μ μΈκ³ | |
- κ°μ±μ νκ΅μ λΉλ° | |
**무ν** | |
- μ²μ¬μ λͺ°λ½κ³Ό λΆν | |
- μ νμ λ§κ΅μ κ²½κ³ | |
- μ μ€μ λΉκΈμ μ»λ€ | |
""") | |
# Event handlers | |
def process_query(query, genre, language, session_id): | |
system = WebtoonSystem() | |
planning = "" | |
storyboard = "" | |
for planning_content, storyboard_content, status, new_session_id in system.process_webtoon_stream(query, genre, language, session_id): | |
# Split the combined output if it contains both planning and storyboard | |
if '1ν μ€ν 리보λ' in storyboard_content: | |
parts = storyboard_content.split('=' * 50) | |
if len(parts) >= 2: | |
planning = parts[0] | |
storyboard = ('=' * 50) + ('=' * 50).join(parts[1:]) | |
else: | |
planning = planning_content | |
storyboard = storyboard_content | |
else: | |
planning = planning_content | |
storyboard = storyboard_content | |
yield planning, storyboard, status, new_session_id | |
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} μΉν°" | |
if download_format == "TXT": | |
content = export_to_txt(planning, storyboard, genre, title) | |
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', | |
suffix='.txt', delete=False) as f: | |
f.write(content) | |
return f.name | |
elif download_format == "DOCX": | |
if not DOCX_AVAILABLE: | |
gr.Warning("DOCX export requires python-docx library") | |
return None | |
content = export_to_docx(planning, storyboard, genre, title) | |
with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx', | |
delete=False) as f: | |
f.write(content) | |
return f.name | |
except Exception as e: | |
logger.error(f"Download error: {e}") | |
gr.Warning(f"λ€μ΄λ‘λ μ€ μ€λ₯ λ°μ: {str(e)}") | |
return None | |
# Connect events | |
submit_btn.click( | |
fn=process_query, | |
inputs=[query_input, genre_select, language_select, current_session_id], | |
outputs=[planning_display, storyboard_display, status_text, current_session_id] | |
) | |
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_display, storyboard_display, genre_select], | |
outputs=[download_file] | |
).then( | |
fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False), | |
inputs=[download_file], | |
outputs=[download_file] | |
) | |
# Examples | |
gr.Examples( | |
examples=[ | |
["μ¬λ² 3μΈ μμ¬μ μ μ μ¬μμ λΉλ° κ³μ½μ°μ ", "λ‘맨μ€"], | |
["μ λ λ‘ λΉμνλλ° 1λ ν μ²ν μμ ", "λ‘ν"], | |
["FκΈ νν°κ° SSSκΈ λ€ν¬λ‘맨μλ‘ κ°μ±", "ννμ§"], | |
["무λ₯λ ₯μμΈ μ€ μμλλ° SSSκΈ μμ°μ§", "νν"], | |
["μ²νμ μΌλ¬Έ λ§λ΄κ° λ§κ΅ κ΅μ£Ό μ μκ° λλ€", "무ν"], | |
["νκ΅μ κ°ν λμ°½ν, ν λͺ μ© μ¬λΌμ§λ€", "μ€λ¦΄λ¬"], | |
["νΈμμ μλ°μμ μμν μΌμ", "μΌμ"], | |
["μ΄μΈκ³ μ©μ¬μΈλ° μ€ν―μ΄ μ΄μνλ€", "κ°κ·Έ"], | |
["λ²€μΉ λ©€λ²μμ μμ΄μ€κ° λκΈ°κΉμ§", "μ€ν¬μΈ "] | |
], | |
inputs=[query_input, genre_select] | |
) | |
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())) | |
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 | |
) |