emailvenky commited on
Commit
4b19c49
·
verified ·
1 Parent(s): b41359f

Upload 27 files

Browse files
.env.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenAI API Key
2
+ OPENAI_API_KEY=sk-your-key-here
3
+
4
+ # OpenAI Model (optional, defaults to gpt-4o-mini)
5
+ # Options: gpt-4o-mini, gpt-3.5-turbo, gpt-4, gpt-4-turbo
6
+ OPENAI_MODEL=gpt-4o-mini
7
+
8
+ HUGGINGFACE_TOKEN=hf_your-token-here
9
+ ELEVENLABS_API_KEY=your-key-here
10
+ GEMINI_API_KEY=your-key-here
11
+ MODAL_TOKEN=your-token-here
12
+ SAMBANOVA_API_KEY=your-key-here
13
+ NEBIUS_API_KEY=your-key-here
14
+ BLAXEL_API_KEY=blx_your-key-here
15
+
16
+ # Comic Mode Feature Flags (Step 1)
17
+ ENABLE_COMIC_MODE=false
18
+ ENABLE_BLAXEL=false
19
+ ENABLE_MODAL=false
20
+ ENABLE_SAMBANOVA=false
.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.so
8
+ *.egg
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ venv/
13
+ env/
14
+ .venv/
15
+ gradio_cached_examples/
16
+ flagged/
17
+ *.log
18
+ .DS_Store
19
+
20
+
21
+ .venvCartoonProject/
22
+
23
+
README.md CHANGED
@@ -1,14 +1,152 @@
1
  ---
2
- title: Mythforge Comic Generator
3
- emoji: 🦀
4
- colorFrom: purple
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.0.1
8
  app_file: app.py
9
  pinned: false
10
- license: apache-2.0
11
- short_description: Transform text into professional manga comics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MythForge AI Comic Generator
3
+ emoji: 📚
4
+ colorFrom: blue
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.0.0
8
  app_file: app.py
9
  pinned: false
10
+ tags:
11
+ - mcp-in-action-track-creative
12
+ - mcp-in-action-track-consumer
13
+ - comic-generation
14
+ - manga
15
+ - ai-art
16
+ - storytelling
17
+ - multimodal
18
+ - gradio
19
+ - mcp
20
+ - agents
21
+ - openai
22
+ - gemini
23
+ - modal
24
+ - elevenlabs
25
+ - llamaindex
26
+ - blaxel
27
+ - anthropic
28
+ - sambanova
29
  ---
30
 
31
+ # 📚 MythForge AI Comic Generator
32
+
33
+ **Create professional manga-style comics with AI-powered storytelling, image generation, and voice narration!**
34
+
35
+ > 🎉 **Submission for MCP's 1st Birthday Hackathon - Track 2: MCP in Action (Creative)**
36
+
37
+ ## 🎥 Demo Video
38
+
39
+ [Add your demo video link here - YouTube, Loom, or direct upload]
40
+
41
+ ## 📢 Social Media
42
+
43
+ [Add your social media post link here - Twitter/X, LinkedIn, etc.]
44
+
45
+ ## ✨ What It Does
46
+
47
+ MythForge AI transforms your story ideas into complete 6-panel manga comics with:
48
+ - **AI Story Generation**: GPT-4o-mini crafts engaging narratives with character development
49
+ - **Character Consistency**: Gemini Pro ensures characters look the same across all panels
50
+ - **Professional Art**: SDXL generates high-quality 1024×1024 manga-style images
51
+ - **Voice Narration**: ElevenLabs provides diverse character voices for each panel
52
+ - **Character Memory**: **MCP-powered LlamaIndex** maintains character consistency across stories
53
+ - **Multiple Export Formats**: PDF, ZIP package, or interactive web viewer
54
+
55
+ ## 🤖 MCP Integration
56
+
57
+ This application demonstrates **MCP in Action** by using the Model Context Protocol for:
58
+ - **Character Memory & RAG**: LlamaIndex MCP server maintains persistent character profiles and story context
59
+ - **Autonomous Agent Behavior**: The system autonomously plans story arcs, maintains character consistency, and orchestrates multiple AI services
60
+ - **Multi-Provider Orchestration**: Blaxel framework coordinates between OpenAI, Gemini, Modal, and ElevenLabs
61
+
62
+ ## 🏗️ Architecture
63
+
64
+ ```
65
+ User Prompt → Story Generation (GPT-4o-mini + SambaNova fallback)
66
+
67
+ Character Analysis (Gemini 2.5 Flash) → MCP Character Memory (LlamaIndex)
68
+
69
+ Image Generation (Modal SDXL) → Character Consistency Check (Gemini Pro)
70
+
71
+ Audio Generation (ElevenLabs TTS)
72
+
73
+ Comic Assembly (2x3 Grid Layout)
74
+ ```
75
+
76
+ ## 🛠️ Built With
77
+
78
+ - **OpenAI**: GPT-4o-mini (Primary Story Generation), GPT-4o (Dynamic Prompt Examples)
79
+ - **Gemini**: 3 Pro Image (Character Consistency), 2.5 Flash (Vision), 2.0 Flash (Intelligence)
80
+ - **Modal**: SDXL (Panel Image Generation)
81
+ - **ElevenLabs**: TTS with 9 Diverse Voices
82
+ - **LlamaIndex**: Character Memory & RAG via MCP
83
+ - **Blaxel**: Multi-Provider Orchestration Framework
84
+ - **Claude Code**: Autonomous Development & Debugging
85
+ - **SambaNova**: Llama 3.3 70B (Story Fallback)
86
+ - **Gradio**: v6 UI Framework
87
+
88
+ ## 🚀 Features
89
+
90
+ ### Story Creation
91
+ - Dynamic prompt examples generated by GPT-4o
92
+ - Multiple visual styles (anime, manga, comic, webcomic)
93
+ - Optional speech bubbles and audio narration
94
+
95
+ ### Character Consistency
96
+ - MCP-powered character memory across multiple stories
97
+ - Gemini Pro vision analysis for visual consistency
98
+ - Persistent character profiles using LlamaIndex RAG
99
+
100
+ ### Professional Output
101
+ - 2148×3192 full manga page (2x3 grid)
102
+ - Individual 1024×1024 panel images
103
+ - Synchronized audio narration for each panel
104
+ - Export as PDF, ZIP, or interactive HTML
105
+
106
+ ## 📦 Installation
107
+
108
+ ```bash
109
+ # Clone the repository
110
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/mythforge-comic-generator
111
+
112
+ # Install dependencies
113
+ pip install -r requirements.txt
114
+
115
+ # Set up environment variables
116
+ cp .env.example .env
117
+ # Add your API keys to .env
118
+ ```
119
+
120
+ ## 🔑 Required API Keys
121
+
122
+ - `OPENAI_API_KEY`: For GPT-4o story generation
123
+ - `GEMINI_API_KEY`: For character consistency analysis
124
+ - `ELEVENLABS_API_KEY`: For voice narration
125
+ - `MODAL_TOKEN_ID` & `MODAL_TOKEN_SECRET`: For SDXL image generation
126
+
127
+ ## 💡 Usage
128
+
129
+ 1. Enter your story idea or use AI-generated examples
130
+ 2. Select your preferred visual style
131
+ 3. Choose whether to include audio and speech bubbles
132
+ 4. Click "Generate Comic"
133
+ 5. Download your comic in your preferred format
134
+
135
+ ## 🎯 MCP Use Case
136
+
137
+ This project showcases MCP's power in creative applications by:
138
+ - **Context Engineering**: Maintaining character profiles and story context across sessions
139
+ - **Autonomous Planning**: Orchestrating multiple AI services without manual intervention
140
+ - **Tool Integration**: Using MCP servers as intelligent tools for character memory
141
+
142
+ ## 👥 Team
143
+
144
+ - [Your HuggingFace Username]
145
+
146
+ ## 📄 License
147
+
148
+ [Specify your license - MIT, Apache 2.0, etc.]
149
+
150
+ ## 🙏 Acknowledgments
151
+
152
+ Built for MCP's 1st Birthday Hackathon hosted by Anthropic and Gradio.
app.py CHANGED
@@ -1,7 +1,19 @@
1
- import gradio as gr
 
 
2
 
3
- def greet(name):
4
- return "Hello " + name + "!!"
 
5
 
6
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MythForge AI - Comic Generator
3
+ Hugging Face Spaces Entry Point
4
 
5
+ This file imports and launches the Gradio app from app_comic.py
6
+ Required for Hugging Face Spaces deployment (must be named app.py)
7
+ """
8
 
9
+ from app_comic import demo
10
+
11
+ if __name__ == "__main__":
12
+ print("📚 Starting MythForge Comic Generator (Hugging Face Spaces)...")
13
+
14
+ # Launch with Hugging Face Spaces compatible settings
15
+ demo.launch(
16
+ server_name="0.0.0.0",
17
+ server_port=7860, # Default Hugging Face port
18
+ share=False
19
+ )
app_comic.py ADDED
@@ -0,0 +1,1192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MythForge AI - Comic Generator Application (Gradio 6)"""
2
+ import gradio as gr
3
+ import asyncio
4
+ import tempfile
5
+ import os
6
+ import json
7
+ from datetime import datetime
8
+ from typing import Dict, List, Optional
9
+ from PIL import Image
10
+
11
+ from config import Config
12
+ from sambanova_generator import SambanovaGenerator
13
+ from audio_generator import AudioGenerator
14
+ from comic_layout import ComicLayout
15
+ from speech_bubble_overlay import SpeechBubbleOverlay
16
+ from export_service import ExportService
17
+ from modal_image_generator import ModalImageGenerator
18
+
19
+ # New sponsor integrations
20
+ try:
21
+ from story_memory import StoryMemory
22
+ LLAMAINDEX_AVAILABLE = True
23
+ except ImportError:
24
+ LLAMAINDEX_AVAILABLE = False
25
+ print("⚠️ LlamaIndex not available - character memory disabled")
26
+
27
+ try:
28
+ from multimodal_analyzer import MultimodalAnalyzer
29
+ GEMINI_AVAILABLE = True
30
+ except ImportError:
31
+ GEMINI_AVAILABLE = False
32
+ print("⚠️ Gemini analyzer not available - image verification disabled")
33
+
34
+ try:
35
+ from blaxel_agent import BlaxelComicAgent
36
+ BLAXEL_AVAILABLE = True
37
+ except ImportError:
38
+ BLAXEL_AVAILABLE = False
39
+ print("⚠️ Blaxel agent not available - orchestration disabled")
40
+
41
+ # MCP Memory Client (optional, for memory operations via MCP)
42
+ try:
43
+ from mythforge_mcp_memory_client import get_mcp_memory_client
44
+ MCP_MEMORY_AVAILABLE = True
45
+ except ImportError:
46
+ MCP_MEMORY_AVAILABLE = False
47
+ print("⚠️ MCP Memory Client not available - using direct LlamaIndex")
48
+
49
+ # Feature flags
50
+ ENABLE_COMIC_MODE = Config.ENABLE_COMIC_MODE
51
+ ENABLE_SAMBANOVA = Config.ENABLE_SAMBANOVA
52
+ ENABLE_MODAL = Config.ENABLE_MODAL
53
+
54
+
55
+ class ComicGeneratorApp:
56
+ """Comic Generator Gradio Application"""
57
+
58
+ def __init__(self):
59
+ """Initialize comic generator components"""
60
+ # Core generators
61
+ self.sambanova_gen = SambanovaGenerator() if ENABLE_SAMBANOVA else None
62
+ self.modal_gen = ModalImageGenerator() if ENABLE_MODAL else None
63
+ self.audio_gen = AudioGenerator()
64
+ self.layout = ComicLayout()
65
+ self.bubble_overlay = SpeechBubbleOverlay()
66
+ self.export_service = ExportService()
67
+
68
+ # New sponsor integrations
69
+ # Use MCP for memory if enabled, otherwise use direct LlamaIndex
70
+ self.mcp_memory_client = None
71
+ if Config.ENABLE_MCP and MCP_MEMORY_AVAILABLE:
72
+ self.mcp_memory_client = get_mcp_memory_client()
73
+ if self.mcp_memory_client:
74
+ print("📡 Using MCP for story memory operations")
75
+ self.story_memory = None # Don't use direct LlamaIndex
76
+ else:
77
+ # Fallback to direct LlamaIndex
78
+ self.story_memory = StoryMemory() if LLAMAINDEX_AVAILABLE else None
79
+ else:
80
+ # Use direct LlamaIndex (existing behavior)
81
+ self.story_memory = StoryMemory() if LLAMAINDEX_AVAILABLE else None
82
+
83
+ self.multimodal_analyzer = MultimodalAnalyzer() if GEMINI_AVAILABLE else None
84
+ self.blaxel_agent = BlaxelComicAgent() if BLAXEL_AVAILABLE else None
85
+
86
+ # OpenAI fallback (uses existing dependency)
87
+ try:
88
+ from openai import OpenAI
89
+ self.openai_client = OpenAI(api_key=Config.OPENAI_API_KEY)
90
+ self.openai_available = True
91
+ except Exception as e:
92
+ print(f"⚠️ OpenAI not available: {e}")
93
+ self.openai_client = None
94
+ self.openai_available = False
95
+
96
+ async def generate_comic_async(
97
+ self,
98
+ prompt: str,
99
+ style: str = "manga",
100
+ include_audio: bool = True,
101
+ include_bubbles: bool = True,
102
+ progress=None
103
+ ):
104
+ """Generate complete comic with all features"""
105
+
106
+ if not prompt or len(prompt.strip()) < 10:
107
+ raise ValueError("Please enter a story prompt (at least 10 characters)")
108
+
109
+ # Extract style name if it contains description (e.g., "manga - Japanese...")
110
+ if " - " in style:
111
+ style = style.split(" - ")[0].strip()
112
+
113
+ outputs = {
114
+ "story_data": None,
115
+ "panel_images": [],
116
+ "manga_page": None,
117
+ "audio_files": [],
118
+ "metadata": {}
119
+ }
120
+
121
+ try:
122
+ # Fork: Use Blaxel workflow orchestration or direct API calls
123
+ if Config.ENABLE_BLAXEL_WORKFLOW and self.blaxel_agent:
124
+ print("🔀 Using Blaxel workflow orchestration...")
125
+ try:
126
+ return await self._generate_with_blaxel_workflow(
127
+ prompt=prompt,
128
+ style=style,
129
+ include_audio=include_audio,
130
+ include_bubbles=include_bubbles,
131
+ progress=progress
132
+ )
133
+ except Exception as e:
134
+ print(f"⚠️ Blaxel workflow failed: {e}")
135
+ print("🔄 Falling back to direct API calls...")
136
+ # Continue with direct API calls below
137
+
138
+ # Step 1: Generate story with SambaNova (or fallback)
139
+ if progress:
140
+ progress(0.1, desc="✍️ Writing story with AI...")
141
+
142
+ # Retrieve context from LlamaIndex memory for character consistency
143
+ context_prompt = prompt
144
+ if self.mcp_memory_client:
145
+ # Use MCP for memory retrieval
146
+ try:
147
+ context = self.mcp_memory_client.retrieve_context_sync(prompt, top_k=3)
148
+ if context and context != "No stories in memory yet." and "Error" not in context:
149
+ context_prompt = f"{prompt}\n\nPrevious story context:\n{context}"
150
+ print(f"📚 Retrieved character context via MCP")
151
+ except Exception as e:
152
+ print(f"⚠️ MCP memory retrieval failed: {e}")
153
+ context_prompt = prompt
154
+ elif self.story_memory:
155
+ # Use direct LlamaIndex (existing behavior)
156
+ try:
157
+ context = self.story_memory.retrieve_context(prompt, top_k=3)
158
+ if context and context != "No stories in memory yet.":
159
+ context_prompt = f"{prompt}\n\nPrevious story context:\n{context}"
160
+ print(f"📚 Retrieved character context from LlamaIndex")
161
+ except Exception as e:
162
+ print(f"⚠️ Memory retrieval failed: {e}")
163
+ context_prompt = prompt
164
+
165
+
166
+ # Adaptive text generation: OpenAI first (for $1k prize) or SambaNova first
167
+ story_data = None
168
+
169
+ if Config.USE_OPENAI_PRIMARY:
170
+ # Primary: OpenAI GPT-4o-mini ($1,000 API credits prize)
171
+ if self.openai_available:
172
+ try:
173
+ print("🎯 Generating story with OpenAI GPT-4o-mini...")
174
+ story_data = await self._generate_with_openai(context_prompt, style)
175
+ print("✅ Story generated with OpenAI")
176
+ except Exception as e:
177
+ print(f"⚠️ OpenAI failed: {e}")
178
+ story_data = None
179
+
180
+ # Fallback: SambaNova
181
+ if not story_data and self.sambanova_gen and ENABLE_SAMBANOVA:
182
+ try:
183
+ print("🔄 Falling back to SambaNova...")
184
+ story_data = self.sambanova_gen.generate_comic_story(
185
+ prompt=context_prompt,
186
+ panel_count=6,
187
+ style=style
188
+ )
189
+ print("✅ Story generated with SambaNova")
190
+ except Exception as e:
191
+ print(f"⚠️ SambaNova fallback failed: {e}")
192
+ story_data = None
193
+ else:
194
+ # Primary: SambaNova Llama 3.3 70B
195
+ if self.sambanova_gen and ENABLE_SAMBANOVA:
196
+ try:
197
+ print("🎯 Generating story with SambaNova Llama 3.3 70B...")
198
+ story_data = self.sambanova_gen.generate_comic_story(
199
+ prompt=context_prompt,
200
+ panel_count=6,
201
+ style=style
202
+ )
203
+ print("✅ Story generated with SambaNova")
204
+ except Exception as e:
205
+ print(f"⚠️ SambaNova failed: {e}")
206
+ story_data = None
207
+
208
+ # Fallback: OpenAI
209
+ if not story_data and self.openai_available:
210
+ try:
211
+ print("🔄 Falling back to OpenAI GPT-4o-mini...")
212
+ story_data = await self._generate_with_openai(context_prompt, style)
213
+ print("✅ Story generated with OpenAI")
214
+ except Exception as e:
215
+ print(f"⚠️ OpenAI fallback failed: {e}")
216
+ story_data = None
217
+
218
+ # Final fallback: Simple placeholder
219
+ if not story_data:
220
+ print("⚠️ Using placeholder story")
221
+ story_data = self._create_fallback_story(prompt, style)
222
+
223
+ outputs["story_data"] = story_data
224
+ outputs["metadata"] = {
225
+ "title": story_data.get("title", "MythForge Comic"),
226
+ "prompt": prompt,
227
+ "style": style,
228
+ "timestamp": datetime.now().isoformat(),
229
+ "panel_count": len(story_data["panels"])
230
+ }
231
+
232
+ # Step 2: Generate panel images with Modal SDXL
233
+ if progress:
234
+ progress(0.3, desc="🎨 Generating panel artwork with SDXL...")
235
+
236
+ # Use Modal SDXL if enabled, otherwise use placeholders
237
+ if self.modal_gen and self.modal_gen.enabled:
238
+ panel_images = await self.modal_gen.generate_comic_panels(
239
+ panels_data=story_data["panels"],
240
+ style=style,
241
+ num_inference_steps=25
242
+ )
243
+ else:
244
+ if progress:
245
+ progress(0.3, desc="🎨 Creating placeholder panels...")
246
+ panel_images = self._create_placeholder_panels(story_data["panels"])
247
+
248
+ outputs["panel_images"] = panel_images
249
+
250
+ # Step 2.5: Verify image quality with Gemini (if enabled)
251
+ if self.multimodal_analyzer and self.multimodal_analyzer.enabled:
252
+ if progress:
253
+ progress(0.4, desc="🔍 Verifying panel quality with Gemini...")
254
+ try:
255
+ quality_scores = []
256
+ for i, panel_img in enumerate(panel_images):
257
+ score = await self.multimodal_analyzer.analyze_panel_quality(
258
+ panel_img,
259
+ story_data["panels"][i]
260
+ )
261
+ quality_scores.append(score)
262
+
263
+ avg_quality = sum(quality_scores) / len(quality_scores) if quality_scores else 0
264
+ print(f"✅ Gemini quality score: {avg_quality:.2f}/10")
265
+ outputs["quality_score"] = avg_quality
266
+ except Exception as e:
267
+ print(f"⚠️ Gemini verification failed: {e}")
268
+
269
+ # Step 3: Add speech bubbles
270
+ if include_bubbles and progress:
271
+ progress(0.5, desc="💬 Adding speech bubbles...")
272
+
273
+ if include_bubbles:
274
+ panel_images_with_bubbles = []
275
+ for i, (panel_img, panel_data) in enumerate(zip(panel_images, story_data["panels"])):
276
+ dialogue = panel_data.get("dialogue", "")
277
+ character = panel_data.get("character_name", "")
278
+
279
+ if dialogue:
280
+ # Determine position based on panel number
281
+ position = "top" if i < 2 else ("middle" if i < 4 else "bottom")
282
+ panel_with_bubble = self.bubble_overlay.add_dialogue_to_panel(
283
+ panel_img.copy(),
284
+ dialogue,
285
+ character,
286
+ position
287
+ )
288
+ panel_images_with_bubbles.append(panel_with_bubble)
289
+ else:
290
+ panel_images_with_bubbles.append(panel_img)
291
+
292
+ panel_images = panel_images_with_bubbles
293
+ outputs["panel_images"] = panel_images
294
+
295
+ # Step 4: Create manga layout
296
+ if progress:
297
+ progress(0.6, desc="📖 Creating manga page layout...")
298
+
299
+ manga_page = self.layout.create_manga_page(panel_images, layout="2x3")
300
+ outputs["manga_page"] = manga_page
301
+
302
+ # Step 5: Generate audio (intro + panels + conclusion)
303
+ if include_audio and progress:
304
+ progress(0.8, desc="🎤 Generating audio tracks...")
305
+
306
+ if include_audio:
307
+ # Create persistent temp directory for audio files (prefix to avoid cleanup)
308
+ temp_dir = tempfile.mkdtemp(prefix="mythforge_audio_")
309
+
310
+ # Generate introduction track (Track 0)
311
+ intro_path = f"{temp_dir}/intro_audio.mp3"
312
+ characters = list(set([p.get("character_name", "Character") for p in story_data["panels"] if p.get("character_name")]))
313
+ await self.audio_gen.generate_introduction(
314
+ story_prompt=prompt,
315
+ story_title=story_data.get("title", "Comic Story"),
316
+ characters=characters,
317
+ output_path=intro_path
318
+ )
319
+ print(f"✅ Generated introduction audio: {intro_path}")
320
+
321
+ # Generate panel audio (Tracks 1-6)
322
+ audio_files = await self.audio_gen.generate_comic_audio(
323
+ panels=story_data["panels"],
324
+ output_dir=temp_dir
325
+ )
326
+
327
+ # Generate conclusion track (Track 7)
328
+ conclusion_path = f"{temp_dir}/conclusion_audio.mp3"
329
+ await self.audio_gen.generate_conclusion(
330
+ story_title=story_data.get("title", "Comic Story"),
331
+ panels=story_data["panels"],
332
+ output_path=conclusion_path
333
+ )
334
+ print(f"✅ Generated conclusion audio: {conclusion_path}")
335
+
336
+ # Combine all audio: intro + panels + conclusion
337
+ all_audio_files = [intro_path] + audio_files + [conclusion_path]
338
+
339
+ # Store the temp directory path so it persists
340
+ outputs["audio_temp_dir"] = temp_dir
341
+ outputs["audio_files"] = all_audio_files
342
+ outputs["intro_audio"] = intro_path
343
+ outputs["conclusion_audio"] = conclusion_path
344
+
345
+ # Step 6: Store story in LlamaIndex memory for future context
346
+ if self.mcp_memory_client:
347
+ # Use MCP for memory storage
348
+ try:
349
+ # Convert story_data to text for storage
350
+ story_text = f"Title: {story_data.get('title', 'Untitled')}\n\n"
351
+ for panel in story_data.get("panels", []):
352
+ story_text += f"Panel {panel.get('panel_number', '?')}: "
353
+ story_text += f"{panel.get('scene_description', '')} "
354
+ story_text += f"{panel.get('dialogue', '')}\n"
355
+
356
+ self.mcp_memory_client.store_story_sync(
357
+ story_text=story_text,
358
+ metadata={"prompt": prompt, "style": style}
359
+ )
360
+ print("✅ Story stored via MCP for future context")
361
+ except Exception as e:
362
+ print(f"⚠️ MCP memory storage failed: {e}")
363
+ elif self.story_memory:
364
+ # Use direct LlamaIndex (existing behavior)
365
+ try:
366
+ # Convert story_data to text for storage
367
+ story_text = f"Title: {story_data.get('title', 'Untitled')}\n\n"
368
+ for panel in story_data.get("panels", []):
369
+ story_text += f"Panel {panel.get('panel_number', '?')}: "
370
+ story_text += f"{panel.get('scene_description', '')} "
371
+ story_text += f"{panel.get('dialogue', '')}\n"
372
+
373
+ self.story_memory.store_story(
374
+ story_text=story_text,
375
+ metadata={"prompt": prompt, "style": style}
376
+ )
377
+ print("✅ Story stored in LlamaIndex memory for future context")
378
+ except Exception as e:
379
+ print(f"⚠️ Memory storage failed: {e}")
380
+
381
+ if progress:
382
+ progress(1.0, desc="✅ Comic complete!")
383
+
384
+ return outputs
385
+
386
+ except Exception as e:
387
+ print(f"Error generating comic: {e}")
388
+ raise
389
+
390
+ def _create_fallback_story(self, prompt: str, style: str):
391
+ """Create fallback story data when SambaNova unavailable"""
392
+ return {
393
+ "title": "Adventure Story",
394
+ "panels": [
395
+ {
396
+ "panel_number": i + 1,
397
+ "scene_description": f"Scene {i+1}: {prompt[:50]}...",
398
+ "dialogue": f"Panel {i+1} dialogue about the adventure",
399
+ "character_name": "Hero" if i % 2 == 0 else "Guide",
400
+ "action": f"Action happening in panel {i+1}"
401
+ }
402
+ for i in range(6)
403
+ ]
404
+ }
405
+
406
+ async def _generate_with_openai(self, prompt: str, style: str) -> Dict:
407
+ """
408
+ Generate comic story using OpenAI GPT-4 as fallback
409
+
410
+ Args:
411
+ prompt: Story prompt with optional LlamaIndex context
412
+ style: Visual style (manga, shonen, etc.)
413
+
414
+ Returns:
415
+ Dict with same structure as SambaNova (title, panels)
416
+ """
417
+ if not self.openai_client:
418
+ raise RuntimeError("OpenAI client not initialized")
419
+
420
+ system_prompt = f"""You are a creative comic story writer specializing in {style} style.
421
+ Generate a complete 6-panel comic story with:
422
+ 1. Engaging narrative suitable for visual storytelling
423
+ 2. Clear scene descriptions for each panel
424
+ 3. Character dialogue in speech bubble format
425
+ 4. Action and emotion cues for illustrations
426
+
427
+ Format your response as JSON with this structure:
428
+ {{
429
+ "title": "Story Title",
430
+ "panels": [
431
+ {{
432
+ "panel_number": 1,
433
+ "scene_description": "Detailed visual description",
434
+ "dialogue": "Character: What they say",
435
+ "action": "What's happening"
436
+ }},
437
+ ...
438
+ ]
439
+ }}"""
440
+
441
+ user_prompt = f"""Create a 6-panel {style} comic story based on this prompt:
442
+
443
+ "{prompt}"
444
+
445
+ Make it visually dynamic with clear scenes for each panel. Include character dialogue."""
446
+
447
+ try:
448
+ response = self.openai_client.chat.completions.create(
449
+ model="gpt-4o-mini",
450
+ messages=[
451
+ {"role": "system", "content": system_prompt},
452
+ {"role": "user", "content": user_prompt}
453
+ ],
454
+ temperature=0.8,
455
+ max_tokens=2000,
456
+ response_format={"type": "json_object"}
457
+ )
458
+
459
+ # Parse JSON response
460
+ content = response.choices[0].message.content
461
+ story_data = json.loads(content)
462
+
463
+ # Ensure all panels have required fields
464
+ for panel in story_data.get("panels", []):
465
+ if "character_name" not in panel:
466
+ # Extract character name from dialogue
467
+ dialogue = panel.get("dialogue", "")
468
+ if ":" in dialogue:
469
+ panel["character_name"] = dialogue.split(":")[0].strip()
470
+ else:
471
+ panel["character_name"] = "Narrator"
472
+
473
+ print(f"💰 OpenAI cost: ~${response.usage.total_tokens * 0.0000015:.4f}")
474
+ return story_data
475
+
476
+ except Exception as e:
477
+ raise RuntimeError(f"OpenAI generation failed: {e}")
478
+
479
+ async def _generate_with_blaxel_workflow(
480
+ self,
481
+ prompt: str,
482
+ style: str,
483
+ include_audio: bool,
484
+ include_bubbles: bool,
485
+ progress=None
486
+ ) -> Dict:
487
+ """
488
+ Generate comic using Blaxel workflow orchestration
489
+
490
+ This method uses Blaxel to orchestrate all AI services (SambaNova, Modal, ElevenLabs)
491
+ in a single workflow, providing unified cost tracking and error handling.
492
+
493
+ Args:
494
+ prompt: User's story prompt
495
+ style: Visual style
496
+ include_audio: Whether to generate audio
497
+ include_bubbles: Whether to add speech bubbles
498
+ progress: Gradio progress callback
499
+
500
+ Returns:
501
+ Complete outputs dict with story, images, audio, etc.
502
+ """
503
+ if not self.blaxel_agent:
504
+ raise RuntimeError("Blaxel agent not initialized")
505
+
506
+ if progress:
507
+ progress(0.1, desc="🔀 Orchestrating workflow with Blaxel...")
508
+
509
+ # Call Blaxel orchestration
510
+ result = await self.blaxel_agent.generate_comic_orchestrated(
511
+ prompt=prompt,
512
+ style=style,
513
+ include_audio=include_audio
514
+ )
515
+
516
+ # Extract results from Blaxel response
517
+ story_generation = result.get("story_generation", {})
518
+ image_generation = result.get("image_generation", {})
519
+ audio_generation = result.get("audio_generation", {})
520
+
521
+ # Convert Blaxel format to our app format
522
+ story_data = story_generation
523
+ panel_images = image_generation.get("images", [])
524
+ audio_files = audio_generation.get("files", []) if include_audio else []
525
+
526
+ if progress:
527
+ progress(0.6, desc="📖 Creating manga layout...")
528
+
529
+ # Create manga layout
530
+ manga_page = self.layout.create_manga_page(panel_images, layout="2x3")
531
+
532
+ # Add speech bubbles if requested
533
+ if include_bubbles:
534
+ if progress:
535
+ progress(0.7, desc="💬 Adding speech bubbles...")
536
+
537
+ panel_images_with_bubbles = []
538
+ for i, (panel_img, panel_data) in enumerate(zip(panel_images, story_data["panels"])):
539
+ dialogue = panel_data.get("dialogue", "")
540
+ character = panel_data.get("character_name", "")
541
+
542
+ if dialogue:
543
+ position = "top" if i < 2 else ("middle" if i < 4 else "bottom")
544
+ panel_with_bubble = self.bubble_overlay.add_dialogue_to_panel(
545
+ panel_img.copy(),
546
+ dialogue,
547
+ character,
548
+ position
549
+ )
550
+ panel_images_with_bubbles.append(panel_with_bubble)
551
+ else:
552
+ panel_images_with_bubbles.append(panel_img)
553
+
554
+ panel_images = panel_images_with_bubbles
555
+
556
+ # Store in LlamaIndex memory
557
+ if self.mcp_memory_client:
558
+ try:
559
+ # Convert story_data to text for storage
560
+ story_text = f"Title: {story_data.get('title', 'Untitled')}\n\n"
561
+ for panel in story_data.get("panels", []):
562
+ story_text += f"Panel {panel.get('panel_number', '?')}: "
563
+ story_text += f"{panel.get('scene_description', '')} "
564
+ story_text += f"{panel.get('dialogue', '')}\n"
565
+
566
+ self.mcp_memory_client.store_story_sync(
567
+ story_text=story_text,
568
+ metadata={"prompt": prompt, "style": style}
569
+ )
570
+ print("✅ Story stored via MCP")
571
+ except Exception as e:
572
+ print(f"⚠️ MCP memory storage failed: {e}")
573
+ elif self.story_memory:
574
+ try:
575
+ await self.story_memory.store_story(
576
+ story_data=story_data,
577
+ prompt=prompt,
578
+ style=style
579
+ )
580
+ print("✅ Story stored in LlamaIndex memory")
581
+ except Exception as e:
582
+ print(f"⚠️ Memory storage failed: {e}")
583
+
584
+ # Print Blaxel cost tracking
585
+ workflow_id = result.get("workflow_id")
586
+ if workflow_id:
587
+ try:
588
+ costs = self.blaxel_agent.get_cost_report(workflow_id)
589
+ print(f"💰 Blaxel total cost: ${costs.get('total_cost', 0):.4f}")
590
+ except:
591
+ pass
592
+
593
+ return {
594
+ "story_data": story_data,
595
+ "panel_images": panel_images,
596
+ "manga_page": manga_page,
597
+ "audio_files": audio_files,
598
+ "metadata": {
599
+ "title": story_data.get("title", "MythForge Comic"),
600
+ "prompt": prompt,
601
+ "style": style,
602
+ "timestamp": datetime.now().isoformat(),
603
+ "panel_count": len(story_data["panels"]),
604
+ "workflow": "blaxel_orchestrated"
605
+ }
606
+ }
607
+
608
+ def _create_placeholder_panels(self, panels_data):
609
+ """Create placeholder panel images (until Modal SDXL integrated)"""
610
+ from PIL import ImageDraw, ImageFont
611
+
612
+ panel_images = []
613
+ colors = [
614
+ (255, 200, 200), # Light red
615
+ (200, 255, 200), # Light green
616
+ (200, 200, 255), # Light blue
617
+ (255, 255, 200), # Light yellow
618
+ (255, 200, 255), # Light magenta
619
+ (200, 255, 255) # Light cyan
620
+ ]
621
+
622
+ for i, panel_data in enumerate(panels_data):
623
+ # Create colored panel with gradient border
624
+ panel = Image.new('RGB', (1024, 1024), color=colors[i])
625
+ draw = ImageDraw.Draw(panel)
626
+
627
+ # Add decorative border
628
+ border_width = 10
629
+ draw.rectangle(
630
+ [border_width, border_width, 1024-border_width, 1024-border_width],
631
+ outline=(0, 0, 0),
632
+ width=border_width
633
+ )
634
+
635
+ # Panel number (large and prominent)
636
+ panel_num_text = f"PANEL {i+1}"
637
+
638
+ # Try to load a larger font, fallback to default
639
+ try:
640
+ # Try to use a system font at larger size
641
+ font_large = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 80)
642
+ font_medium = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 40)
643
+ font_small = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 24)
644
+ except:
645
+ # Fallback to default (will be smaller)
646
+ font_large = ImageFont.load_default()
647
+ font_medium = ImageFont.load_default()
648
+ font_small = ImageFont.load_default()
649
+
650
+ # Draw panel number at top
651
+ bbox = draw.textbbox((512, 150), panel_num_text, font=font_large, anchor='mm')
652
+ draw.rectangle(bbox, fill=(255, 255, 255, 180))
653
+ draw.text((512, 150), panel_num_text, fill=(0, 0, 0), anchor='mm', font=font_large)
654
+
655
+ # Draw scene description (wrapped)
656
+ scene = panel_data.get("scene_description", "Scene description")
657
+ # Wrap text to fit width
658
+ words = scene.split()
659
+ lines = []
660
+ current_line = []
661
+ for word in words:
662
+ current_line.append(word)
663
+ test_line = ' '.join(current_line)
664
+ if len(test_line) > 35: # Roughly 35 chars per line
665
+ if len(current_line) > 1:
666
+ current_line.pop()
667
+ lines.append(' '.join(current_line))
668
+ current_line = [word]
669
+ else:
670
+ lines.append(test_line)
671
+ current_line = []
672
+ if current_line:
673
+ lines.append(' '.join(current_line))
674
+
675
+ # Draw wrapped text
676
+ y_pos = 400
677
+ for line in lines[:4]: # Max 4 lines
678
+ draw.text((512, y_pos), line, fill=(20, 20, 20), anchor='mm', font=font_medium)
679
+ y_pos += 50
680
+
681
+ # Draw dialogue if present
682
+ dialogue = panel_data.get("dialogue", "")
683
+ character = panel_data.get("character_name", "")
684
+ if dialogue:
685
+ # Draw character name
686
+ y_pos = 700
687
+ if character:
688
+ draw.text((512, y_pos), f"💬 {character}:",
689
+ fill=(0, 0, 128), anchor='mm', font=font_small)
690
+ y_pos += 40
691
+
692
+ # Draw dialogue (wrapped)
693
+ dialogue_words = dialogue.split()
694
+ dialogue_lines = []
695
+ current_line = []
696
+ for word in dialogue_words:
697
+ current_line.append(word)
698
+ test_line = ' '.join(current_line)
699
+ if len(test_line) > 40:
700
+ if len(current_line) > 1:
701
+ current_line.pop()
702
+ dialogue_lines.append(' '.join(current_line))
703
+ current_line = [word]
704
+ else:
705
+ dialogue_lines.append(test_line)
706
+ current_line = []
707
+ if current_line:
708
+ dialogue_lines.append(' '.join(current_line))
709
+
710
+ for line in dialogue_lines[:3]: # Max 3 lines
711
+ draw.text((512, y_pos), f'"{line}"',
712
+ fill=(0, 0, 0), anchor='mm', font=font_small)
713
+ y_pos += 35
714
+
715
+ # Add placeholder indicator at bottom
716
+ draw.text((512, 950), "🎨 Placeholder (SDXL pending)",
717
+ fill=(128, 128, 128), anchor='mm', font=font_small)
718
+
719
+ panel_images.append(panel)
720
+
721
+ return panel_images
722
+
723
+ def export_comic(self, outputs, export_format: str):
724
+ """Export comic in requested format"""
725
+ if not outputs or not outputs.get("manga_page"):
726
+ raise ValueError("No comic to export. Generate a comic first.")
727
+
728
+ manga_page = outputs["manga_page"]
729
+ panel_images = outputs["panel_images"]
730
+ audio_files = outputs.get("audio_files", [])
731
+ metadata = outputs.get("metadata", {})
732
+ title = metadata.get("title", "MythForge_Comic").replace(" ", "_")
733
+
734
+ try:
735
+ if export_format == "pdf":
736
+ pdf_bytes = self.export_service.generate_comic_pdf(
737
+ comic_page=manga_page,
738
+ title=metadata.get("title", "MythForge Comic"),
739
+ metadata=metadata
740
+ )
741
+
742
+ # Save to temp file
743
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f:
744
+ f.write(pdf_bytes)
745
+ return f.name
746
+
747
+ elif export_format == "zip":
748
+ zip_bytes = self.export_service.generate_comic_zip(
749
+ comic_page=manga_page,
750
+ panel_images=panel_images,
751
+ audio_files=audio_files,
752
+ title=title,
753
+ metadata=metadata
754
+ )
755
+
756
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
757
+ f.write(zip_bytes)
758
+ return f.name
759
+
760
+ elif export_format == "web":
761
+ web_bytes = self.export_service.generate_web_package(
762
+ comic_page=manga_page,
763
+ audio_files=audio_files,
764
+ title=title,
765
+ metadata=metadata
766
+ )
767
+
768
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
769
+ f.write(web_bytes)
770
+ return f.name
771
+
772
+ except Exception as e:
773
+ print(f"Export error: {e}")
774
+ raise
775
+
776
+
777
+ # Global app instance
778
+ app = ComicGeneratorApp()
779
+
780
+ # Store current comic output for exports
781
+ current_comic = {}
782
+
783
+
784
+ def generate_comic_sync(prompt, style, include_audio, include_bubbles, progress=gr.Progress()):
785
+ """Synchronous wrapper for comic generation"""
786
+ global current_comic
787
+
788
+ try:
789
+ # Run async generation
790
+ outputs = asyncio.run(app.generate_comic_async(
791
+ prompt=prompt,
792
+ style=style,
793
+ include_audio=include_audio,
794
+ include_bubbles=include_bubbles,
795
+ progress=progress
796
+ ))
797
+
798
+ # Store for exports
799
+ current_comic = outputs
800
+
801
+ # Prepare UI outputs
802
+ story_data = outputs["story_data"]
803
+ manga_page = outputs["manga_page"]
804
+ panel_images = outputs["panel_images"]
805
+ audio_files = outputs.get("audio_files", [])
806
+
807
+ # Format story text
808
+ story_text = f"# {story_data.get('title', 'Comic Story')}\n\n"
809
+ for panel in story_data["panels"]:
810
+ story_text += f"**Panel {panel['panel_number']}:**\n"
811
+ story_text += f"*{panel['scene_description']}*\n\n"
812
+ if panel.get('dialogue'):
813
+ story_text += f'"{panel["dialogue"]}" - {panel.get("character_name", "")}\n\n'
814
+
815
+ # Save manga page to temp file for display
816
+ manga_temp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
817
+ manga_page.save(manga_temp.name)
818
+
819
+ # Prepare audio outputs (6 audio players)
820
+ audio_outputs = []
821
+ for i in range(6):
822
+ if i < len(audio_files) and audio_files[i]:
823
+ # Verify file exists
824
+ audio_path = audio_files[i]
825
+ if os.path.exists(audio_path):
826
+ audio_outputs.append(audio_path)
827
+ print(f" Audio {i+1}: {audio_path} ({os.path.getsize(audio_path):,} bytes)")
828
+ else:
829
+ print(f" Audio {i+1}: File not found at {audio_path}")
830
+ audio_outputs.append(None)
831
+ else:
832
+ audio_outputs.append(None)
833
+
834
+ # Status message
835
+ status = f"""✅ **Comic Generated Successfully!**
836
+
837
+ **Panels:** {len(panel_images)}
838
+ **Format:** 2x3 Manga Grid (2148×3192)
839
+ **Audio Tracks:** {len([a for a in audio_files if a])}
840
+ **Style:** {style}
841
+
842
+ Ready to download in PDF, ZIP, or Web Viewer format!"""
843
+
844
+ return (story_text, manga_temp.name, *panel_images, *audio_outputs, status)
845
+
846
+ except Exception as e:
847
+ error_msg = f"❌ **Error:** {str(e)}\n\nPlease check your configuration and try again."
848
+ # Return error + empty outputs
849
+ return (error_msg, None, *[None]*6, *[None]*6, error_msg)
850
+
851
+
852
+ def export_comic_sync(export_format):
853
+ """Export current comic"""
854
+ global current_comic
855
+
856
+ if not current_comic:
857
+ return None, "⚠️ No comic to export. Generate a comic first."
858
+
859
+ try:
860
+ file_path = app.export_comic(current_comic, export_format)
861
+
862
+ format_info = {
863
+ "pdf": "PDF (comic page only)",
864
+ "zip": "ZIP (full package with audio)",
865
+ "web": "Web Viewer (interactive HTML)"
866
+ }
867
+
868
+ message = f"✅ Exported as {format_info.get(export_format, export_format)}!"
869
+ return file_path, message
870
+
871
+ except Exception as e:
872
+ return None, f"❌ Export failed: {str(e)}"
873
+
874
+
875
+ # Generate example prompts using GPT-4o
876
+ def generate_example_prompts():
877
+ """Generate 2 concise example prompts using GPT-4o"""
878
+ try:
879
+ from openai import OpenAI
880
+ client = OpenAI(api_key=Config.OPENAI_API_KEY)
881
+
882
+ response = client.chat.completions.create(
883
+ model="gpt-4o",
884
+ messages=[{
885
+ "role": "system",
886
+ "content": "You are a creative comic story writer. Generate concise, engaging comic prompts."
887
+ }, {
888
+ "role": "user",
889
+ "content": """Generate 2 diverse, creative comic story prompts. Each prompt should be:
890
+ - Concise: 30-40 words (3-4 sentences)
891
+ - Complete story idea with characters, setting, and conflict
892
+ - Visually exciting and comic-worthy
893
+
894
+ Make them diverse genres (e.g., sci-fi and fantasy, or modern and adventure).
895
+ Return only the 2 prompts, one per paragraph. No titles, no extra formatting."""
896
+ }],
897
+ temperature=0.9,
898
+ max_tokens=300
899
+ )
900
+
901
+ examples_text = response.choices[0].message.content
902
+ # Split into 2 examples
903
+ examples = [ex.strip() for ex in examples_text.split("\n\n") if ex.strip()][:2]
904
+ return examples
905
+ except Exception as e:
906
+ print(f"⚠️ Could not generate examples: {e}")
907
+ # Fallback examples - concise 30-40 words each
908
+ return [
909
+ "A young monk discovers ancient scrolls that teach forbidden martial arts. When shadow warriors attack the temple, she must master the techniques before dawn to save her masters and protect the sacred texts.",
910
+
911
+ "In a neon-lit city where music controls technology, a street violinist accidentally awakens a dormant AI. Together they must perform the perfect symphony to prevent a corporate takeover of all sound."
912
+ ]
913
+
914
+
915
+ # Custom CSS for artistic comic style
916
+ comic_css = """
917
+ @import url('https://fonts.googleapis.com/css2?family=Comic+Neue:wght@400;700&family=Poppins:wght@400;600&display=swap');
918
+
919
+ body, .gradio-container {
920
+ font-family: 'Poppins', sans-serif !important;
921
+ background-color: #f9f9f9;
922
+ }
923
+
924
+ h1, h2, h3, h4, h5, h6 {
925
+ font-family: 'Comic Neue', cursive !important;
926
+ font-weight: 700 !important;
927
+ color: #2c3e50;
928
+ }
929
+
930
+ .comic-panel {
931
+ border: 3px solid #2c3e50;
932
+ border-radius: 10px;
933
+ box-shadow: 5px 5px 15px rgba(0,0,0,0.1);
934
+ transition: transform 0.2s;
935
+ overflow: hidden;
936
+ }
937
+
938
+ .comic-panel:hover {
939
+ transform: scale(1.02);
940
+ box-shadow: 8px 8px 20px rgba(0,0,0,0.15);
941
+ }
942
+
943
+ /* Make the generate button pop */
944
+ .comic-button {
945
+ font-family: 'Comic Neue', cursive !important;
946
+ font-weight: 700 !important;
947
+ font-size: 1.2em !important;
948
+ background: linear-gradient(135deg, #ff9966 0%, #ff5e62 100%) !important;
949
+ border: 2px solid #2c3e50 !important;
950
+ box-shadow: 3px 3px 0px #2c3e50 !important;
951
+ color: white !important;
952
+ transition: all 0.2s !important;
953
+ }
954
+
955
+ .comic-button:active {
956
+ transform: translate(2px, 2px);
957
+ box-shadow: 1px 1px 0px #2c3e50 !important;
958
+ }
959
+
960
+ /* Improve text readability */
961
+ .prose {
962
+ font-size: 1.1em !important;
963
+ line-height: 1.6 !important;
964
+ color: #333 !important;
965
+ }
966
+
967
+ /* Custom container styling */
968
+ .group-container {
969
+ background: white;
970
+ padding: 20px;
971
+ border-radius: 15px;
972
+ border: 1px solid #e0e0e0;
973
+ box-shadow: 0 4px 6px rgba(0,0,0,0.05);
974
+ }
975
+
976
+ /* Make download file component prominent */
977
+ .download-file-container {
978
+ background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%) !important;
979
+ padding: 20px !important;
980
+ border-radius: 12px !important;
981
+ border: 3px solid #4caf50 !important;
982
+ box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3) !important;
983
+ margin-top: 15px !important;
984
+ }
985
+
986
+ .download-file-container label {
987
+ font-family: 'Comic Neue', cursive !important;
988
+ font-weight: 700 !important;
989
+ font-size: 1.3em !important;
990
+ color: #2e7d32 !important;
991
+ }
992
+
993
+ .download-file-container a {
994
+ font-size: 1.2em !important;
995
+ font-weight: 600 !important;
996
+ color: #1976d2 !important;
997
+ text-decoration: underline !important;
998
+ }
999
+ """
1000
+
1001
+ # Create Gradio interface
1002
+ with gr.Blocks(title="MythForge AI - Comic Generator") as demo:
1003
+ # Inject custom CSS (Gradio 6 compatibility)
1004
+ gr.HTML(f"<style>{comic_css}</style>")
1005
+
1006
+ gr.Markdown("""
1007
+ <div style="text-align: center;">
1008
+
1009
+ # 📚 MythForge AI - Comic Generator
1010
+
1011
+ ### Generate 6-panel manga comics with AI!
1012
+
1013
+ Powered by OpenAI, Gemini, Modal, ElevenLabs, LlamaIndex, Blaxel, Claude Code, and SambaNova
1014
+
1015
+ </div>
1016
+ """)
1017
+
1018
+ with gr.Row():
1019
+ with gr.Column(scale=1, elem_classes=["group-container"]):
1020
+ gr.Markdown("### 📝 Story Input")
1021
+
1022
+ prompt_input = gr.Textbox(
1023
+ label="Comic Story Prompt",
1024
+ placeholder="Enter your story idea here (draw inspiration from examples below and Click on Generate Comic button)...",
1025
+ lines=4,
1026
+ max_lines=8,
1027
+ max_length=800
1028
+ )
1029
+
1030
+ # Generate example prompts
1031
+ gr.Markdown("#### 💡 Example Story Prompts")
1032
+ example_prompts = generate_example_prompts()
1033
+
1034
+ with gr.Row():
1035
+ for i, example in enumerate(example_prompts):
1036
+ with gr.Column(scale=1):
1037
+ example_display = gr.Textbox(
1038
+ value=example,
1039
+ label=f"Example {i+1}",
1040
+ lines=4,
1041
+ max_lines=4,
1042
+ interactive=True,
1043
+ show_label=True
1044
+ )
1045
+
1046
+ style_input = gr.Dropdown(
1047
+ label="Visual Style",
1048
+ choices=[
1049
+ "anime - Modern Japanese animation with vibrant colors",
1050
+ "cartoon - Playful Western animation with bold outlines",
1051
+ "comic - Classic American comic book with action lines",
1052
+ "manga - Japanese black-and-white comic art style",
1053
+ "shonen - Action-packed manga for young males",
1054
+ "shoujo - Romantic manga for young females",
1055
+ "webcomic - Digital comic optimized for scrolling"
1056
+ ],
1057
+ value="anime - Modern Japanese animation with vibrant colors"
1058
+ )
1059
+
1060
+ with gr.Row():
1061
+ include_audio_checkbox = gr.Checkbox(
1062
+ label="Generate Audio",
1063
+ value=True
1064
+ )
1065
+ include_bubbles_checkbox = gr.Checkbox(
1066
+ label="Add Speech Bubbles",
1067
+ value=True
1068
+ )
1069
+
1070
+ generate_btn = gr.Button("🎨 Generate Comic", variant="primary", size="lg", elem_classes=["comic-button"])
1071
+
1072
+ status_output = gr.Markdown("Ready to generate...")
1073
+
1074
+ with gr.Column(scale=2, elem_classes=["group-container"]):
1075
+ gr.Markdown("### 📖 Full Manga Page")
1076
+ manga_page_output = gr.Image(
1077
+ label="2x3 Manga Layout (2148×3192)",
1078
+ type="filepath",
1079
+ height=900,
1080
+ elem_classes=["comic-panel"]
1081
+ )
1082
+
1083
+ with gr.Row():
1084
+ gr.Markdown("## 📜 Story Script")
1085
+
1086
+ with gr.Row():
1087
+ story_output = gr.Markdown(label="Comic Script")
1088
+
1089
+ with gr.Row():
1090
+ gr.Markdown("## 🖼️ Individual Panels (1024×1024)")
1091
+
1092
+ # 6 individual panels in 2x3 grid with audio
1093
+ panel_outputs = []
1094
+ audio_outputs = []
1095
+
1096
+ with gr.Row():
1097
+ gr.Markdown("## 🖼️ Comic Panels & Audio")
1098
+
1099
+ # Create 3 rows of 2 columns
1100
+ for row in range(3):
1101
+ with gr.Row():
1102
+ for col in range(2):
1103
+ idx = row * 2 + col + 1
1104
+ with gr.Column():
1105
+ panel = gr.Image(label=f"Panel {idx}", type="pil", height=300, elem_classes=["comic-panel"])
1106
+ audio = gr.Audio(label=f"Panel {idx} Audio", type="filepath", interactive=False, autoplay=False)
1107
+ panel_outputs.append(panel)
1108
+ audio_outputs.append(audio)
1109
+
1110
+ with gr.Row():
1111
+ gr.Markdown("## 💾 Download Comic")
1112
+
1113
+ with gr.Row():
1114
+ with gr.Column():
1115
+ export_format_input = gr.Radio(
1116
+ label="Export Format",
1117
+ choices=[
1118
+ ("PDF (Comic Only)", "pdf"),
1119
+ ("ZIP Package (All Assets + Audio)", "zip"),
1120
+ ("Web Viewer (Interactive HTML)", "web")
1121
+ ],
1122
+ value="web"
1123
+ )
1124
+
1125
+ export_btn = gr.Button("📥 Generate Download Artifact", variant="primary", size="lg", elem_classes=["comic-button"])
1126
+ export_status = gr.Markdown("")
1127
+ download_output = gr.File(label="Download File", elem_classes=["download-file-container"])
1128
+
1129
+ # Wire up generate button
1130
+ generate_btn.click(
1131
+ fn=generate_comic_sync,
1132
+ inputs=[prompt_input, style_input, include_audio_checkbox, include_bubbles_checkbox],
1133
+ outputs=[story_output, manga_page_output, *panel_outputs, *audio_outputs, status_output]
1134
+ )
1135
+
1136
+ # Wire up export button
1137
+ export_btn.click(
1138
+ fn=export_comic_sync,
1139
+ inputs=[export_format_input],
1140
+ outputs=[download_output, export_status]
1141
+ )
1142
+
1143
+ gr.HTML("""
1144
+ <div style="text-align: center; padding: 20px; background: #f0f2f5; border-radius: 10px; margin-top: 20px; border: 1px solid #e0e0e0;">
1145
+ <h3 style="font-family: 'Comic Neue', cursive; color: #2c3e50; margin-bottom: 5px;">MythForge AI Comic Generator</h3>
1146
+ <p style="color: #666; margin-bottom: 20px; font-style: italic;">Create manga-style comics with AI</p>
1147
+
1148
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; text-align: left;">
1149
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1150
+ <strong>OpenAI:</strong> GPT-4o-mini (Primary Story Generation), GPT-4o (Dynamic Prompt Examples)
1151
+ </div>
1152
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1153
+ <strong>Gemini:</strong> 3 Pro Image (Character Consistency), 2.5 Flash (Vision), 2.0 Flash (Intelligence)
1154
+ </div>
1155
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1156
+ <strong>Modal:</strong> SDXL (Panel Image Generation)
1157
+ </div>
1158
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1159
+ <strong>ElevenLabs:</strong> TTS with 9 Diverse Voices
1160
+ </div>
1161
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1162
+ <strong>LlamaIndex:</strong> Character Memory & RAG
1163
+ </div>
1164
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1165
+ <strong>Blaxel:</strong> Multi-Provider Orchestration Framework
1166
+ </div>
1167
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1168
+ <strong>Claude Code:</strong> Autonomous Development & Debugging
1169
+ </div>
1170
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1171
+ <strong>SambaNova:</strong> Llama 3.3 70B (Story Fallback)
1172
+ </div>
1173
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
1174
+ <strong>Gradio:</strong> v6 UI Framework
1175
+ </div>
1176
+ </div>
1177
+ </div>
1178
+ """)
1179
+
1180
+
1181
+ if __name__ == "__main__":
1182
+ print("📚 Starting MythForge Comic Generator...")
1183
+ print(f"🎨 Comic Mode: {ENABLE_COMIC_MODE}")
1184
+ print(f"⚡ SambaNova: {ENABLE_SAMBANOVA}")
1185
+ print(f"🖼️ Modal SDXL: {ENABLE_MODAL}")
1186
+ print("🌐 Opening Gradio interface...")
1187
+
1188
+ demo.launch(
1189
+ server_name="0.0.0.0",
1190
+ server_port=7861, # Different port from original app
1191
+ share=False
1192
+ )
audio_generator.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio Generation Service using ElevenLabs"""
2
+ import re
3
+ from typing import List, Optional
4
+ from elevenlabs.client import ElevenLabs
5
+ from elevenlabs import save
6
+ from config import Config
7
+
8
+ class AudioGenerator:
9
+ """Generate narrated audio with character voices using ElevenLabs"""
10
+
11
+ def __init__(self):
12
+ """Initialize ElevenLabs client"""
13
+ self.client = None
14
+ # Diverse ElevenLabs voice IDs for better variety
15
+ self.voices = {
16
+ "narrator": "ErXwobaYiN019PkySvjV", # Antoni - warm narrator
17
+ "character_1": "TxGEqnHWrfWFTfGW9XjX", # Josh - young male
18
+ "character_2": "EXAVITQu4vr4xnSDxMaL", # Bella - soft female
19
+ "character_3": "21m00Tcm4TlvDq8ikWAM", # Rachel - clear female
20
+ "character_4": "VR6AewLTigWG4xSOukaG", # Arnold - deep male
21
+ "character_5": "AZnzlk1XvdvUeBnXmlld", # Domi - young female
22
+ "character_6": "pNInz6obpgDQGcFmaJgB", # Adam - deep male 2
23
+ "character_7": "yoZ06aMxZJJ28mfd3POQ", # Sam - raspy male
24
+ "character_8": "onwK4e9ZLuTAKqWW03F9" # Daniel - authoritative male
25
+ }
26
+
27
+ def _init_client(self):
28
+ """Lazy initialize ElevenLabs client"""
29
+ if self.client is None:
30
+ if not Config.ELEVENLABS_API_KEY:
31
+ raise ValueError("ELEVENLABS_API_KEY not set in environment")
32
+ self.client = ElevenLabs(api_key=Config.ELEVENLABS_API_KEY)
33
+ print(f"✅ ElevenLabs TTS with {len(self.voices)} diverse voices")
34
+
35
+ def _parse_dialogue(self, text: str) -> List[tuple]:
36
+ """
37
+ Parse story text to identify dialogue and narration
38
+ Returns list of (text, voice_type) tuples
39
+ """
40
+ # Simple parsing: look for quoted dialogue
41
+ # Format: [(text, "narrator"), (dialogue, "character_1"), ...]
42
+
43
+ segments = []
44
+ current_pos = 0
45
+
46
+ # Find all quoted text
47
+ dialogue_pattern = r'"([^"]+)"'
48
+ matches = list(re.finditer(dialogue_pattern, text))
49
+
50
+ if not matches:
51
+ # No dialogue found, return entire text as narration
52
+ return [(text, "narrator")]
53
+
54
+ for i, match in enumerate(matches):
55
+ # Add narration before dialogue
56
+ if match.start() > current_pos:
57
+ narration = text[current_pos:match.start()].strip()
58
+ if narration:
59
+ segments.append((narration, "narrator"))
60
+
61
+ # Add dialogue
62
+ dialogue = match.group(1)
63
+ # Alternate between character voices
64
+ voice_type = f"character_{(i % 2) + 1}"
65
+ segments.append((dialogue, voice_type))
66
+
67
+ current_pos = match.end()
68
+
69
+ # Add remaining narration
70
+ if current_pos < len(text):
71
+ narration = text[current_pos:].strip()
72
+ if narration:
73
+ segments.append((narration, "narrator"))
74
+
75
+ return segments
76
+
77
+ async def generate_narration(
78
+ self,
79
+ story_text: str,
80
+ use_multiple_voices: bool = False
81
+ ) -> bytes:
82
+ """
83
+ Generate audio narration for the story
84
+
85
+ Args:
86
+ story_text: The story text to narrate
87
+ use_multiple_voices: Whether to use multiple character voices
88
+
89
+ Returns:
90
+ Audio data as bytes (MP3 format)
91
+ """
92
+ self._init_client()
93
+
94
+ try:
95
+ if use_multiple_voices:
96
+ # Parse dialogue and generate with multiple voices
97
+ segments = self._parse_dialogue(story_text)
98
+ audio_segments = []
99
+
100
+ for text, voice_type in segments:
101
+ if not text.strip():
102
+ continue
103
+
104
+ voice_name = self.voices.get(voice_type, self.voices["narrator"])
105
+
106
+ # Use the correct API method
107
+ audio = self.client.text_to_speech.convert(
108
+ text=text,
109
+ voice_id=voice_name,
110
+ model_id="eleven_monolingual_v1"
111
+ )
112
+
113
+ # Collect audio bytes
114
+ audio_bytes = b""
115
+ for chunk in audio:
116
+ audio_bytes += chunk
117
+
118
+ audio_segments.append(audio_bytes)
119
+
120
+ # Combine audio segments
121
+ return b"".join(audio_segments)
122
+
123
+ else:
124
+ # Single narrator voice
125
+ audio = self.client.text_to_speech.convert(
126
+ text=story_text,
127
+ voice_id=self.voices["narrator"],
128
+ model_id="eleven_monolingual_v1"
129
+ )
130
+
131
+ # Collect audio bytes
132
+ audio_bytes = b""
133
+ for chunk in audio:
134
+ audio_bytes += chunk
135
+
136
+ return audio_bytes
137
+
138
+ except Exception as e:
139
+ print(f"Error generating audio: {e}")
140
+ raise
141
+
142
+ async def generate_panel_audio(
143
+ self,
144
+ panel_data: dict,
145
+ output_path: Optional[str] = None
146
+ ) -> bytes:
147
+ """
148
+ Generate audio for a single comic panel
149
+
150
+ Args:
151
+ panel_data: Panel dictionary with 'dialogue' and optional 'character_name'
152
+ output_path: Optional path to save MP3 file
153
+
154
+ Returns:
155
+ Audio data as bytes (MP3 format)
156
+ """
157
+ self._init_client()
158
+
159
+ try:
160
+ dialogue = panel_data.get("dialogue", "")
161
+ character_name = panel_data.get("character_name", "").lower()
162
+
163
+ if not dialogue.strip():
164
+ # No dialogue, return empty audio or silence
165
+ return b""
166
+
167
+ # Remove "Character:" prefix if present (prevents voice from saying the name)
168
+ if ":" in dialogue:
169
+ parts = dialogue.split(":", 1)
170
+ # Check if this looks like a "Character: dialogue" format
171
+ potential_name = parts[0].strip()
172
+ if not potential_name.startswith("(") and len(potential_name.split()) <= 3:
173
+ dialogue = parts[1].strip()
174
+
175
+ # Map character names to voices
176
+ voice_id = self._map_character_to_voice(character_name)
177
+
178
+ # Generate audio
179
+ audio = self.client.text_to_speech.convert(
180
+ text=dialogue,
181
+ voice_id=voice_id,
182
+ model_id="eleven_monolingual_v1"
183
+ )
184
+
185
+ # Collect audio bytes
186
+ audio_bytes = b""
187
+ for chunk in audio:
188
+ audio_bytes += chunk
189
+
190
+ # Save if output path provided
191
+ if output_path and audio_bytes:
192
+ with open(output_path, 'wb') as f:
193
+ f.write(audio_bytes)
194
+
195
+ return audio_bytes
196
+
197
+ except Exception as e:
198
+ print(f"Error generating panel audio: {e}")
199
+ raise
200
+
201
+ async def generate_comic_audio(
202
+ self,
203
+ panels: List[dict],
204
+ output_dir: str = "."
205
+ ) -> List[str]:
206
+ """
207
+ Generate audio for all panels in a comic
208
+
209
+ Args:
210
+ panels: List of panel dictionaries from SambaNova output
211
+ output_dir: Directory to save MP3 files
212
+
213
+ Returns:
214
+ List of output file paths
215
+ """
216
+ self._init_client()
217
+
218
+ output_files = []
219
+
220
+ for i, panel in enumerate(panels):
221
+ panel_num = panel.get("panel_number", i + 1)
222
+ output_path = f"{output_dir}/panel_{panel_num}_audio.mp3"
223
+
224
+ try:
225
+ await self.generate_panel_audio(panel, output_path)
226
+ output_files.append(output_path)
227
+ print(f"✅ Generated audio for panel {panel_num}: {output_path}")
228
+ except Exception as e:
229
+ print(f"❌ Failed to generate audio for panel {panel_num}: {e}")
230
+ output_files.append(None)
231
+
232
+ return output_files
233
+
234
+ def _map_character_to_voice(self, character_name: str) -> str:
235
+ """
236
+ Map character name to ElevenLabs voice ID
237
+
238
+ Args:
239
+ character_name: Character name from dialogue
240
+
241
+ Returns:
242
+ ElevenLabs voice ID
243
+ """
244
+ # Mapping based on character traits for diverse voices
245
+ character_lower = character_name.lower()
246
+
247
+ # Female character indicators → Use diverse female voices
248
+ female_indicators = ["she", "her", "girl", "woman", "princess", "queen", "mother", "sister", "lady", "maiden"]
249
+ # Male character indicators → Use diverse male voices
250
+ male_indicators = ["he", "him", "boy", "man", "prince", "king", "father", "brother", "wizard", "knight", "sir"]
251
+ # Authoritative/older → Deep voices
252
+ authority_indicators = ["king", "wizard", "master", "elder", "lord", "sage", "mentor"]
253
+ # Young characters → Lighter voices
254
+ young_indicators = ["boy", "girl", "child", "youth", "apprentice", "student"]
255
+
256
+ # Narrator default
257
+ if character_lower in ["narrator", ""]:
258
+ return self.voices["narrator"] # Antoni
259
+
260
+ # Check for specific character traits
261
+ is_female = any(indicator in character_lower for indicator in female_indicators)
262
+ is_male = any(indicator in character_lower for indicator in male_indicators)
263
+ is_authority = any(indicator in character_lower for indicator in authority_indicators)
264
+ is_young = any(indicator in character_lower for indicator in young_indicators)
265
+
266
+ # Smart voice mapping based on character traits
267
+ if is_female:
268
+ if is_young:
269
+ return self.voices["character_5"] # Domi - young female
270
+ else:
271
+ # Alternate between Bella and Rachel
272
+ char_hash = sum(ord(c) for c in character_name)
273
+ return self.voices["character_2"] if char_hash % 2 == 0 else self.voices["character_3"]
274
+ elif is_male:
275
+ if is_authority:
276
+ # Deep authoritative male voices
277
+ char_hash = sum(ord(c) for c in character_name)
278
+ return self.voices["character_4"] if char_hash % 2 == 0 else self.voices["character_6"] # Arnold or Adam
279
+ elif is_young:
280
+ return self.voices["character_1"] # Josh - young male
281
+ else:
282
+ # Alternate between different male voices
283
+ char_hash = sum(ord(c) for c in character_name)
284
+ voice_options = ["character_1", "character_7", "character_8"] # Josh, Sam, Daniel
285
+ return self.voices[voice_options[char_hash % len(voice_options)]]
286
+
287
+ # Default: distribute across all 8 character voices based on hash
288
+ # This ensures consistency for the same character name while using all voices
289
+ char_hash = sum(ord(c) for c in character_name)
290
+ voice_index = (char_hash % 8) + 1
291
+ return self.voices[f"character_{voice_index}"]
292
+
293
+ async def generate_introduction(
294
+ self,
295
+ story_prompt: str,
296
+ story_title: str,
297
+ characters: List[str],
298
+ output_path: Optional[str] = None
299
+ ) -> bytes:
300
+ """
301
+ Generate introductory narrator track
302
+
303
+ Args:
304
+ story_prompt: Original user story prompt
305
+ story_title: Generated story title
306
+ characters: List of character names
307
+ output_path: Optional path to save MP3 file
308
+
309
+ Returns:
310
+ Audio data as bytes (MP3 format)
311
+ """
312
+ self._init_client()
313
+
314
+ try:
315
+ # Create introduction script
316
+ char_list = ", ".join(characters) if characters else "our characters"
317
+ intro_text = f"""Welcome to {story_title}.
318
+
319
+ {story_prompt}
320
+
321
+ Today's story features {char_list}. Let's begin."""
322
+
323
+ # Generate audio using narrator voice
324
+ audio = self.client.text_to_speech.convert(
325
+ text=intro_text,
326
+ voice_id=self.voices["narrator"],
327
+ model_id="eleven_monolingual_v1"
328
+ )
329
+
330
+ # Collect audio bytes
331
+ audio_bytes = b""
332
+ for chunk in audio:
333
+ audio_bytes += chunk
334
+
335
+ # Save if output path provided
336
+ if output_path:
337
+ with open(output_path, "wb") as f:
338
+ f.write(audio_bytes)
339
+
340
+ return audio_bytes
341
+
342
+ except Exception as e:
343
+ print(f"Error generating introduction: {e}")
344
+ raise
345
+
346
+ async def generate_conclusion(
347
+ self,
348
+ story_title: str,
349
+ panels: List[dict],
350
+ output_path: Optional[str] = None
351
+ ) -> bytes:
352
+ """
353
+ Generate concluding narrator track
354
+
355
+ Args:
356
+ story_title: Story title
357
+ panels: List of panel data to summarize
358
+ output_path: Optional path to save MP3 file
359
+
360
+ Returns:
361
+ Audio data as bytes (MP3 format)
362
+ """
363
+ self._init_client()
364
+
365
+ try:
366
+ # Create conclusion script based on final panel
367
+ final_panel = panels[-1] if panels else None
368
+ final_action = final_panel.get("action", "the story concludes") if final_panel else "the story concludes"
369
+
370
+ conclusion_text = f"""And so, {final_action}.
371
+
372
+ This concludes {story_title}. Thank you for experiencing this story with us."""
373
+
374
+ # Generate audio using narrator voice
375
+ audio = self.client.text_to_speech.convert(
376
+ text=conclusion_text,
377
+ voice_id=self.voices["narrator"],
378
+ model_id="eleven_monolingual_v1"
379
+ )
380
+
381
+ # Collect audio bytes
382
+ audio_bytes = b""
383
+ for chunk in audio:
384
+ audio_bytes += chunk
385
+
386
+ # Save if output path provided
387
+ if output_path:
388
+ with open(output_path, "wb") as f:
389
+ f.write(audio_bytes)
390
+
391
+ return audio_bytes
392
+
393
+ except Exception as e:
394
+ print(f"Error generating conclusion: {e}")
395
+ raise
396
+
397
+ def estimate_cost(self, text: str) -> float:
398
+ """
399
+ Estimate cost for audio generation
400
+ ElevenLabs charges per character
401
+ """
402
+ char_count = len(text)
403
+ # Approximate cost: $0.30 per 1000 characters
404
+ cost = (char_count / 1000) * 0.30
405
+ return cost
406
+
407
+ def estimate_comic_cost(self, panels: List[dict]) -> dict:
408
+ """
409
+ Estimate cost for generating audio for all panels
410
+
411
+ Args:
412
+ panels: List of panel dictionaries
413
+
414
+ Returns:
415
+ Dictionary with cost breakdown
416
+ """
417
+ total_chars = 0
418
+ panel_costs = []
419
+
420
+ for panel in panels:
421
+ dialogue = panel.get("dialogue", "")
422
+ char_count = len(dialogue)
423
+ total_chars += char_count
424
+ cost = self.estimate_cost(dialogue)
425
+ panel_costs.append({
426
+ "panel": panel.get("panel_number", 0),
427
+ "characters": char_count,
428
+ "cost": cost
429
+ })
430
+
431
+ total_cost = self.estimate_cost("".join([p.get("dialogue", "") for p in panels]))
432
+
433
+ return {
434
+ "total_characters": total_chars,
435
+ "total_cost": total_cost,
436
+ "panel_breakdown": panel_costs
437
+ }
auto_character_intelligence.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Automatic Character Intelligence System
3
+ Extracts characters from user prompts, generates descriptions, and introduces surprise characters
4
+ """
5
+
6
+ import json
7
+ import re
8
+ from typing import Dict, List, Tuple, Optional
9
+ import google.generativeai as genai
10
+ from config import Config
11
+
12
+
13
+ class AutoCharacterIntelligence:
14
+ """Automatically extract and generate characters from story prompts"""
15
+
16
+ def __init__(self, api_key: Optional[str] = None):
17
+ """
18
+ Initialize Auto Character Intelligence
19
+
20
+ Args:
21
+ api_key: Gemini API key (defaults to Config.GEMINI_API_KEY)
22
+ """
23
+ self.api_key = api_key or Config.GEMINI_API_KEY
24
+ if not self.api_key:
25
+ raise ValueError("Gemini API key is required")
26
+
27
+ genai.configure(api_key=self.api_key)
28
+
29
+ # Try Gemini 2.0 Flash for fast character analysis
30
+ try:
31
+ self.model = genai.GenerativeModel('gemini-2.0-flash')
32
+ self.model_name = 'gemini-2.0-flash'
33
+ except:
34
+ self.model = genai.GenerativeModel('gemini-1.5-flash')
35
+ self.model_name = 'gemini-1.5-flash'
36
+
37
+ print(f"✅ AutoCharacterIntelligence using {self.model_name.upper().replace('-', ' ')}")
38
+
39
+ def extract_characters_from_prompt(
40
+ self,
41
+ story_prompt: str,
42
+ art_style: str = "manga",
43
+ include_surprise_character: bool = True
44
+ ) -> Dict[str, str]:
45
+ """
46
+ Automatically extract characters from story prompt and generate detailed descriptions
47
+
48
+ Args:
49
+ story_prompt: User's story prompt
50
+ art_style: Art style for character design
51
+ include_surprise_character: Add a surprise character that fits the story
52
+
53
+ Returns:
54
+ Dictionary mapping character names to detailed descriptions
55
+ """
56
+ prompt = f"""Analyze this story prompt and extract ALL characters, then generate detailed visual descriptions for each.
57
+
58
+ STORY PROMPT: "{story_prompt}"
59
+
60
+ ART STYLE: {art_style}
61
+
62
+ Your task:
63
+ 1. Identify ALL characters mentioned or implied in the story
64
+ 2. For each character, create a DETAILED visual description including:
65
+ - Age range and gender
66
+ - Physical appearance (face, hair, body type)
67
+ - Clothing and accessories
68
+ - Distinctive features
69
+ - Personality traits reflected in appearance
70
+ - Color palette
71
+
72
+ 3. {'IMPORTANT: Also introduce ONE surprise character that would add interest to this story. This character should fit naturally into the narrative but NOT be explicitly mentioned in the prompt. Make them intriguing and relevant.' if include_surprise_character else ''}
73
+
74
+ Return a JSON object with this structure:
75
+ {{
76
+ "main_characters": {{
77
+ "Character Name 1": "Detailed visual description...",
78
+ "Character Name 2": "Detailed visual description..."
79
+ }},
80
+ {'"surprise_character": {{"Character Name": "Detailed description with explanation of why they appear"}},' if include_surprise_character else ''}
81
+ "story_analysis": "Brief analysis of the story and character relationships"
82
+ }}
83
+
84
+ Make descriptions vivid, specific, and suitable for {art_style} art style.
85
+ Return ONLY valid JSON."""
86
+
87
+ try:
88
+ response = self.model.generate_content(prompt)
89
+ response_text = response.text.strip()
90
+
91
+ # Remove markdown code blocks
92
+ if response_text.startswith("```"):
93
+ lines = response_text.split("\n")
94
+ if lines[0].startswith("```"):
95
+ lines = lines[1:]
96
+ if lines and lines[-1].strip() == "```":
97
+ lines = lines[:-1]
98
+ response_text = "\n".join(lines)
99
+
100
+ # Parse JSON
101
+ result = json.loads(response_text)
102
+
103
+ # Combine all characters
104
+ all_characters = {}
105
+ all_characters.update(result.get("main_characters", {}))
106
+
107
+ if include_surprise_character and "surprise_character" in result:
108
+ all_characters.update(result.get("surprise_character", {}))
109
+
110
+ print(f"\n✓ Extracted {len(all_characters)} characters from prompt:")
111
+ for char_name in all_characters.keys():
112
+ char_type = "🎁 SURPRISE" if char_name in result.get("surprise_character", {}) else "📖 Main"
113
+ print(f" {char_type}: {char_name}")
114
+
115
+ if "story_analysis" in result:
116
+ print(f"\n📝 Story Analysis: {result['story_analysis']}")
117
+
118
+ return all_characters
119
+
120
+ except json.JSONDecodeError as e:
121
+ print(f"Error parsing JSON: {e}")
122
+ print(f"Raw response: {response_text}")
123
+ return self._fallback_character_extraction(story_prompt, art_style)
124
+
125
+ except Exception as e:
126
+ print(f"Error extracting characters: {e}")
127
+ return self._fallback_character_extraction(story_prompt, art_style)
128
+
129
+ def _fallback_character_extraction(self, story_prompt: str, art_style: str) -> Dict[str, str]:
130
+ """Fallback character extraction using simple pattern matching"""
131
+ characters = {}
132
+
133
+ # Common character archetypes based on keywords
134
+ archetypes = {
135
+ "yogi": "Elderly Indian yogi, white beard, saffron robes, meditation beads",
136
+ "wizard": "Elderly wizard, long grey beard, blue robes with stars, pointed hat",
137
+ "warrior": "Strong warrior, armor, sword, determined expression",
138
+ "princess": "Young princess, elegant gown, crown, graceful demeanor",
139
+ "dragon": "Large dragon, scales, wings, fierce eyes",
140
+ "knight": "Armored knight, shield, sword, noble bearing"
141
+ }
142
+
143
+ # Extract using simple matching
144
+ prompt_lower = story_prompt.lower()
145
+ for keyword, description in archetypes.items():
146
+ if keyword in prompt_lower:
147
+ char_name = keyword.capitalize()
148
+ characters[char_name] = f"{description}, {art_style} art style"
149
+
150
+ # If no characters found, create generic ones
151
+ if not characters:
152
+ characters["Protagonist"] = f"Main character, {art_style} art style, heroic appearance"
153
+
154
+ print(f"⚠️ Using fallback extraction: {len(characters)} characters")
155
+ return characters
156
+
157
+ def generate_panel_breakdown(
158
+ self,
159
+ story_prompt: str,
160
+ num_panels: int = 3
161
+ ) -> List[Dict]:
162
+ """
163
+ Generate panel-by-panel breakdown from story prompt
164
+
165
+ Args:
166
+ story_prompt: User's story prompt
167
+ num_panels: Number of panels to generate
168
+
169
+ Returns:
170
+ List of panel dictionaries with scene descriptions and characters
171
+ """
172
+ prompt = f"""Break down this story into {num_panels} comic panels.
173
+
174
+ STORY: "{story_prompt}"
175
+
176
+ For each panel, provide:
177
+ 1. Scene description (visual details)
178
+ 2. Characters appearing in that panel
179
+ 3. Action/event happening
180
+ 4. Mood/atmosphere
181
+
182
+ Return JSON array:
183
+ [
184
+ {{
185
+ "panel_number": 1,
186
+ "scene_description": "Detailed visual description of the scene...",
187
+ "characters": ["Character1", "Character2"],
188
+ "action": "What is happening",
189
+ "mood": "Peaceful/Tense/Exciting etc."
190
+ }},
191
+ ...
192
+ ]
193
+
194
+ Make each panel visually distinct and progress the story naturally.
195
+ Return ONLY valid JSON array."""
196
+
197
+ try:
198
+ response = self.model.generate_content(prompt)
199
+ response_text = response.text.strip()
200
+
201
+ # Remove markdown code blocks
202
+ if response_text.startswith("```"):
203
+ lines = response_text.split("\n")
204
+ if lines[0].startswith("```"):
205
+ lines = lines[1:]
206
+ if lines and lines[-1].strip() == "```":
207
+ lines = lines[:-1]
208
+ response_text = "\n".join(lines)
209
+
210
+ panels = json.loads(response_text)
211
+
212
+ print(f"\n✓ Generated {len(panels)} panel breakdown:")
213
+ for panel in panels:
214
+ print(f" Panel {panel.get('panel_number')}: {panel.get('characters', [])}")
215
+
216
+ return panels
217
+
218
+ except Exception as e:
219
+ print(f"Error generating panel breakdown: {e}")
220
+ # Fallback: simple 3-panel structure
221
+ return [
222
+ {
223
+ "panel_number": 1,
224
+ "scene_description": f"Opening scene: {story_prompt[:100]}",
225
+ "characters": [],
226
+ "action": "Introduction",
227
+ "mood": "Setting the scene"
228
+ },
229
+ {
230
+ "panel_number": 2,
231
+ "scene_description": f"Middle scene: {story_prompt[:100]}",
232
+ "characters": [],
233
+ "action": "Development",
234
+ "mood": "Building tension"
235
+ },
236
+ {
237
+ "panel_number": 3,
238
+ "scene_description": f"Final scene: {story_prompt[:100]}",
239
+ "characters": [],
240
+ "action": "Conclusion",
241
+ "mood": "Resolution"
242
+ }
243
+ ]
244
+
245
+ def analyze_story_for_surprise_character(
246
+ self,
247
+ story_prompt: str,
248
+ existing_characters: List[str]
249
+ ) -> Tuple[str, str]:
250
+ """
251
+ Suggest a surprise character that would enhance the story
252
+
253
+ Args:
254
+ story_prompt: User's story prompt
255
+ existing_characters: List of already identified characters
256
+
257
+ Returns:
258
+ Tuple of (character_name, character_description)
259
+ """
260
+ existing_str = ", ".join(existing_characters)
261
+
262
+ prompt = f"""Based on this story, suggest ONE surprise character that would add intrigue.
263
+
264
+ STORY: "{story_prompt}"
265
+ EXISTING CHARACTERS: {existing_str}
266
+
267
+ The surprise character should:
268
+ 1. Fit naturally into the story world
269
+ 2. Add an interesting dynamic or plot twist
270
+ 3. Be visually distinctive
271
+ 4. NOT duplicate existing characters
272
+
273
+ Return JSON:
274
+ {{
275
+ "character_name": "Name",
276
+ "character_description": "Detailed visual description",
277
+ "story_reason": "Why this character appears and their role"
278
+ }}
279
+
280
+ Return ONLY valid JSON."""
281
+
282
+ try:
283
+ response = self.model.generate_content(prompt)
284
+ response_text = response.text.strip()
285
+
286
+ # Remove markdown code blocks
287
+ if response_text.startswith("```"):
288
+ lines = response_text.split("\n")
289
+ if lines[0].startswith("```"):
290
+ lines = lines[1:]
291
+ if lines and lines[-1].strip() == "```":
292
+ lines = lines[:-1]
293
+ response_text = "\n".join(lines)
294
+
295
+ result = json.loads(response_text)
296
+
297
+ character_name = result.get("character_name", "Mystery Character")
298
+ character_desc = result.get("character_description", "Unknown appearance")
299
+ reason = result.get("story_reason", "")
300
+
301
+ print(f"\n🎁 Surprise Character Suggested: {character_name}")
302
+ print(f" Reason: {reason}")
303
+
304
+ return character_name, character_desc
305
+
306
+ except Exception as e:
307
+ print(f"Error suggesting surprise character: {e}")
308
+ return "Mystery Character", "Mysterious figure shrouded in shadows"
309
+
310
+
311
+ # Example usage
312
+ if __name__ == "__main__":
313
+ intelligence = AutoCharacterIntelligence()
314
+
315
+ # Example 1: Extract characters from yogi prompt
316
+ print("="*70)
317
+ print("Example 1: Yogi Story")
318
+ print("="*70)
319
+
320
+ yogi_prompt = "A yogi teaching a young aspirant the rules of meditation and mindfulness leading to levitation by his own example in a beautiful vibrant colorful jungle"
321
+
322
+ characters = intelligence.extract_characters_from_prompt(
323
+ story_prompt=yogi_prompt,
324
+ art_style="manga",
325
+ include_surprise_character=True
326
+ )
327
+
328
+ print("\n--- Extracted Characters ---")
329
+ for name, desc in characters.items():
330
+ print(f"\n{name}:")
331
+ print(f" {desc[:150]}...")
332
+
333
+ # Example 2: Generate panel breakdown
334
+ print("\n" + "="*70)
335
+ print("Example 2: Panel Breakdown")
336
+ print("="*70)
337
+
338
+ panels = intelligence.generate_panel_breakdown(yogi_prompt, num_panels=3)
339
+
340
+ print("\n--- Panel Breakdown ---")
341
+ for panel in panels:
342
+ print(f"\nPanel {panel['panel_number']}:")
343
+ print(f" Characters: {', '.join(panel.get('characters', []))}")
344
+ print(f" Scene: {panel.get('scene_description', '')[:100]}...")
345
+ print(f" Mood: {panel.get('mood', 'N/A')}")
346
+
347
+ # Example 3: Suggest surprise character
348
+ print("\n" + "="*70)
349
+ print("Example 3: Surprise Character")
350
+ print("="*70)
351
+
352
+ surprise_name, surprise_desc = intelligence.analyze_story_for_surprise_character(
353
+ story_prompt=yogi_prompt,
354
+ existing_characters=["Master Yogi", "Young Aspirant"]
355
+ )
356
+
357
+ print(f"\nSurprise Character: {surprise_name}")
358
+ print(f"Description: {surprise_desc}")
blaxel_agent.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Blaxel Agent Integration for MythForge Comic Generator
2
+
3
+ Orchestrates multi-provider AI comic generation through Blaxel's agent platform.
4
+ """
5
+
6
+ import requests
7
+ import asyncio
8
+ from typing import Dict, List, Optional
9
+ from blaxel_config import BlaxelConfig
10
+
11
+
12
+ class BlaxelComicAgent:
13
+ """Blaxel agent for orchestrating comic generation workflow"""
14
+
15
+ def __init__(self):
16
+ """Initialize Blaxel agent client"""
17
+ self.enabled = BlaxelConfig.is_enabled()
18
+ self.base_url = BlaxelConfig.BASE_URL
19
+ self.headers = BlaxelConfig.get_headers()
20
+
21
+ if self.enabled:
22
+ print(f"✅ Blaxel agent enabled: {BlaxelConfig.AGENT_NAME}")
23
+ else:
24
+ print("⚠️ Blaxel agent disabled (set ENABLE_BLAXEL=true in .env)")
25
+
26
+ async def generate_comic_orchestrated(
27
+ self,
28
+ prompt: str,
29
+ style: str = "manga",
30
+ include_audio: bool = True
31
+ ) -> Dict:
32
+ """
33
+ Orchestrate comic generation through Blaxel agent workflow
34
+
35
+ Args:
36
+ prompt: User's story prompt
37
+ style: Visual style (manga, shonen, etc.)
38
+ include_audio: Whether to generate audio
39
+
40
+ Returns:
41
+ Dict with generated assets and metadata
42
+ """
43
+ if not self.enabled:
44
+ raise RuntimeError("Blaxel agent not enabled")
45
+
46
+ # Define agent workflow
47
+ workflow = {
48
+ "agent_name": BlaxelConfig.AGENT_NAME,
49
+ "workflow_version": BlaxelConfig.AGENT_VERSION,
50
+ "steps": [
51
+ {
52
+ "name": "story_generation",
53
+ "provider": "sambanova",
54
+ "model": "llama-3.3-70b",
55
+ "input": {
56
+ "prompt": prompt,
57
+ "style": style,
58
+ "panel_count": 6
59
+ },
60
+ "timeout": 10
61
+ },
62
+ {
63
+ "name": "image_generation",
64
+ "provider": "modal",
65
+ "model": "sdxl",
66
+ "input": {
67
+ "scenes": "{{story_generation.panels}}",
68
+ "style": style,
69
+ "count": 6
70
+ },
71
+ "timeout": 120,
72
+ "parallel": True # Generate 6 panels in parallel
73
+ },
74
+ {
75
+ "name": "audio_generation",
76
+ "provider": "elevenlabs",
77
+ "model": "eleven_multilingual_v2",
78
+ "input": {
79
+ "dialogues": "{{story_generation.panels}}",
80
+ "count": 6
81
+ },
82
+ "timeout": 30,
83
+ "enabled": include_audio
84
+ }
85
+ ],
86
+ "monitoring": {
87
+ "track_costs": True,
88
+ "track_latency": True,
89
+ "log_errors": True
90
+ }
91
+ }
92
+
93
+ # Execute workflow through Blaxel
94
+ try:
95
+ response = await self._execute_workflow(workflow)
96
+ return response
97
+ except Exception as e:
98
+ print(f"❌ Blaxel workflow failed: {e}")
99
+ raise
100
+
101
+ async def _execute_workflow(self, workflow: Dict) -> Dict:
102
+ """Execute workflow through Blaxel API"""
103
+ endpoint = f"{self.base_url}/v1/workflows/execute"
104
+
105
+ try:
106
+ response = requests.post(
107
+ endpoint,
108
+ json=workflow,
109
+ headers=self.headers,
110
+ timeout=BlaxelConfig.REQUEST_TIMEOUT
111
+ )
112
+
113
+ if response.status_code != 200:
114
+ raise RuntimeError(
115
+ f"Blaxel API error {response.status_code}: {response.text}"
116
+ )
117
+
118
+ result = response.json()
119
+ return result
120
+
121
+ except requests.exceptions.Timeout:
122
+ raise RuntimeError("Blaxel workflow timeout")
123
+ except Exception as e:
124
+ raise RuntimeError(f"Blaxel execution failed: {e}")
125
+
126
+ def get_workflow_status(self, workflow_id: str) -> Dict:
127
+ """Get status of a running workflow"""
128
+ endpoint = f"{self.base_url}/v1/workflows/{workflow_id}/status"
129
+
130
+ response = requests.get(endpoint, headers=self.headers)
131
+ return response.json()
132
+
133
+ def get_cost_report(self, workflow_id: str) -> Dict:
134
+ """Get cost breakdown for a workflow"""
135
+ endpoint = f"{self.base_url}/v1/workflows/{workflow_id}/costs"
136
+
137
+ response = requests.get(endpoint, headers=self.headers)
138
+ return response.json()
139
+
140
+ @classmethod
141
+ def is_available(cls) -> bool:
142
+ """Check if Blaxel agent is available"""
143
+ return BlaxelConfig.is_enabled()
144
+
145
+
146
+ # Example usage
147
+ if __name__ == "__main__":
148
+ import asyncio
149
+
150
+ async def test_blaxel():
151
+ """Test Blaxel agent integration"""
152
+ agent = BlaxelComicAgent()
153
+
154
+ if not agent.enabled:
155
+ print("❌ Blaxel not enabled. Set ENABLE_BLAXEL=true in .env")
156
+ return
157
+
158
+ print("Testing Blaxel comic generation workflow...")
159
+
160
+ try:
161
+ result = await agent.generate_comic_orchestrated(
162
+ prompt="A wizard discovers a magical library",
163
+ style="manga",
164
+ include_audio=True
165
+ )
166
+
167
+ print("✅ Blaxel workflow completed!")
168
+ print(f"Story: {result.get('story_generation', {}).get('title')}")
169
+ print(f"Panels generated: {len(result.get('image_generation', {}).get('images', []))}")
170
+ print(f"Audio files: {len(result.get('audio_generation', {}).get('files', []))}")
171
+
172
+ # Get cost report
173
+ workflow_id = result.get('workflow_id')
174
+ if workflow_id:
175
+ costs = agent.get_cost_report(workflow_id)
176
+ print(f"Total cost: ${costs.get('total_cost', 0):.4f}")
177
+
178
+ except Exception as e:
179
+ print(f"❌ Test failed: {e}")
180
+
181
+ # Run test
182
+ asyncio.run(test_blaxel())
blaxel_config.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Blaxel Configuration for MythForge Comic Generator"""
2
+ import os
3
+ from config import Config
4
+
5
+ class BlaxelConfig:
6
+ """Configuration for Blaxel agent orchestration"""
7
+
8
+ # Blaxel API Configuration
9
+ API_KEY = Config.BLAXEL_API_KEY
10
+ BASE_URL = os.getenv("BLAXEL_BASE_URL", "https://api.blaxel.ai")
11
+
12
+ # Agent Configuration
13
+ AGENT_NAME = "mythforge-comic-generator"
14
+ AGENT_DESCRIPTION = "Generates 6-panel manga-style comics with dialogue and audio"
15
+ AGENT_VERSION = "1.0.0"
16
+
17
+ # Timeout Settings (in seconds)
18
+ REQUEST_TIMEOUT = 300 # 5 minutes for comic generation
19
+ RETRY_ATTEMPTS = 3
20
+ RETRY_DELAY = 2 # seconds between retries
21
+
22
+ # Model Gateway Configuration
23
+ # Routes for different AI services through Blaxel
24
+ MODEL_GATEWAY_ENABLED = Config.ENABLE_BLAXEL
25
+
26
+ # Service Routes (through Blaxel Model Gateway)
27
+ ROUTES = {
28
+ "text_generation": {
29
+ "primary": "sambanova/llama-3.1-70b",
30
+ "fallback": "openai/gpt-4o-mini"
31
+ },
32
+ "image_generation": {
33
+ "primary": "modal/sdxl",
34
+ "fallback": "local/sd-1.5"
35
+ },
36
+ "audio_generation": {
37
+ "primary": "elevenlabs/eleven_multilingual_v2"
38
+ },
39
+ "vision_analysis": {
40
+ "primary": "gemini/flash"
41
+ }
42
+ }
43
+
44
+ # Batch Job Configuration
45
+ ENABLE_BATCH_MODE = False # For bulk comic generation
46
+ MAX_BATCH_SIZE = 10
47
+
48
+ # Monitoring and Logging
49
+ ENABLE_LOGGING = True
50
+ LOG_LEVEL = os.getenv("BLAXEL_LOG_LEVEL", "INFO")
51
+
52
+ @classmethod
53
+ def validate(cls):
54
+ """Validate Blaxel configuration"""
55
+ if Config.ENABLE_BLAXEL and not cls.API_KEY:
56
+ raise ValueError(
57
+ "BLAXEL_API_KEY is required when ENABLE_BLAXEL=true. "
58
+ "Please set it in .env file or disable Blaxel."
59
+ )
60
+ return True
61
+
62
+ @classmethod
63
+ def is_enabled(cls):
64
+ """Check if Blaxel is enabled"""
65
+ return Config.ENABLE_BLAXEL and cls.API_KEY is not None
66
+
67
+ @classmethod
68
+ def get_headers(cls):
69
+ """Get properly formatted headers for Blaxel API requests"""
70
+ return {
71
+ "X-Blaxel-Authorization": f"Bearer {cls.API_KEY}",
72
+ "accept": "application/json, text/plain, */*",
73
+ "Content-Type": "application/json"
74
+ }
character_consistency_manager.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Consistency Manager
3
+ Orchestrates Nano Banana + LlamaIndex hybrid workflow for character persistence
4
+ """
5
+
6
+ import os
7
+ import json
8
+ from typing import Dict, List, Optional, Tuple, Union
9
+ from PIL import Image
10
+ from io import BytesIO
11
+ import base64
12
+
13
+ from nano_banana_client import NanoBananaClient
14
+ from character_extractor import CharacterExtractor
15
+ from story_memory import StoryMemory
16
+ from auto_character_intelligence import AutoCharacterIntelligence
17
+ from config import Config
18
+
19
+
20
+ class CharacterConsistencyManager:
21
+ """Manages character consistency across comic panels using Nano Banana + LlamaIndex"""
22
+
23
+ def __init__(
24
+ self,
25
+ character_library_dir: str = "./character_library",
26
+ enable_nano_banana: bool = None,
27
+ enable_character_library: bool = None
28
+ ):
29
+ """
30
+ Initialize Character Consistency Manager
31
+
32
+ Args:
33
+ character_library_dir: Directory to store character library
34
+ enable_nano_banana: Enable Nano Banana image generation (defaults to Config)
35
+ enable_character_library: Enable character library persistence (defaults to Config)
36
+ """
37
+ self.character_library_dir = character_library_dir
38
+ self.enable_nano_banana = enable_nano_banana if enable_nano_banana is not None else Config.ENABLE_NANO_BANANA
39
+ self.enable_character_library = enable_character_library if enable_character_library is not None else Config.ENABLE_CHARACTER_LIBRARY
40
+
41
+ # Create character library directory
42
+ os.makedirs(character_library_dir, exist_ok=True)
43
+ os.makedirs(os.path.join(character_library_dir, "images"), exist_ok=True)
44
+ os.makedirs(os.path.join(character_library_dir, "profiles"), exist_ok=True)
45
+
46
+ # Initialize components
47
+ self.nano_banana = None
48
+ self.character_extractor = None
49
+ self.story_memory = None
50
+ self.auto_intelligence = None
51
+
52
+ if Config.GEMINI_API_KEY:
53
+ try:
54
+ if self.enable_nano_banana:
55
+ self.nano_banana = NanoBananaClient(model=Config.NANO_BANANA_MODEL)
56
+ print("✓ Nano Banana client initialized")
57
+ except Exception as e:
58
+ print(f"Warning: Could not initialize Nano Banana: {e}")
59
+
60
+ try:
61
+ self.character_extractor = CharacterExtractor()
62
+ print("✓ Character Extractor initialized")
63
+ except Exception as e:
64
+ print(f"Warning: Could not initialize Character Extractor: {e}")
65
+
66
+ try:
67
+ self.auto_intelligence = AutoCharacterIntelligence()
68
+ print("✓ Auto Character Intelligence initialized")
69
+ except Exception as e:
70
+ print(f"Warning: Could not initialize Auto Intelligence: {e}")
71
+
72
+ if self.enable_character_library and Config.ENABLE_LLAMAINDEX:
73
+ try:
74
+ self.story_memory = StoryMemory()
75
+ print("✓ Story Memory (LlamaIndex) initialized")
76
+ except Exception as e:
77
+ print(f"Warning: Could not initialize Story Memory: {e}")
78
+
79
+ # Character cache (in-memory for current session)
80
+ self.character_cache: Dict[str, Dict] = {}
81
+
82
+ print(f"Character Consistency Manager initialized")
83
+ print(f" Nano Banana: {'✓ Enabled' if self.enable_nano_banana and self.nano_banana else '✗ Disabled'}")
84
+ print(f" Character Library: {'✓ Enabled' if self.enable_character_library and self.story_memory else '✗ Disabled'}")
85
+
86
+ def create_character(
87
+ self,
88
+ character_description: str,
89
+ character_name: str,
90
+ style: str = "manga"
91
+ ) -> Dict:
92
+ """
93
+ Create a new character with reference images
94
+
95
+ Args:
96
+ character_description: Detailed character description
97
+ character_name: Name of the character
98
+ style: Art style
99
+
100
+ Returns:
101
+ Character profile dictionary with reference images
102
+ """
103
+ if not self.nano_banana:
104
+ raise ValueError("Nano Banana is not enabled. Set ENABLE_NANO_BANANA=true")
105
+
106
+ print(f"\n=== Creating Character: {character_name} ===")
107
+
108
+ # Generate character reference sheet
109
+ print("Generating character reference images...")
110
+ try:
111
+ reference_images = self.nano_banana.generate_character_sheet(
112
+ character_description=character_description,
113
+ style=style
114
+ )
115
+
116
+ if not reference_images:
117
+ raise ValueError("No reference images generated")
118
+
119
+ print(f"Generated {len(reference_images)} reference images")
120
+
121
+ # Extract character profile from first image
122
+ print("Extracting character profile...")
123
+ character_profile = self.character_extractor.extract_character_profile(
124
+ image=reference_images[0],
125
+ character_name=character_name
126
+ )
127
+
128
+ # Save reference images
129
+ image_paths = []
130
+ for i, img in enumerate(reference_images):
131
+ img_filename = f"{character_name.lower().replace(' ', '_')}_ref_{i}.png"
132
+ img_path = os.path.join(self.character_library_dir, "images", img_filename)
133
+ img.save(img_path)
134
+ image_paths.append(img_path)
135
+ print(f"Saved reference image: {img_filename}")
136
+
137
+ # Update profile with image paths
138
+ character_profile["reference_images"] = image_paths
139
+ character_profile["style"] = style
140
+ character_profile["original_description"] = character_description
141
+
142
+ # Save character profile
143
+ profile_filename = f"{character_name.lower().replace(' ', '_')}_profile.json"
144
+ profile_path = os.path.join(self.character_library_dir, "profiles", profile_filename)
145
+ with open(profile_path, 'w') as f:
146
+ json.dump(character_profile, f, indent=2)
147
+ print(f"Saved character profile: {profile_filename}")
148
+
149
+ # Cache character
150
+ self.character_cache[character_name] = character_profile
151
+
152
+ # Store in LlamaIndex if enabled
153
+ if self.story_memory and self.enable_character_library:
154
+ consistency_prompt = self.character_extractor.generate_consistency_prompt(character_profile)
155
+ self.story_memory.store_story(
156
+ story_text=f"Character: {character_name}\n{consistency_prompt}",
157
+ metadata={
158
+ "type": "character_profile",
159
+ "character_name": character_name,
160
+ "style": style
161
+ }
162
+ )
163
+ print(f"Stored {character_name} in character library")
164
+
165
+ print(f"✓ Character '{character_name}' created successfully")
166
+ return character_profile
167
+
168
+ except Exception as e:
169
+ print(f"Error creating character: {e}")
170
+ raise
171
+
172
+ def get_character(self, character_name: str) -> Optional[Dict]:
173
+ """
174
+ Retrieve character from library
175
+
176
+ Args:
177
+ character_name: Name of the character
178
+
179
+ Returns:
180
+ Character profile dictionary or None if not found
181
+ """
182
+ # Check cache first
183
+ if character_name in self.character_cache:
184
+ return self.character_cache[character_name]
185
+
186
+ # Check disk
187
+ profile_filename = f"{character_name.lower().replace(' ', '_')}_profile.json"
188
+ profile_path = os.path.join(self.character_library_dir, "profiles", profile_filename)
189
+
190
+ if os.path.exists(profile_path):
191
+ with open(profile_path, 'r') as f:
192
+ character_profile = json.load(f)
193
+ self.character_cache[character_name] = character_profile
194
+ print(f"Loaded character '{character_name}' from library")
195
+ return character_profile
196
+
197
+ # Check LlamaIndex
198
+ if self.story_memory and self.enable_character_library:
199
+ char_info = self.story_memory.get_character_info(character_name)
200
+ if char_info:
201
+ print(f"Found character '{character_name}' in story memory")
202
+ return char_info
203
+
204
+ print(f"Character '{character_name}' not found in library")
205
+ return None
206
+
207
+ def generate_panel_with_character(
208
+ self,
209
+ scene_description: str,
210
+ character_name: str,
211
+ panel_number: int = 1,
212
+ create_if_missing: bool = False,
213
+ character_description: Optional[str] = None
214
+ ) -> Image.Image:
215
+ """
216
+ Generate a comic panel with character consistency
217
+
218
+ Args:
219
+ scene_description: Description of the scene
220
+ character_name: Name of the character
221
+ panel_number: Panel number in sequence
222
+ create_if_missing: If True, create character if not in library
223
+ character_description: Character description (required if create_if_missing=True)
224
+
225
+ Returns:
226
+ Generated PIL Image
227
+ """
228
+ if not self.nano_banana:
229
+ raise ValueError("Nano Banana is not enabled")
230
+
231
+ # Get or create character
232
+ character_profile = self.get_character(character_name)
233
+
234
+ if not character_profile:
235
+ if create_if_missing and character_description:
236
+ print(f"Character '{character_name}' not found. Creating new character...")
237
+ character_profile = self.create_character(
238
+ character_description=character_description,
239
+ character_name=character_name
240
+ )
241
+ else:
242
+ raise ValueError(f"Character '{character_name}' not found in library. Set create_if_missing=True to create.")
243
+
244
+ # Load reference images
245
+ reference_images = []
246
+ for img_path in character_profile.get("reference_images", []):
247
+ if os.path.exists(img_path):
248
+ reference_images.append(Image.open(img_path))
249
+
250
+ if not reference_images:
251
+ print(f"Warning: No reference images found for {character_name}")
252
+
253
+ # Generate consistency prompt
254
+ consistency_prompt = self.character_extractor.generate_consistency_prompt(character_profile)
255
+
256
+ # Generate panel
257
+ print(f"\nGenerating Panel {panel_number} with character '{character_name}'...")
258
+ generated_image = self.nano_banana.generate_with_character_consistency(
259
+ prompt=scene_description,
260
+ character_references=reference_images,
261
+ character_description=consistency_prompt,
262
+ scene_number=panel_number
263
+ )
264
+
265
+ # Verify consistency
266
+ if panel_number > 1 and reference_images and self.character_extractor:
267
+ print("Verifying character consistency...")
268
+ consistency_result = self.character_extractor.compare_character_consistency(
269
+ image1=reference_images[0],
270
+ image2=generated_image,
271
+ character_name=character_name
272
+ )
273
+ print(f"Consistency score: {consistency_result.get('consistency_score', 0.0):.2%}")
274
+
275
+ if consistency_result.get('consistency_score', 0.0) < 0.7:
276
+ print(f"⚠ Low consistency score! Differences: {consistency_result.get('differences', [])}")
277
+
278
+ return generated_image
279
+
280
+ def generate_story_panels(
281
+ self,
282
+ story_panels: List[Dict],
283
+ main_character_name: str,
284
+ main_character_description: Optional[str] = None
285
+ ) -> List[Image.Image]:
286
+ """
287
+ Generate all panels for a story with character consistency
288
+
289
+ Args:
290
+ story_panels: List of panel dictionaries with 'description' field
291
+ main_character_name: Name of the main character
292
+ main_character_description: Character description (for first-time creation)
293
+
294
+ Returns:
295
+ List of generated PIL Images
296
+ """
297
+ generated_panels = []
298
+
299
+ # Get or create character
300
+ character_profile = self.get_character(main_character_name)
301
+
302
+ if not character_profile and main_character_description:
303
+ print(f"Creating new character '{main_character_name}'...")
304
+ character_profile = self.create_character(
305
+ character_description=main_character_description,
306
+ character_name=main_character_name
307
+ )
308
+
309
+ # Generate each panel
310
+ for i, panel in enumerate(story_panels, 1):
311
+ scene_description = panel.get("description", "")
312
+
313
+ try:
314
+ panel_image = self.generate_panel_with_character(
315
+ scene_description=scene_description,
316
+ character_name=main_character_name,
317
+ panel_number=i,
318
+ create_if_missing=False
319
+ )
320
+ generated_panels.append(panel_image)
321
+ print(f"✓ Panel {i}/{len(story_panels)} generated")
322
+
323
+ except Exception as e:
324
+ print(f"✗ Error generating panel {i}: {e}")
325
+ # Create placeholder
326
+ placeholder = Image.new('RGB', (1024, 1024), color='gray')
327
+ generated_panels.append(placeholder)
328
+
329
+ return generated_panels
330
+
331
+ def generate_panel_with_multiple_characters(
332
+ self,
333
+ scene_description: str,
334
+ character_names: List[str],
335
+ panel_number: int = 1,
336
+ create_missing: bool = False,
337
+ character_descriptions: Optional[Dict[str, str]] = None
338
+ ) -> Image.Image:
339
+ """
340
+ Generate a panel with MULTIPLE characters maintaining consistency
341
+
342
+ Args:
343
+ scene_description: Description of the scene
344
+ character_names: List of character names to include
345
+ panel_number: Panel number in sequence
346
+ create_missing: Create characters if not in library
347
+ character_descriptions: Dict mapping character names to descriptions (for creation)
348
+
349
+ Returns:
350
+ Generated PIL Image with all characters
351
+
352
+ Note:
353
+ Nano Banana Pro can maintain up to 5 people and 6 objects simultaneously.
354
+ For best results, limit to 2-3 main characters per panel.
355
+ """
356
+ if not self.nano_banana:
357
+ raise ValueError("Nano Banana is not enabled")
358
+
359
+ if len(character_names) > 5:
360
+ print(f"⚠️ Warning: {len(character_names)} characters requested. Nano Banana Pro supports up to 5 people.")
361
+ print(f" Using first 5 characters only.")
362
+ character_names = character_names[:5]
363
+
364
+ # Get or create all characters
365
+ all_character_profiles = []
366
+ all_reference_images = []
367
+ combined_consistency_prompts = []
368
+
369
+ for char_name in character_names:
370
+ char_profile = self.get_character(char_name)
371
+
372
+ # Create if missing
373
+ if not char_profile:
374
+ if create_missing and character_descriptions and char_name in character_descriptions:
375
+ print(f"Creating new character '{char_name}'...")
376
+ char_profile = self.create_character(
377
+ character_description=character_descriptions[char_name],
378
+ character_name=char_name
379
+ )
380
+ else:
381
+ raise ValueError(f"Character '{char_name}' not found. Set create_missing=True and provide description.")
382
+
383
+ all_character_profiles.append(char_profile)
384
+
385
+ # Load reference images (limit per character to fit within 14 total)
386
+ refs_per_character = min(14 // len(character_names), 4)
387
+ char_refs = []
388
+ for img_path in char_profile.get("reference_images", [])[:refs_per_character]:
389
+ if os.path.exists(img_path):
390
+ char_refs.append(Image.open(img_path))
391
+
392
+ all_reference_images.extend(char_refs)
393
+
394
+ # Build consistency prompt
395
+ consistency_prompt = self.character_extractor.generate_consistency_prompt(char_profile)
396
+ combined_consistency_prompts.append(f"CHARACTER {len(all_character_profiles)} ({char_name}):\n{consistency_prompt}")
397
+
398
+ # Build combined prompt
399
+ characters_description = "\n\n".join(combined_consistency_prompts)
400
+
401
+ full_prompt = f"""IMPORTANT: Maintain EXACT character appearances from reference images.
402
+
403
+ {characters_description}
404
+
405
+ SCENE (Panel {panel_number}):
406
+ {scene_description}
407
+
408
+ Generate this scene with ALL {len(character_names)} characters maintaining their EXACT appearance from the references.
409
+ Ensure each character is visually distinct and identifiable.
410
+ """
411
+
412
+ print(f"\nGenerating Panel {panel_number} with {len(character_names)} characters:")
413
+ for name in character_names:
414
+ print(f" - {name}")
415
+
416
+ # Generate with all reference images
417
+ try:
418
+ generated_image = self.nano_banana.generate_image(
419
+ prompt=full_prompt,
420
+ reference_images=all_reference_images,
421
+ aspect_ratio="1:1",
422
+ num_images=1
423
+ )[0]
424
+
425
+ print(f"✓ Panel {panel_number} generated with {len(character_names)} characters")
426
+ return generated_image
427
+
428
+ except Exception as e:
429
+ print(f"✗ Error generating multi-character panel: {e}")
430
+ raise
431
+
432
+ def list_characters(self) -> List[str]:
433
+ """List all characters in the library"""
434
+ characters = []
435
+
436
+ # Check profiles directory
437
+ profiles_dir = os.path.join(self.character_library_dir, "profiles")
438
+ if os.path.exists(profiles_dir):
439
+ for filename in os.listdir(profiles_dir):
440
+ if filename.endswith("_profile.json"):
441
+ character_name = filename.replace("_profile.json", "").replace("_", " ").title()
442
+ characters.append(character_name)
443
+
444
+ return sorted(characters)
445
+
446
+ def get_character_info_summary(self, character_name: str) -> str:
447
+ """Get a human-readable summary of character information"""
448
+ character_profile = self.get_character(character_name)
449
+
450
+ if not character_profile:
451
+ return f"Character '{character_name}' not found."
452
+
453
+ summary = f"=== Character: {character_name} ===\n"
454
+ summary += f"Physical Description: {character_profile.get('physical_description', 'N/A')}\n"
455
+ summary += f"Age Range: {character_profile.get('age_range', 'N/A')}\n"
456
+ summary += f"Hair: {character_profile.get('hair', 'N/A')}\n"
457
+ summary += f"Clothing: {character_profile.get('clothing', 'N/A')}\n"
458
+ summary += f"Body Type: {character_profile.get('body_type', 'N/A')}\n"
459
+ summary += f"Art Style: {character_profile.get('style', 'N/A')}\n"
460
+ summary += f"Reference Images: {len(character_profile.get('reference_images', []))}\n"
461
+
462
+ return summary
463
+
464
+ def generate_comic_from_prompt(
465
+ self,
466
+ story_prompt: str,
467
+ num_panels: int = 3,
468
+ art_style: str = "manga",
469
+ include_surprise_character: bool = True,
470
+ output_dir: str = "./auto_generated_comics"
471
+ ) -> Dict:
472
+ """
473
+ FULLY AUTOMATIC: Generate complete comic from just a text prompt
474
+
475
+ This method automatically:
476
+ 1. Extracts ALL characters from the prompt
477
+ 2. Adds a surprise character (optional)
478
+ 3. Generates detailed descriptions for each character
479
+ 4. Creates character reference sheets
480
+ 5. Breaks story into panel-by-panel scenes
481
+ 6. Generates all panels with consistent characters
482
+ 7. Saves everything to disk
483
+
484
+ Args:
485
+ story_prompt: User's story idea (e.g., "A yogi teaching meditation...")
486
+ num_panels: Number of comic panels to generate
487
+ art_style: Art style (manga, anime, comic, etc.)
488
+ include_surprise_character: Add a surprise character for intrigue
489
+ output_dir: Directory to save generated comic
490
+
491
+ Returns:
492
+ Dictionary with:
493
+ - characters: Dict of created characters
494
+ - panels: List of generated panel images
495
+ - panel_info: List of panel descriptions
496
+ - surprise_character: Name of surprise character (if added)
497
+
498
+ Example:
499
+ ```python
500
+ manager = CharacterConsistencyManager()
501
+ result = manager.generate_comic_from_prompt(
502
+ "A yogi teaching a young aspirant meditation and levitation in a jungle"
503
+ )
504
+ # Automatically creates: Yogi, Aspirant, + Surprise character
505
+ # Generates 3 panels with all characters consistent
506
+ ```
507
+ """
508
+ if not self.auto_intelligence or not self.nano_banana:
509
+ raise ValueError("Auto Intelligence and Nano Banana must be enabled")
510
+
511
+ print("\n" + "="*70)
512
+ print(" 🤖 AUTOMATIC COMIC GENERATION")
513
+ print("="*70)
514
+ print(f"Story: {story_prompt}")
515
+ print(f"Panels: {num_panels}")
516
+ print(f"Style: {art_style}")
517
+ print(f"Surprise Character: {'Yes' if include_surprise_character else 'No'}")
518
+ print("="*70)
519
+
520
+ # Create output directory
521
+ os.makedirs(output_dir, exist_ok=True)
522
+
523
+ # Step 1: Extract characters automatically
524
+ print("\n📖 Step 1: Extracting characters from prompt...")
525
+ characters_dict = self.auto_intelligence.extract_characters_from_prompt(
526
+ story_prompt=story_prompt,
527
+ art_style=art_style,
528
+ include_surprise_character=include_surprise_character
529
+ )
530
+
531
+ surprise_char_name = None
532
+ if include_surprise_character:
533
+ # Identify surprise character (last one added)
534
+ all_names = list(characters_dict.keys())
535
+ surprise_char_name = all_names[-1] if len(all_names) > 2 else None
536
+
537
+ # Step 2: Create all characters with reference sheets
538
+ print(f"\n🎨 Step 2: Creating {len(characters_dict)} characters...")
539
+ created_characters = {}
540
+
541
+ for char_name, char_description in characters_dict.items():
542
+ # Check if character already exists
543
+ existing = self.get_character(char_name)
544
+ if existing:
545
+ print(f" ✓ {char_name} already in library (reusing)")
546
+ created_characters[char_name] = existing
547
+ else:
548
+ char_type = "🎁 SURPRISE" if char_name == surprise_char_name else "📖"
549
+ print(f" {char_type} Creating {char_name}...")
550
+ try:
551
+ profile = self.create_character(
552
+ character_description=char_description,
553
+ character_name=char_name,
554
+ style=art_style
555
+ )
556
+ created_characters[char_name] = profile
557
+ print(f" ✓ {char_name} created")
558
+ except Exception as e:
559
+ print(f" ✗ Error creating {char_name}: {e}")
560
+
561
+ # Step 3: Generate panel breakdown
562
+ print(f"\n📋 Step 3: Breaking story into {num_panels} panels...")
563
+ panel_breakdown = self.auto_intelligence.generate_panel_breakdown(
564
+ story_prompt=story_prompt,
565
+ num_panels=num_panels
566
+ )
567
+
568
+ # Step 4: Generate each panel
569
+ print(f"\n🖼️ Step 4: Generating {num_panels} panels...")
570
+ generated_panels = []
571
+ panel_info = []
572
+
573
+ for i, panel_desc in enumerate(panel_breakdown[:num_panels], 1):
574
+ scene_desc = panel_desc.get("scene_description", story_prompt)
575
+ panel_characters = panel_desc.get("characters", list(created_characters.keys()))
576
+
577
+ # Filter to only use created characters
578
+ valid_characters = [c for c in panel_characters if c in created_characters]
579
+
580
+ # If no characters specified, use all created characters
581
+ if not valid_characters:
582
+ valid_characters = list(created_characters.keys())[:3] # Limit to 3 for best results
583
+
584
+ print(f"\n Panel {i}/{num_panels}:")
585
+ print(f" Scene: {scene_desc[:80]}...")
586
+ print(f" Characters: {', '.join(valid_characters)}")
587
+
588
+ try:
589
+ # Generate panel with all characters
590
+ if len(valid_characters) == 1:
591
+ panel_image = self.generate_panel_with_character(
592
+ scene_description=scene_desc,
593
+ character_name=valid_characters[0],
594
+ panel_number=i
595
+ )
596
+ else:
597
+ panel_image = self.generate_panel_with_multiple_characters(
598
+ scene_description=scene_desc,
599
+ character_names=valid_characters,
600
+ panel_number=i
601
+ )
602
+
603
+ # Save panel
604
+ panel_filename = f"panel_{i:02d}.png"
605
+ panel_path = os.path.join(output_dir, panel_filename)
606
+ panel_image.save(panel_path)
607
+
608
+ generated_panels.append(panel_image)
609
+ panel_info.append({
610
+ "panel_number": i,
611
+ "characters": valid_characters,
612
+ "scene": scene_desc,
613
+ "filename": panel_filename,
614
+ "path": panel_path
615
+ })
616
+
617
+ print(f" ✓ Saved: {panel_filename}")
618
+
619
+ except Exception as e:
620
+ print(f" ✗ Error generating panel {i}: {e}")
621
+
622
+ # Step 5: Summary
623
+ print("\n" + "="*70)
624
+ print(" ✅ COMIC GENERATION COMPLETE")
625
+ print("="*70)
626
+ print(f"\nCharacters Created: {len(created_characters)}")
627
+ for char_name in created_characters.keys():
628
+ char_type = "🎁 SURPRISE" if char_name == surprise_char_name else "📖"
629
+ print(f" {char_type} {char_name}")
630
+
631
+ print(f"\nPanels Generated: {len(generated_panels)}/{num_panels}")
632
+ print(f"Output Directory: {output_dir}")
633
+
634
+ if surprise_char_name:
635
+ print(f"\n🎁 Surprise Character: {surprise_char_name}")
636
+ print(f" Check the panels to see how they appear in the story!")
637
+
638
+ # Return results
639
+ return {
640
+ "characters": created_characters,
641
+ "panels": generated_panels,
642
+ "panel_info": panel_info,
643
+ "surprise_character": surprise_char_name,
644
+ "output_dir": output_dir,
645
+ "story_prompt": story_prompt
646
+ }
647
+
648
+
649
+ # Example usage
650
+ if __name__ == "__main__":
651
+ print("=== Character Consistency Manager Test ===\n")
652
+
653
+ # Initialize manager
654
+ manager = CharacterConsistencyManager()
655
+
656
+ # Example: Create a character
657
+ if manager.nano_banana:
658
+ print("Test 1: Creating a character")
659
+ try:
660
+ yogi_profile = manager.create_character(
661
+ character_description="Elderly Indian yogi with long white beard, saffron robes, peaceful expression, meditation beads",
662
+ character_name="Master Yogi",
663
+ style="manga"
664
+ )
665
+ print("✓ Character created successfully\n")
666
+ except Exception as e:
667
+ print(f"✗ Test 1 failed: {e}\n")
668
+
669
+ # Example: List characters
670
+ print("Test 2: Listing characters")
671
+ characters = manager.list_characters()
672
+ print(f"Characters in library: {characters}\n")
673
+
674
+ # Example: Generate panel with character
675
+ if characters:
676
+ print("Test 3: Generating panel with character")
677
+ try:
678
+ panel = manager.generate_panel_with_character(
679
+ scene_description="The yogi meditating peacefully in a vibrant jungle",
680
+ character_name=characters[0],
681
+ panel_number=1
682
+ )
683
+ print("✓ Panel generated successfully\n")
684
+ except Exception as e:
685
+ print(f"✗ Test 3 failed: {e}\n")
686
+ else:
687
+ print("Nano Banana not enabled. Set ENABLE_NANO_BANANA=true in .env to test.")
688
+
689
+ print("=== Tests Complete ===")
character_extractor.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Extraction Module using Gemini Vision
3
+ Analyzes comic panels to extract detailed character descriptions and visual attributes
4
+ """
5
+
6
+ import os
7
+ import base64
8
+ from io import BytesIO
9
+ from typing import Dict, List, Optional, Tuple, Union
10
+ from PIL import Image
11
+ import google.generativeai as genai
12
+ import json
13
+ import re
14
+ from config import Config
15
+
16
+
17
+ class CharacterExtractor:
18
+ """Extract character information from images using Gemini Vision"""
19
+
20
+ def __init__(self, api_key: Optional[str] = None):
21
+ """
22
+ Initialize Character Extractor
23
+
24
+ Args:
25
+ api_key: Gemini API key (defaults to Config.GEMINI_API_KEY)
26
+ """
27
+ self.api_key = api_key or Config.GEMINI_API_KEY
28
+ if not self.api_key:
29
+ raise ValueError("Gemini API key is required")
30
+
31
+ genai.configure(api_key=self.api_key)
32
+
33
+ # Try Gemini 2.5 Flash, fallback to 2.0 Flash
34
+ try:
35
+ self.model = genai.GenerativeModel('gemini-2.5-flash')
36
+ self.model_name = 'gemini-2.5-flash'
37
+ except:
38
+ try:
39
+ self.model = genai.GenerativeModel('gemini-2.0-flash')
40
+ self.model_name = 'gemini-2.0-flash'
41
+ except:
42
+ self.model = genai.GenerativeModel('gemini-2.0-flash-exp')
43
+ self.model_name = 'gemini-2.0-flash-exp'
44
+
45
+ print(f"✅ CharacterExtractor using {self.model_name.upper().replace('-', ' ')}")
46
+
47
+ def extract_character_profile(
48
+ self,
49
+ image: Union[str, Image.Image, bytes],
50
+ character_name: Optional[str] = None
51
+ ) -> Dict:
52
+ """
53
+ Extract detailed character profile from image
54
+
55
+ Args:
56
+ image: Image file path, PIL Image, or bytes
57
+ character_name: Optional known character name
58
+
59
+ Returns:
60
+ Dictionary with character profile:
61
+ {
62
+ "name": str,
63
+ "physical_description": str,
64
+ "age_range": str,
65
+ "clothing": str,
66
+ "hair": str,
67
+ "facial_features": str,
68
+ "body_type": str,
69
+ "distinctive_features": List[str],
70
+ "color_palette": List[str],
71
+ "art_style": str,
72
+ "confidence_score": float
73
+ }
74
+ """
75
+ # Prepare image
76
+ if isinstance(image, str):
77
+ pil_image = Image.open(image)
78
+ elif isinstance(image, bytes):
79
+ pil_image = Image.open(BytesIO(image))
80
+ else:
81
+ pil_image = image
82
+
83
+ # Build extraction prompt
84
+ character_context = f"the character named {character_name}" if character_name else "the main character"
85
+
86
+ prompt = f"""Analyze this image and extract a detailed character profile for {character_context}.
87
+
88
+ Provide a comprehensive analysis in JSON format with these fields:
89
+
90
+ 1. **name**: Character's apparent name or role (if visible in the image or provided)
91
+ 2. **physical_description**: Overall physical appearance (2-3 sentences)
92
+ 3. **age_range**: Estimated age range (e.g., "elderly", "young adult", "child")
93
+ 4. **clothing**: Detailed description of clothing, accessories, and attire
94
+ 5. **hair**: Hair color, style, length, and notable features
95
+ 6. **facial_features**: Detailed facial characteristics (eyes, nose, expression, wrinkles, etc.)
96
+ 7. **body_type**: Body build and posture (lean, muscular, stocky, etc.)
97
+ 8. **distinctive_features**: List of unique identifying features (scars, tattoos, jewelry, etc.)
98
+ 9. **color_palette**: Main colors associated with the character (clothing, hair, skin tone)
99
+ 10. **art_style**: The artistic style of the image (manga, anime, realistic, cartoon, etc.)
100
+ 11. **confidence_score**: Your confidence in this analysis (0.0 to 1.0)
101
+
102
+ Be extremely detailed and precise. Focus on features that would ensure visual consistency across multiple images.
103
+
104
+ Return ONLY valid JSON with no additional text."""
105
+
106
+ try:
107
+ # Generate analysis
108
+ response = self.model.generate_content([pil_image, prompt])
109
+
110
+ # Extract JSON from response
111
+ response_text = response.text.strip()
112
+
113
+ # Remove markdown code blocks if present
114
+ if response_text.startswith("```"):
115
+ lines = response_text.split("\n")
116
+ if lines[0].startswith("```"):
117
+ lines = lines[1:]
118
+ if lines and lines[-1].strip() == "```":
119
+ lines = lines[:-1]
120
+ response_text = "\n".join(lines)
121
+
122
+ # Parse JSON
123
+ character_profile = json.loads(response_text)
124
+
125
+ # Add character name if provided
126
+ if character_name:
127
+ character_profile["name"] = character_name
128
+
129
+ # Ensure all required fields exist
130
+ required_fields = [
131
+ "name", "physical_description", "age_range", "clothing",
132
+ "hair", "facial_features", "body_type", "distinctive_features",
133
+ "color_palette", "art_style", "confidence_score"
134
+ ]
135
+
136
+ for field in required_fields:
137
+ if field not in character_profile:
138
+ character_profile[field] = "Unknown" if field != "distinctive_features" and field != "color_palette" else []
139
+
140
+ print(f"Extracted character profile for '{character_profile.get('name', 'Unknown')}'")
141
+ return character_profile
142
+
143
+ except json.JSONDecodeError as e:
144
+ print(f"Error parsing JSON response: {e}")
145
+ print(f"Raw response: {response_text}")
146
+
147
+ # Fallback: Create basic profile
148
+ return self._create_fallback_profile(character_name)
149
+
150
+ except Exception as e:
151
+ print(f"Error extracting character profile: {e}")
152
+ return self._create_fallback_profile(character_name)
153
+
154
+ def _create_fallback_profile(self, character_name: Optional[str] = None) -> Dict:
155
+ """Create a basic fallback profile when extraction fails"""
156
+ return {
157
+ "name": character_name or "Unknown",
158
+ "physical_description": "Unable to extract detailed description",
159
+ "age_range": "unknown",
160
+ "clothing": "unknown",
161
+ "hair": "unknown",
162
+ "facial_features": "unknown",
163
+ "body_type": "unknown",
164
+ "distinctive_features": [],
165
+ "color_palette": [],
166
+ "art_style": "unknown",
167
+ "confidence_score": 0.0
168
+ }
169
+
170
+ def generate_consistency_prompt(self, character_profile: Dict) -> str:
171
+ """
172
+ Generate a detailed consistency prompt from character profile
173
+
174
+ Args:
175
+ character_profile: Character profile dictionary
176
+
177
+ Returns:
178
+ Detailed prompt text for maintaining character consistency
179
+ """
180
+ prompt_parts = []
181
+
182
+ # Name
183
+ if character_profile.get("name") and character_profile["name"] != "Unknown":
184
+ prompt_parts.append(f"Character: {character_profile['name']}")
185
+
186
+ # Physical description
187
+ if character_profile.get("physical_description"):
188
+ prompt_parts.append(character_profile["physical_description"])
189
+
190
+ # Age
191
+ if character_profile.get("age_range") and character_profile["age_range"] != "unknown":
192
+ prompt_parts.append(f"Age: {character_profile['age_range']}")
193
+
194
+ # Hair
195
+ if character_profile.get("hair") and character_profile["hair"] != "unknown":
196
+ prompt_parts.append(f"Hair: {character_profile['hair']}")
197
+
198
+ # Clothing
199
+ if character_profile.get("clothing") and character_profile["clothing"] != "unknown":
200
+ prompt_parts.append(f"Clothing: {character_profile['clothing']}")
201
+
202
+ # Facial features
203
+ if character_profile.get("facial_features") and character_profile["facial_features"] != "unknown":
204
+ prompt_parts.append(f"Facial features: {character_profile['facial_features']}")
205
+
206
+ # Body type
207
+ if character_profile.get("body_type") and character_profile["body_type"] != "unknown":
208
+ prompt_parts.append(f"Body type: {character_profile['body_type']}")
209
+
210
+ # Distinctive features
211
+ if character_profile.get("distinctive_features") and character_profile["distinctive_features"]:
212
+ features_str = ", ".join(character_profile["distinctive_features"])
213
+ prompt_parts.append(f"Distinctive features: {features_str}")
214
+
215
+ # Art style
216
+ if character_profile.get("art_style") and character_profile["art_style"] != "unknown":
217
+ prompt_parts.append(f"Art style: {character_profile['art_style']}")
218
+
219
+ return ". ".join(prompt_parts)
220
+
221
+ def crop_character_from_image(
222
+ self,
223
+ image: Union[str, Image.Image, bytes],
224
+ character_name: Optional[str] = None
225
+ ) -> Optional[Image.Image]:
226
+ """
227
+ Attempt to crop the character from the image using Gemini Vision
228
+ (Note: Actual bounding box detection would require additional ML models like YOLO or SAM)
229
+
230
+ For now, this is a placeholder that returns the full image.
231
+ Future enhancement: Use object detection to crop character bounding box.
232
+
233
+ Args:
234
+ image: Image file path, PIL Image, or bytes
235
+ character_name: Optional character name for context
236
+
237
+ Returns:
238
+ Cropped PIL Image or None if failed
239
+ """
240
+ # Prepare image
241
+ if isinstance(image, str):
242
+ pil_image = Image.open(image)
243
+ elif isinstance(image, bytes):
244
+ pil_image = Image.open(BytesIO(image))
245
+ else:
246
+ pil_image = image
247
+
248
+ # TODO: Implement actual character cropping using object detection
249
+ # For now, return the full image as a placeholder
250
+ print(f"Character cropping not yet implemented. Returning full image.")
251
+ return pil_image
252
+
253
+ def compare_character_consistency(
254
+ self,
255
+ image1: Union[str, Image.Image, bytes],
256
+ image2: Union[str, Image.Image, bytes],
257
+ character_name: Optional[str] = None
258
+ ) -> Dict:
259
+ """
260
+ Compare two images to verify character consistency
261
+
262
+ Args:
263
+ image1: First image
264
+ image2: Second image
265
+ character_name: Optional character name
266
+
267
+ Returns:
268
+ Dictionary with consistency analysis:
269
+ {
270
+ "is_consistent": bool,
271
+ "consistency_score": float,
272
+ "differences": List[str],
273
+ "analysis": str
274
+ }
275
+ """
276
+ # Prepare images
277
+ if isinstance(image1, str):
278
+ pil_image1 = Image.open(image1)
279
+ elif isinstance(image1, bytes):
280
+ pil_image1 = Image.open(BytesIO(image1))
281
+ else:
282
+ pil_image1 = image1
283
+
284
+ if isinstance(image2, str):
285
+ pil_image2 = Image.open(image2)
286
+ elif isinstance(image2, bytes):
287
+ pil_image2 = Image.open(BytesIO(image2))
288
+ else:
289
+ pil_image2 = image2
290
+
291
+ character_context = f"the character {character_name}" if character_name else "the main character"
292
+
293
+ prompt = f"""Compare {character_context} in these two images and analyze visual consistency.
294
+
295
+ Provide analysis in JSON format:
296
+
297
+ 1. **is_consistent**: true/false - Are the characters the same person?
298
+ 2. **consistency_score**: 0.0 to 1.0 - How consistent is the character appearance?
299
+ 3. **differences**: List of specific visual differences found
300
+ 4. **analysis**: Detailed explanation of your assessment
301
+
302
+ Focus on:
303
+ - Facial features (eyes, nose, face shape)
304
+ - Hair (color, style, length)
305
+ - Clothing and accessories
306
+ - Body type and proportions
307
+ - Art style consistency
308
+
309
+ Return ONLY valid JSON."""
310
+
311
+ try:
312
+ response = self.model.generate_content([
313
+ "Image 1:", pil_image1,
314
+ "Image 2:", pil_image2,
315
+ prompt
316
+ ])
317
+
318
+ response_text = response.text.strip()
319
+
320
+ # Remove markdown code blocks
321
+ if response_text.startswith("```"):
322
+ lines = response_text.split("\n")
323
+ if lines[0].startswith("```"):
324
+ lines = lines[1:]
325
+ if lines and lines[-1].strip() == "```":
326
+ lines = lines[:-1]
327
+ response_text = "\n".join(lines)
328
+
329
+ consistency_result = json.loads(response_text)
330
+
331
+ print(f"Consistency score: {consistency_result.get('consistency_score', 0.0):.2f}")
332
+ return consistency_result
333
+
334
+ except Exception as e:
335
+ print(f"Error comparing character consistency: {e}")
336
+ return {
337
+ "is_consistent": False,
338
+ "consistency_score": 0.0,
339
+ "differences": ["Unable to analyze"],
340
+ "analysis": f"Error: {str(e)}"
341
+ }
342
+
343
+
344
+ # Example usage
345
+ if __name__ == "__main__":
346
+ extractor = CharacterExtractor()
347
+
348
+ print("=== Character Extractor Test ===")
349
+ print("Note: Requires actual image file to test")
350
+ print("Example usage:")
351
+ print("""
352
+ # Extract character profile
353
+ profile = extractor.extract_character_profile("panel1.png", character_name="Yogi")
354
+ print(json.dumps(profile, indent=2))
355
+
356
+ # Generate consistency prompt
357
+ consistency_prompt = extractor.generate_consistency_prompt(profile)
358
+ print(consistency_prompt)
359
+
360
+ # Compare consistency between two panels
361
+ consistency = extractor.compare_character_consistency("panel1.png", "panel3.png", "Yogi")
362
+ print(f"Consistency score: {consistency['consistency_score']}")
363
+ """)
comic_generator.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Comic Generator with Character Consistency Integration
3
+ Safe wrapper that integrates character consistency system with automatic fallback to legacy system
4
+ """
5
+
6
+ import asyncio
7
+ import os
8
+ from typing import Dict, List, Optional, Union
9
+ from PIL import Image
10
+ from config import Config
11
+
12
+ # Try to import character consistency system (optional dependency)
13
+ try:
14
+ from character_consistency_manager import CharacterConsistencyManager
15
+ CHARACTER_CONSISTENCY_AVAILABLE = True
16
+ print("✓ Character Consistency system available")
17
+ except ImportError as e:
18
+ CHARACTER_CONSISTENCY_AVAILABLE = False
19
+ print(f"⚠️ Character Consistency not available - using legacy system")
20
+ print(f" Import error: {e}")
21
+
22
+
23
+ class ComicGeneratorWithConsistency:
24
+ """
25
+ Unified comic generator with character consistency support and legacy fallback
26
+
27
+ This class provides a safe integration layer between the new character consistency
28
+ system and the existing comic generation logic in app_comic.py
29
+
30
+ Behavior:
31
+ - If ENABLE_NANO_BANANA=true AND system available: Use character consistency
32
+ - If character consistency fails OR disabled: Automatic fallback to legacy system
33
+ - Legacy system always available as safety net
34
+ """
35
+
36
+ def __init__(self):
37
+ """Initialize comic generator with optional character consistency"""
38
+ self.consistency_enabled = (
39
+ CHARACTER_CONSISTENCY_AVAILABLE and
40
+ Config.ENABLE_NANO_BANANA
41
+ )
42
+
43
+ self.consistency_manager = None
44
+
45
+ if self.consistency_enabled:
46
+ try:
47
+ self.consistency_manager = CharacterConsistencyManager()
48
+ print("✓ Character Consistency Manager initialized")
49
+ except Exception as e:
50
+ print(f"⚠️ Failed to initialize Character Consistency: {e}")
51
+ print("🔄 Will use legacy system")
52
+ self.consistency_enabled = False
53
+
54
+ # Stats tracking
55
+ self.consistency_success_count = 0
56
+ self.consistency_failure_count = 0
57
+ self.fallback_count = 0
58
+
59
+ async def generate_comic_from_prompt(
60
+ self,
61
+ story_prompt: str,
62
+ num_panels: int = 3,
63
+ art_style: str = "manga",
64
+ include_surprise_character: bool = True,
65
+ force_legacy: bool = False,
66
+ output_dir: Optional[str] = None
67
+ ) -> Dict:
68
+ """
69
+ Generate comic with automatic character consistency or legacy fallback
70
+
71
+ Args:
72
+ story_prompt: User's story prompt
73
+ num_panels: Number of panels to generate
74
+ art_style: Art style for the comic
75
+ include_surprise_character: Add AI-generated surprise character
76
+ force_legacy: Force use of legacy system (for testing)
77
+ output_dir: Output directory for generated files
78
+
79
+ Returns:
80
+ Dictionary with generation results:
81
+ {
82
+ "success": bool,
83
+ "method": str, # "character_consistency" or "legacy"
84
+ "panels": List[Image],
85
+ "characters": Dict[str, str], # Only if character_consistency used
86
+ "output_dir": str,
87
+ "panel_info": List[Dict], # Panel metadata
88
+ "error": Optional[str]
89
+ }
90
+ """
91
+
92
+ # Determine which system to use
93
+ use_consistency = (
94
+ self.consistency_enabled and
95
+ not force_legacy and
96
+ self.consistency_manager is not None
97
+ )
98
+
99
+ if use_consistency:
100
+ print(f"\n🎨 Generating comic with CHARACTER CONSISTENCY system...")
101
+ print(f" Story: {story_prompt[:100]}...")
102
+ print(f" Panels: {num_panels}, Style: {art_style}")
103
+
104
+ try:
105
+ result = await self._generate_with_consistency(
106
+ story_prompt=story_prompt,
107
+ num_panels=num_panels,
108
+ art_style=art_style,
109
+ include_surprise_character=include_surprise_character,
110
+ output_dir=output_dir
111
+ )
112
+
113
+ if result["success"]:
114
+ self.consistency_success_count += 1
115
+ print(f"✓ Character consistency generation successful!")
116
+ return result
117
+ else:
118
+ print(f"⚠️ Character consistency failed: {result.get('error')}")
119
+ print(f"🔄 Falling back to legacy system...")
120
+ self.consistency_failure_count += 1
121
+
122
+ except Exception as e:
123
+ print(f"⚠️ Character consistency exception: {e}")
124
+ print(f"🔄 Falling back to legacy system...")
125
+ self.consistency_failure_count += 1
126
+
127
+ # Fallback to legacy system
128
+ self.fallback_count += 1
129
+ print(f"\n🎨 Generating comic with LEGACY system...")
130
+
131
+ return await self._generate_with_legacy(
132
+ story_prompt=story_prompt,
133
+ num_panels=num_panels,
134
+ art_style=art_style,
135
+ output_dir=output_dir
136
+ )
137
+
138
+ async def _generate_with_consistency(
139
+ self,
140
+ story_prompt: str,
141
+ num_panels: int,
142
+ art_style: str,
143
+ include_surprise_character: bool,
144
+ output_dir: Optional[str]
145
+ ) -> Dict:
146
+ """
147
+ Generate comic using character consistency system
148
+
149
+ This wraps the CharacterConsistencyManager.generate_comic_from_prompt method
150
+ in a safe async context with proper error handling
151
+ """
152
+
153
+ try:
154
+ # Call character consistency system (runs in executor for async compatibility)
155
+ loop = asyncio.get_event_loop()
156
+ result = await loop.run_in_executor(
157
+ None,
158
+ self.consistency_manager.generate_comic_from_prompt,
159
+ story_prompt,
160
+ num_panels,
161
+ art_style,
162
+ include_surprise_character,
163
+ output_dir or "./auto_generated_comics"
164
+ )
165
+
166
+ # Add success flag and method
167
+ result["success"] = True
168
+ result["method"] = "character_consistency"
169
+
170
+ return result
171
+
172
+ except Exception as e:
173
+ return {
174
+ "success": False,
175
+ "method": "character_consistency",
176
+ "error": str(e),
177
+ "panels": [],
178
+ "characters": {},
179
+ "panel_info": []
180
+ }
181
+
182
+ async def _generate_with_legacy(
183
+ self,
184
+ story_prompt: str,
185
+ num_panels: int,
186
+ art_style: str,
187
+ output_dir: Optional[str]
188
+ ) -> Dict:
189
+ """
190
+ Generate comic using legacy system (placeholder for app_comic.py integration)
191
+
192
+ NOTE: This is a placeholder. In actual integration, this would call the
193
+ existing comic generation logic from app_comic.py
194
+
195
+ For now, returns a structure indicating legacy mode was used
196
+ """
197
+
198
+ return {
199
+ "success": True,
200
+ "method": "legacy",
201
+ "panels": [],
202
+ "characters": {},
203
+ "panel_info": [
204
+ {
205
+ "panel_number": i + 1,
206
+ "characters": [],
207
+ "filename": f"legacy_panel_{i+1}.png"
208
+ }
209
+ for i in range(num_panels)
210
+ ],
211
+ "output_dir": output_dir or "./generated_comics",
212
+ "note": "Legacy system placeholder - integrate with app_comic.py logic"
213
+ }
214
+
215
+ def get_stats(self) -> Dict:
216
+ """Get generation statistics"""
217
+ total = self.consistency_success_count + self.consistency_failure_count + self.fallback_count
218
+
219
+ return {
220
+ "total_generations": total,
221
+ "consistency_enabled": self.consistency_enabled,
222
+ "consistency_available": CHARACTER_CONSISTENCY_AVAILABLE,
223
+ "consistency_successes": self.consistency_success_count,
224
+ "consistency_failures": self.consistency_failure_count,
225
+ "legacy_fallbacks": self.fallback_count,
226
+ "consistency_rate": (
227
+ f"{(self.consistency_success_count / total * 100):.1f}%"
228
+ if total > 0 else "N/A"
229
+ )
230
+ }
231
+
232
+ def print_stats(self):
233
+ """Print generation statistics"""
234
+ stats = self.get_stats()
235
+
236
+ print("\n" + "="*70)
237
+ print(" COMIC GENERATOR STATISTICS")
238
+ print("="*70)
239
+ print(f"Character Consistency Available: {stats['consistency_available']}")
240
+ print(f"Character Consistency Enabled: {stats['consistency_enabled']}")
241
+ print(f"\nTotal Generations: {stats['total_generations']}")
242
+ print(f" ✓ Consistency Successes: {stats['consistency_successes']}")
243
+ print(f" ⚠ Consistency Failures: {stats['consistency_failures']}")
244
+ print(f" 🔄 Legacy Fallbacks: {stats['legacy_fallbacks']}")
245
+ print(f"\nConsistency Success Rate: {stats['consistency_rate']}")
246
+ print("="*70)
247
+
248
+
249
+ # Example usage and testing
250
+ async def test_comic_generator():
251
+ """Test the comic generator with both systems"""
252
+
253
+ print("\n" + "="*70)
254
+ print(" COMIC GENERATOR TEST")
255
+ print("="*70)
256
+
257
+ generator = ComicGeneratorWithConsistency()
258
+
259
+ test_prompt = "A yogi teaching a young aspirant the rules of meditation and mindfulness leading to levitation by his own example in a beautiful vibrant colorful jungle"
260
+
261
+ # Test 1: Character consistency (if available)
262
+ print("\n--- Test 1: Character Consistency Mode ---")
263
+ result1 = await generator.generate_comic_from_prompt(
264
+ story_prompt=test_prompt,
265
+ num_panels=3,
266
+ art_style="manga",
267
+ include_surprise_character=True
268
+ )
269
+
270
+ print(f"\nResult: {result1['method']}")
271
+ print(f"Success: {result1['success']}")
272
+ if result1.get('characters'):
273
+ print(f"Characters: {list(result1['characters'].keys())}")
274
+
275
+ # Test 2: Force legacy mode
276
+ print("\n--- Test 2: Forced Legacy Mode ---")
277
+ result2 = await generator.generate_comic_from_prompt(
278
+ story_prompt=test_prompt,
279
+ num_panels=3,
280
+ art_style="manga",
281
+ force_legacy=True
282
+ )
283
+
284
+ print(f"\nResult: {result2['method']}")
285
+ print(f"Success: {result2['success']}")
286
+
287
+ # Print stats
288
+ generator.print_stats()
289
+
290
+
291
+ if __name__ == "__main__":
292
+ # Run test
293
+ asyncio.run(test_comic_generator())
comic_layout.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Comic Panel Layout System for MythForge
2
+
3
+ Creates professional manga page layouts by compositing individual panels
4
+ into 2x3 grids with proper spacing, borders, and gutters.
5
+ """
6
+
7
+ from typing import List, Tuple, Optional
8
+ from PIL import Image, ImageDraw
9
+ from config import Config
10
+
11
+
12
+ class ComicLayout:
13
+ """Comic panel layout compositor for manga pages"""
14
+
15
+ def __init__(self):
16
+ """Initialize comic layout system"""
17
+ # Default manga page dimensions
18
+ self.panel_width = Config.COMIC_PANEL_WIDTH # 1024
19
+ self.panel_height = Config.COMIC_PANEL_HEIGHT # 1024
20
+
21
+ # Layout settings
22
+ self.gutter_width = 20 # Space between panels
23
+ self.page_margin = 40 # Margin around entire page
24
+ self.border_width = 3 # Panel border thickness
25
+ self.border_color = (0, 0, 0) # Black borders
26
+
27
+ def create_manga_page(
28
+ self,
29
+ panels: List[Image.Image],
30
+ layout: str = "2x3"
31
+ ) -> Image.Image:
32
+ """
33
+ Create a manga page from 6 panels in 2x3 grid
34
+
35
+ Args:
36
+ panels: List of 6 PIL Images (should be 1024x1024)
37
+ layout: Layout type (default "2x3" for manga)
38
+
39
+ Returns:
40
+ Composite manga page as single PIL Image
41
+ """
42
+ if len(panels) != 6:
43
+ raise ValueError(f"Expected 6 panels for 2x3 layout, got {len(panels)}")
44
+
45
+ # Calculate page dimensions
46
+ cols, rows = self._parse_layout(layout)
47
+
48
+ page_width = (
49
+ 2 * self.page_margin +
50
+ cols * self.panel_width +
51
+ (cols - 1) * self.gutter_width
52
+ )
53
+
54
+ page_height = (
55
+ 2 * self.page_margin +
56
+ rows * self.panel_height +
57
+ (rows - 1) * self.gutter_width
58
+ )
59
+
60
+ # Create white background
61
+ page = Image.new('RGB', (page_width, page_height), color='white')
62
+
63
+ # Paste panels in 2x3 grid
64
+ for idx, panel in enumerate(panels):
65
+ row = idx // cols
66
+ col = idx % cols
67
+
68
+ # Calculate position
69
+ x = self.page_margin + col * (self.panel_width + self.gutter_width)
70
+ y = self.page_margin + row * (self.panel_height + self.gutter_width)
71
+
72
+ # Resize panel if needed
73
+ if panel.size != (self.panel_width, self.panel_height):
74
+ panel = panel.resize(
75
+ (self.panel_width, self.panel_height),
76
+ Image.Resampling.LANCZOS
77
+ )
78
+
79
+ # Paste panel
80
+ page.paste(panel, (x, y))
81
+
82
+ # Draw border
83
+ if self.border_width > 0:
84
+ self._draw_panel_border(page, x, y)
85
+
86
+ return page
87
+
88
+ def create_horizontal_strip(
89
+ self,
90
+ panels: List[Image.Image],
91
+ max_panels: int = 3
92
+ ) -> Image.Image:
93
+ """
94
+ Create horizontal webcomic strip (3 panels side-by-side)
95
+
96
+ Args:
97
+ panels: List of PIL Images
98
+ max_panels: Maximum panels per row (default 3)
99
+
100
+ Returns:
101
+ Horizontal comic strip
102
+ """
103
+ num_panels = min(len(panels), max_panels)
104
+
105
+ strip_width = (
106
+ 2 * self.page_margin +
107
+ num_panels * self.panel_width +
108
+ (num_panels - 1) * self.gutter_width
109
+ )
110
+
111
+ strip_height = 2 * self.page_margin + self.panel_height
112
+
113
+ # Create white background
114
+ strip = Image.new('RGB', (strip_width, strip_height), color='white')
115
+
116
+ # Paste panels horizontally
117
+ for idx in range(num_panels):
118
+ panel = panels[idx]
119
+
120
+ x = self.page_margin + idx * (self.panel_width + self.gutter_width)
121
+ y = self.page_margin
122
+
123
+ # Resize if needed
124
+ if panel.size != (self.panel_width, self.panel_height):
125
+ panel = panel.resize(
126
+ (self.panel_width, self.panel_height),
127
+ Image.Resampling.LANCZOS
128
+ )
129
+
130
+ strip.paste(panel, (x, y))
131
+
132
+ # Draw border
133
+ if self.border_width > 0:
134
+ self._draw_panel_border(strip, x, y)
135
+
136
+ return strip
137
+
138
+ def create_vertical_scroll(
139
+ self,
140
+ panels: List[Image.Image]
141
+ ) -> Image.Image:
142
+ """
143
+ Create vertical webtoon-style scroll (all panels stacked)
144
+
145
+ Args:
146
+ panels: List of PIL Images
147
+
148
+ Returns:
149
+ Vertical comic scroll
150
+ """
151
+ num_panels = len(panels)
152
+
153
+ scroll_width = 2 * self.page_margin + self.panel_width
154
+
155
+ scroll_height = (
156
+ 2 * self.page_margin +
157
+ num_panels * self.panel_height +
158
+ (num_panels - 1) * self.gutter_width
159
+ )
160
+
161
+ # Create white background
162
+ scroll = Image.new('RGB', (scroll_width, scroll_height), color='white')
163
+
164
+ # Paste panels vertically
165
+ for idx, panel in enumerate(panels):
166
+ x = self.page_margin
167
+ y = self.page_margin + idx * (self.panel_height + self.gutter_width)
168
+
169
+ # Resize if needed
170
+ if panel.size != (self.panel_width, self.panel_height):
171
+ panel = panel.resize(
172
+ (self.panel_width, self.panel_height),
173
+ Image.Resampling.LANCZOS
174
+ )
175
+
176
+ scroll.paste(panel, (x, y))
177
+
178
+ # Draw border
179
+ if self.border_width > 0:
180
+ self._draw_panel_border(scroll, x, y)
181
+
182
+ return scroll
183
+
184
+ def create_custom_layout(
185
+ self,
186
+ panels: List[Image.Image],
187
+ grid: Tuple[int, int]
188
+ ) -> Image.Image:
189
+ """
190
+ Create custom grid layout (e.g., 3x2, 4x2, etc.)
191
+
192
+ Args:
193
+ panels: List of PIL Images
194
+ grid: Tuple of (columns, rows)
195
+
196
+ Returns:
197
+ Custom layout comic page
198
+ """
199
+ cols, rows = grid
200
+ expected_panels = cols * rows
201
+
202
+ if len(panels) != expected_panels:
203
+ raise ValueError(
204
+ f"Expected {expected_panels} panels for {cols}x{rows} grid, "
205
+ f"got {len(panels)}"
206
+ )
207
+
208
+ page_width = (
209
+ 2 * self.page_margin +
210
+ cols * self.panel_width +
211
+ (cols - 1) * self.gutter_width
212
+ )
213
+
214
+ page_height = (
215
+ 2 * self.page_margin +
216
+ rows * self.panel_height +
217
+ (rows - 1) * self.gutter_width
218
+ )
219
+
220
+ # Create white background
221
+ page = Image.new('RGB', (page_width, page_height), color='white')
222
+
223
+ # Paste panels in grid
224
+ for idx, panel in enumerate(panels):
225
+ row = idx // cols
226
+ col = idx % cols
227
+
228
+ x = self.page_margin + col * (self.panel_width + self.gutter_width)
229
+ y = self.page_margin + row * (self.panel_height + self.gutter_width)
230
+
231
+ # Resize if needed
232
+ if panel.size != (self.panel_width, self.panel_height):
233
+ panel = panel.resize(
234
+ (self.panel_width, self.panel_height),
235
+ Image.Resampling.LANCZOS
236
+ )
237
+
238
+ page.paste(panel, (x, y))
239
+
240
+ # Draw border
241
+ if self.border_width > 0:
242
+ self._draw_panel_border(page, x, y)
243
+
244
+ return page
245
+
246
+ def _draw_panel_border(self, image: Image.Image, x: int, y: int):
247
+ """Draw border around a panel"""
248
+ draw = ImageDraw.Draw(image)
249
+
250
+ # Draw rectangle border
251
+ draw.rectangle(
252
+ [
253
+ (x, y),
254
+ (x + self.panel_width, y + self.panel_height)
255
+ ],
256
+ outline=self.border_color,
257
+ width=self.border_width
258
+ )
259
+
260
+ def _parse_layout(self, layout: str) -> Tuple[int, int]:
261
+ """Parse layout string into (columns, rows)"""
262
+ if layout == "2x3":
263
+ return (2, 3)
264
+ elif layout == "3x2":
265
+ return (3, 2)
266
+ elif layout == "1x6":
267
+ return (1, 6)
268
+ elif layout == "6x1":
269
+ return (6, 1)
270
+ elif "x" in layout:
271
+ parts = layout.split("x")
272
+ return (int(parts[0]), int(parts[1]))
273
+ else:
274
+ raise ValueError(f"Invalid layout format: {layout}")
275
+
276
+ def get_page_dimensions(self, layout: str = "2x3") -> Tuple[int, int]:
277
+ """
278
+ Get final page dimensions for a layout
279
+
280
+ Args:
281
+ layout: Layout string (e.g., "2x3")
282
+
283
+ Returns:
284
+ Tuple of (width, height) in pixels
285
+ """
286
+ cols, rows = self._parse_layout(layout)
287
+
288
+ width = (
289
+ 2 * self.page_margin +
290
+ cols * self.panel_width +
291
+ (cols - 1) * self.gutter_width
292
+ )
293
+
294
+ height = (
295
+ 2 * self.page_margin +
296
+ rows * self.panel_height +
297
+ (rows - 1) * self.gutter_width
298
+ )
299
+
300
+ return (width, height)
301
+
302
+ def set_style(
303
+ self,
304
+ gutter_width: Optional[int] = None,
305
+ border_width: Optional[int] = None,
306
+ border_color: Optional[Tuple[int, int, int]] = None,
307
+ page_margin: Optional[int] = None
308
+ ):
309
+ """
310
+ Customize layout style
311
+
312
+ Args:
313
+ gutter_width: Space between panels
314
+ border_width: Panel border thickness
315
+ border_color: RGB tuple for borders
316
+ page_margin: Margin around page
317
+ """
318
+ if gutter_width is not None:
319
+ self.gutter_width = gutter_width
320
+ if border_width is not None:
321
+ self.border_width = border_width
322
+ if border_color is not None:
323
+ self.border_color = border_color
324
+ if page_margin is not None:
325
+ self.page_margin = page_margin
326
+
327
+
328
+ # Test/demo function
329
+ if __name__ == "__main__":
330
+ print("=" * 60)
331
+ print("COMIC LAYOUT SYSTEM TEST")
332
+ print("=" * 60)
333
+ print()
334
+
335
+ layout = ComicLayout()
336
+
337
+ # Show default settings
338
+ print("Default Settings:")
339
+ print(f" Panel size: {layout.panel_width}x{layout.panel_height}")
340
+ print(f" Gutter width: {layout.gutter_width}px")
341
+ print(f" Border width: {layout.border_width}px")
342
+ print(f" Page margin: {layout.page_margin}px")
343
+ print()
344
+
345
+ # Calculate dimensions
346
+ print("Layout Dimensions:")
347
+ print("-" * 60)
348
+
349
+ layouts = ["2x3", "3x2", "1x6", "6x1"]
350
+ for layout_name in layouts:
351
+ width, height = layout.get_page_dimensions(layout_name)
352
+ print(f"{layout_name} layout: {width}x{height} pixels")
353
+
354
+ print()
355
+
356
+ # Create test panels
357
+ print("Creating test manga page...")
358
+ print()
359
+
360
+ # Create 6 colored test panels
361
+ test_panels = []
362
+ colors = [
363
+ (255, 200, 200), # Light red
364
+ (200, 255, 200), # Light green
365
+ (200, 200, 255), # Light blue
366
+ (255, 255, 200), # Light yellow
367
+ (255, 200, 255), # Light magenta
368
+ (200, 255, 255) # Light cyan
369
+ ]
370
+
371
+ for i, color in enumerate(colors):
372
+ panel = Image.new('RGB', (1024, 1024), color=color)
373
+ # Add panel number
374
+ from PIL import ImageDraw, ImageFont
375
+ draw = ImageDraw.Draw(panel)
376
+ text = f"Panel {i+1}"
377
+ # Use default font
378
+ draw.text((512, 512), text, fill=(0, 0, 0), anchor='mm')
379
+ test_panels.append(panel)
380
+
381
+ # Create manga page
382
+ manga_page = layout.create_manga_page(test_panels, layout="2x3")
383
+
384
+ # Save test output
385
+ output_path = "test_manga_page.png"
386
+ manga_page.save(output_path)
387
+
388
+ print(f"✅ Test manga page created: {output_path}")
389
+ print(f" Dimensions: {manga_page.size}")
390
+ print()
391
+
392
+ # Create horizontal strip
393
+ strip = layout.create_horizontal_strip(test_panels[:3])
394
+ strip_path = "test_comic_strip.png"
395
+ strip.save(strip_path)
396
+
397
+ print(f"✅ Test comic strip created: {strip_path}")
398
+ print(f" Dimensions: {strip.size}")
399
+ print()
400
+
401
+ print("=" * 60)
402
+ print("Test complete! Check output images.")
403
+ print("=" * 60)
config.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration management for MythForge AI"""
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ # Load .env file with override=True to ensure .env values take precedence
6
+ load_dotenv(override=True)
7
+
8
+ class Config:
9
+ """Application configuration"""
10
+
11
+ # OpenAI Configuration
12
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
13
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") # Default to gpt-4o-mini, can use gpt-3.5-turbo or gpt-4
14
+
15
+ # HuggingFace Configuration (V2+)
16
+ HUGGINGFACE_TOKEN = os.getenv("HUGGINGFACE_TOKEN")
17
+
18
+ # ElevenLabs Configuration (V4+)
19
+ ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
20
+
21
+ # Gemini Configuration (V6+)
22
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
23
+
24
+ # Optional Services
25
+ MODAL_TOKEN = os.getenv("MODAL_TOKEN")
26
+ SAMBANOVA_API_KEY = os.getenv("SAMBANOVA_API_KEY")
27
+ NEBIUS_API_KEY = os.getenv("NEBIUS_API_KEY")
28
+ BLAXEL_API_KEY = os.getenv("BLAXEL_API_KEY")
29
+
30
+ # Feature Flags for Comic Mode (Step 1)
31
+ ENABLE_COMIC_MODE = os.getenv("ENABLE_COMIC_MODE", "false").lower() == "true"
32
+ ENABLE_BLAXEL = os.getenv("ENABLE_BLAXEL", "false").lower() == "true"
33
+ ENABLE_MODAL = os.getenv("ENABLE_MODAL", "false").lower() == "true"
34
+ ENABLE_SAMBANOVA = os.getenv("ENABLE_SAMBANOVA", "false").lower() == "true"
35
+
36
+ # MCP Server Feature Flag (for backward compatibility)
37
+ ENABLE_MCP = os.getenv("ENABLE_MCP", "false").lower() == "true"
38
+
39
+
40
+ # New Sponsor Feature Flags (Hackathon)
41
+ ENABLE_LLAMAINDEX = os.getenv("ENABLE_LLAMAINDEX", "true").lower() == "true" # Default ON
42
+ ENABLE_GEMINI = os.getenv("ENABLE_GEMINI", "false").lower() == "true"
43
+ ENABLE_OPENAI_FALLBACK = os.getenv("ENABLE_OPENAI_FALLBACK", "true").lower() == "true" # Default ON
44
+ ENABLE_BLAXEL_WORKFLOW = os.getenv("ENABLE_BLAXEL_WORKFLOW", "false").lower() == "true" # Use Blaxel orchestration instead of direct calls
45
+
46
+ # Text Generation Priority (for prize optimization)
47
+ USE_OPENAI_PRIMARY = os.getenv("USE_OPENAI_PRIMARY", "true").lower() == "true" # true = OpenAI first ($1k prize), false = SambaNova first
48
+
49
+ # Nano Banana (Gemini Image) Configuration
50
+ ENABLE_NANO_BANANA = os.getenv("ENABLE_NANO_BANANA", "false").lower() == "true"
51
+ NANO_BANANA_MODEL = os.getenv("NANO_BANANA_MODEL", "gemini-2.5-flash-image") # Options: gemini-2.5-flash-image, gemini-3-pro-image-preview
52
+ NANO_BANANA_MAX_REFERENCES = int(os.getenv("NANO_BANANA_MAX_REFERENCES", "14")) # Up to 14 reference images
53
+ ENABLE_CHARACTER_LIBRARY = os.getenv("ENABLE_CHARACTER_LIBRARY", "false").lower() == "true" # Multimodal character persistence
54
+
55
+ # Character Consistency Fallback Configuration
56
+ NANO_BANANA_AUTO_FALLBACK = os.getenv("NANO_BANANA_AUTO_FALLBACK", "true").lower() == "true" # Auto fallback to legacy on error
57
+ NANO_BANANA_FALLBACK_ON_QUOTA = os.getenv("NANO_BANANA_FALLBACK_ON_QUOTA", "true").lower() == "true" # Fallback on quota exceeded
58
+ NANO_BANANA_MAX_RETRY = int(os.getenv("NANO_BANANA_MAX_RETRY", "2")) # Max retries before fallback
59
+
60
+ # Application Settings
61
+ MAX_STORY_LENGTH = 2000
62
+ DEFAULT_TEMPERATURE = 0.7
63
+
64
+ # Comic Mode Settings (Step 1)
65
+ COMIC_PANEL_COUNT = 6 # Manga style: 2x3 grid
66
+ COMIC_PANEL_WIDTH = 1024 # For SDXL
67
+ COMIC_PANEL_HEIGHT = 1024
68
+
69
+ @classmethod
70
+ def validate_v1(cls):
71
+ """Validate V1 configuration"""
72
+ if not cls.OPENAI_API_KEY:
73
+ raise ValueError("OPENAI_API_KEY is required. Please set it in .env file")
74
+ return True
demo_automatic_comic.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Demo: Fully Automatic Comic Generation
3
+ User provides ONLY a text prompt - system does everything automatically
4
+ """
5
+
6
+ from character_consistency_manager import CharacterConsistencyManager
7
+
8
+
9
+ def demo_yogi_auto():
10
+ """Demo: Automatic yogi story with surprise character"""
11
+ print("="*70)
12
+ print(" DEMO: FULLY AUTOMATIC COMIC GENERATION")
13
+ print(" User provides ONE sentence - System does EVERYTHING")
14
+ print("="*70)
15
+
16
+ manager = CharacterConsistencyManager()
17
+
18
+ # User's simple prompt - that's ALL they provide!
19
+ user_prompt = "A yogi teaching a young aspirant the rules of meditation and mindfulness leading to levitation by his own example in a beautiful vibrant colorful jungle"
20
+
21
+ print(f"\n📝 User Input:")
22
+ print(f' "{user_prompt}"')
23
+ print(f"\n🤖 System will automatically:")
24
+ print(f" 1. Extract all characters (Yogi, Aspirant)")
25
+ print(f" 2. Add a surprise character for intrigue")
26
+ print(f" 3. Generate detailed descriptions for each")
27
+ print(f" 4. Create reference sheets for each character")
28
+ print(f" 5. Break story into 3 panels")
29
+ print(f" 6. Generate all panels with consistent characters")
30
+ print(f"\nStarting automatic generation...\n")
31
+
32
+ # ONE LINE OF CODE!
33
+ result = manager.generate_comic_from_prompt(
34
+ story_prompt=user_prompt,
35
+ num_panels=3,
36
+ art_style="manga",
37
+ include_surprise_character=True
38
+ )
39
+
40
+ print("\n" + "="*70)
41
+ print(" RESULTS")
42
+ print("="*70)
43
+ print(f"\nCharacters automatically created:")
44
+ for char_name in result["characters"].keys():
45
+ if char_name == result["surprise_character"]:
46
+ print(f" 🎁 {char_name} (SURPRISE!)")
47
+ else:
48
+ print(f" 📖 {char_name}")
49
+
50
+ print(f"\nPanels generated: {len(result['panels'])}")
51
+ for panel_info in result["panel_info"]:
52
+ print(f" Panel {panel_info['panel_number']}: {', '.join(panel_info['characters'])}")
53
+ print(f" File: {panel_info['filename']}")
54
+
55
+ print(f"\n✅ Complete comic saved to: {result['output_dir']}")
56
+
57
+
58
+ def demo_custom_story():
59
+ """Demo: User provides their own story"""
60
+ print("\n" + "="*70)
61
+ print(" CUSTOM STORY DEMO")
62
+ print("="*70)
63
+
64
+ manager = CharacterConsistencyManager()
65
+
66
+ print("\n📝 Enter your story idea (or press Enter for default):")
67
+ print(" Example: A wizard teaching magic to an apprentice in a castle")
68
+
69
+ user_story = input("\n> ").strip()
70
+
71
+ if not user_story:
72
+ user_story = "A wise old wizard teaching a young apprentice powerful magic spells in an ancient castle library, accidentally summoning something unexpected"
73
+
74
+ print(f"\n🤖 Generating comic from: '{user_story}'")
75
+
76
+ result = manager.generate_comic_from_prompt(
77
+ story_prompt=user_story,
78
+ num_panels=3,
79
+ art_style="fantasy",
80
+ include_surprise_character=True
81
+ )
82
+
83
+ print(f"\n✅ Comic saved to: {result['output_dir']}")
84
+
85
+
86
+ def compare_before_after():
87
+ """Show the difference: Manual vs Automatic"""
88
+ print("\n" + "="*70)
89
+ print(" COMPARISON: MANUAL vs AUTOMATIC")
90
+ print("="*70)
91
+
92
+ print("\n❌ OLD WAY (Manual - Lots of code):")
93
+ print("""
94
+ manager = CharacterConsistencyManager()
95
+
96
+ # User has to manually specify EVERYTHING
97
+ yogi = manager.create_character(
98
+ "Elderly yogi, white beard, saffron robes...",
99
+ "Master Yogi",
100
+ "manga"
101
+ )
102
+
103
+ aspirant = manager.create_character(
104
+ "Young boy, short hair, white clothes...",
105
+ "Young Aspirant",
106
+ "manga"
107
+ )
108
+
109
+ # Manually generate each panel
110
+ panel1 = manager.generate_panel_with_character(...)
111
+ panel2 = manager.generate_panel_with_multiple_characters(...)
112
+ panel3 = manager.generate_panel_with_multiple_characters(...)
113
+
114
+ # ~20+ lines of code, character hardcoding
115
+ """)
116
+
117
+ print("\n✅ NEW WAY (Automatic - ONE line):")
118
+ print("""
119
+ manager = CharacterConsistencyManager()
120
+
121
+ # User just provides a story prompt!
122
+ result = manager.generate_comic_from_prompt(
123
+ "A yogi teaching a young aspirant meditation and levitation in a jungle"
124
+ )
125
+
126
+ # Done! System automatically:
127
+ # - Extracted: Yogi, Aspirant
128
+ # - Added: Surprise character (e.g., Forest Spirit)
129
+ # - Generated: All character sheets
130
+ # - Created: 3 consistent panels
131
+ # - Saved: Everything to disk
132
+
133
+ # Just 1 line of code!
134
+ """)
135
+
136
+ print("\n🚀 Benefit: 95% less code, 100% automatic!")
137
+
138
+
139
+ def main():
140
+ print("\n" + "="*70)
141
+ print(" AUTOMATIC COMIC GENERATION DEMOS")
142
+ print(" No hardcoding - Just provide a story prompt!")
143
+ print("="*70)
144
+
145
+ print("\nSelect demo:")
146
+ print(" 1. Yogi Story (automatic with surprise character)")
147
+ print(" 2. Custom Story (enter your own idea)")
148
+ print(" 3. Show Comparison (Manual vs Automatic)")
149
+ print(" 4. Run all demos")
150
+ print("="*70)
151
+
152
+ choice = input("\nEnter choice (1-4): ").strip()
153
+
154
+ if choice == "1":
155
+ demo_yogi_auto()
156
+ elif choice == "2":
157
+ demo_custom_story()
158
+ elif choice == "3":
159
+ compare_before_after()
160
+ elif choice == "4":
161
+ compare_before_after()
162
+ input("\nPress Enter to run Yogi demo...")
163
+ demo_yogi_auto()
164
+ input("\nPress Enter to run Custom Story...")
165
+ demo_custom_story()
166
+ else:
167
+ print("Invalid choice!")
168
+
169
+ print("\n" + "="*70)
170
+ print(" ✅ DEMO COMPLETE")
171
+ print("="*70)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
export_service.py ADDED
@@ -0,0 +1,870 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export Service for generating downloadable files"""
2
+ import io
3
+ import os
4
+ import zipfile
5
+ from typing import List, Dict, Optional
6
+ from PIL import Image
7
+ from reportlab.lib.pagesizes import letter, A4
8
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
9
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
10
+ from reportlab.lib.units import inch
11
+ from reportlab.platypus import Image as RLImage
12
+
13
+ class ExportService:
14
+ """Generate downloadable files from stories"""
15
+
16
+ def generate_pdf(self, story_text: str, images: List[Image.Image] = None, metadata: dict = None) -> bytes:
17
+ """
18
+ Generate PDF with story text and images
19
+
20
+ Args:
21
+ story_text: The story text
22
+ images: List of PIL Images
23
+ metadata: Story metadata
24
+
25
+ Returns:
26
+ PDF file as bytes
27
+ """
28
+ buffer = io.BytesIO()
29
+ doc = SimpleDocTemplate(buffer, pagesize=letter)
30
+ story = []
31
+ styles = getSampleStyleSheet()
32
+
33
+ # Title
34
+ title_style = ParagraphStyle(
35
+ 'CustomTitle',
36
+ parent=styles['Heading1'],
37
+ fontSize=24,
38
+ spaceAfter=30
39
+ )
40
+ story.append(Paragraph("MythForge AI Story", title_style))
41
+ story.append(Spacer(1, 0.2*inch))
42
+
43
+ # Metadata
44
+ if metadata:
45
+ meta_text = f"Style: {metadata.get('style', 'N/A')} | Tone: {metadata.get('tone', 'N/A')}"
46
+ story.append(Paragraph(meta_text, styles['Normal']))
47
+ story.append(Spacer(1, 0.3*inch))
48
+
49
+ # Story text
50
+ paragraphs = story_text.split('\n\n')
51
+ for para in paragraphs:
52
+ if para.strip():
53
+ story.append(Paragraph(para.strip(), styles['BodyText']))
54
+ story.append(Spacer(1, 0.2*inch))
55
+
56
+ # Images
57
+ if images:
58
+ story.append(PageBreak())
59
+ story.append(Paragraph("Illustrations", styles['Heading2']))
60
+ story.append(Spacer(1, 0.2*inch))
61
+
62
+ for img in images:
63
+ # Save image to buffer
64
+ img_buffer = io.BytesIO()
65
+ img.save(img_buffer, format='PNG')
66
+ img_buffer.seek(0)
67
+
68
+ # Add to PDF
69
+ rl_img = RLImage(img_buffer, width=4*inch, height=4*inch)
70
+ story.append(rl_img)
71
+ story.append(Spacer(1, 0.3*inch))
72
+
73
+ # Build PDF
74
+ doc.build(story)
75
+ buffer.seek(0)
76
+ return buffer.getvalue()
77
+
78
+ def generate_comic_pdf(
79
+ self,
80
+ comic_page: Image.Image,
81
+ title: str = "MythForge Comic",
82
+ metadata: Optional[Dict] = None
83
+ ) -> bytes:
84
+ """
85
+ Generate PDF with comic page
86
+
87
+ Args:
88
+ comic_page: Composite manga page (2148x3192)
89
+ title: Comic title
90
+ metadata: Comic metadata
91
+
92
+ Returns:
93
+ PDF file as bytes
94
+ """
95
+ buffer = io.BytesIO()
96
+
97
+ # Use letter size with custom margins for better fit
98
+ from reportlab.lib.pagesizes import letter
99
+ doc = SimpleDocTemplate(
100
+ buffer,
101
+ pagesize=letter,
102
+ leftMargin=0.5*inch,
103
+ rightMargin=0.5*inch,
104
+ topMargin=0.5*inch,
105
+ bottomMargin=0.5*inch
106
+ )
107
+ story = []
108
+ styles = getSampleStyleSheet()
109
+
110
+ # Title page
111
+ title_style = ParagraphStyle(
112
+ 'ComicTitle',
113
+ parent=styles['Heading1'],
114
+ fontSize=24,
115
+ spaceAfter=15,
116
+ alignment=1 # Center
117
+ )
118
+ story.append(Spacer(1, 0.5*inch))
119
+ story.append(Paragraph(title, title_style))
120
+ story.append(Spacer(1, 0.2*inch))
121
+
122
+ # Metadata
123
+ if metadata:
124
+ meta_style = ParagraphStyle(
125
+ 'Metadata',
126
+ parent=styles['Normal'],
127
+ fontSize=10,
128
+ alignment=1
129
+ )
130
+ if metadata.get('style'):
131
+ story.append(Paragraph(f"Style: {metadata['style']}", meta_style))
132
+ if metadata.get('prompt'):
133
+ prompt_text = metadata['prompt'][:80] + "..." if len(metadata['prompt']) > 80 else metadata['prompt']
134
+ story.append(Paragraph(f"{prompt_text}", meta_style))
135
+ story.append(Spacer(1, 0.15*inch))
136
+
137
+ story.append(Paragraph("Generated with MythForge AI", styles['Italic']))
138
+ story.append(PageBreak())
139
+
140
+ # Comic page - scale to fit page width while maintaining aspect ratio
141
+ img_buffer = io.BytesIO()
142
+ comic_page.save(img_buffer, format='PNG')
143
+ img_buffer.seek(0)
144
+
145
+ # Calculate scaled dimensions (manga page is 2148x3192)
146
+ # Letter page usable area: ~7.5" x 10" after margins
147
+ page_width = 7.5 * inch
148
+ page_height = 10 * inch
149
+
150
+ # Manga aspect ratio: 2148/3192 = 0.673
151
+ img_aspect = comic_page.width / comic_page.height
152
+
153
+ # Scale to fit width
154
+ scaled_width = page_width * 0.95 # 95% of page width for safety
155
+ scaled_height = scaled_width / img_aspect
156
+
157
+ # If height is too large, scale by height instead
158
+ if scaled_height > page_height:
159
+ scaled_height = page_height * 0.95
160
+ scaled_width = scaled_height * img_aspect
161
+
162
+ rl_img = RLImage(img_buffer, width=scaled_width, height=scaled_height)
163
+ story.append(rl_img)
164
+
165
+ # Build PDF
166
+ doc.build(story)
167
+ buffer.seek(0)
168
+ return buffer.getvalue()
169
+
170
+ def generate_comic_zip(
171
+ self,
172
+ comic_page: Image.Image,
173
+ panel_images: List[Image.Image],
174
+ audio_files: List[str],
175
+ title: str = "MythForge_Comic",
176
+ metadata: Optional[Dict] = None
177
+ ) -> bytes:
178
+ """
179
+ Generate ZIP archive with all comic assets
180
+
181
+ Args:
182
+ comic_page: Composite manga page
183
+ panel_images: List of 6 individual panel images
184
+ audio_files: List of 6 panel audio file paths
185
+ title: Comic title (used for filenames)
186
+ metadata: Comic metadata
187
+
188
+ Returns:
189
+ ZIP file as bytes
190
+ """
191
+ buffer = io.BytesIO()
192
+
193
+ with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
194
+ # Add comic page
195
+ img_buffer = io.BytesIO()
196
+ comic_page.save(img_buffer, format='PNG')
197
+ zip_file.writestr(f"{title}_full_page.png", img_buffer.getvalue())
198
+
199
+ # Add individual panels
200
+ for i, panel in enumerate(panel_images):
201
+ panel_buffer = io.BytesIO()
202
+ panel.save(panel_buffer, format='PNG')
203
+ zip_file.writestr(f"{title}_panel_{i+1}.png", panel_buffer.getvalue())
204
+
205
+ # Add audio files
206
+ for i, audio_path in enumerate(audio_files):
207
+ if audio_path and os.path.exists(audio_path):
208
+ with open(audio_path, 'rb') as f:
209
+ zip_file.writestr(f"{title}_panel_{i+1}_audio.mp3", f.read())
210
+
211
+ # Add metadata as JSON
212
+ if metadata:
213
+ import json
214
+ metadata_str = json.dumps(metadata, indent=2)
215
+ zip_file.writestr(f"{title}_metadata.json", metadata_str)
216
+
217
+ # Add README
218
+ readme = self._generate_readme(title, metadata)
219
+ zip_file.writestr("README.txt", readme)
220
+
221
+ buffer.seek(0)
222
+ return buffer.getvalue()
223
+
224
+ def generate_web_package(
225
+ self,
226
+ comic_page: Image.Image,
227
+ audio_files: List[str],
228
+ title: str = "MythForge_Comic",
229
+ metadata: Optional[Dict] = None
230
+ ) -> bytes:
231
+ """
232
+ Generate web-ready package with HTML viewer
233
+
234
+ Args:
235
+ comic_page: Composite manga page
236
+ audio_files: List of panel audio file paths
237
+ title: Comic title
238
+ metadata: Comic metadata
239
+
240
+ Returns:
241
+ ZIP file with HTML viewer as bytes
242
+ """
243
+ buffer = io.BytesIO()
244
+
245
+ with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
246
+ # Add comic page
247
+ img_buffer = io.BytesIO()
248
+ comic_page.save(img_buffer, format='PNG')
249
+ zip_file.writestr("comic.png", img_buffer.getvalue())
250
+
251
+ # Add audio files (intro + panels + conclusion)
252
+ if audio_files:
253
+ # First file is intro
254
+ if len(audio_files) > 0 and audio_files[0] and os.path.exists(audio_files[0]):
255
+ with open(audio_files[0], 'rb') as f:
256
+ zip_file.writestr("audio/intro.mp3", f.read())
257
+
258
+ # Middle files are panels
259
+ panel_count = len(audio_files) - 2 if len(audio_files) > 2 else 0
260
+ for i in range(panel_count):
261
+ audio_path = audio_files[i + 1]
262
+ if audio_path and os.path.exists(audio_path):
263
+ with open(audio_path, 'rb') as f:
264
+ zip_file.writestr(f"audio/panel_{i+1}.mp3", f.read())
265
+
266
+ # Last file is conclusion
267
+ if len(audio_files) > 1 and audio_files[-1] and os.path.exists(audio_files[-1]):
268
+ with open(audio_files[-1], 'rb') as f:
269
+ zip_file.writestr("audio/conclusion.mp3", f.read())
270
+
271
+ # Add HTML viewer (panel_count excludes intro/conclusion)
272
+ panel_count = max(0, len(audio_files) - 2) if audio_files else 6
273
+ html_content = self._generate_html_viewer(title, panel_count, metadata)
274
+ zip_file.writestr("index.html", html_content)
275
+
276
+ # Add CSS
277
+ css_content = self._generate_css()
278
+ zip_file.writestr("style.css", css_content)
279
+
280
+ # Add JavaScript
281
+ js_content = self._generate_javascript(len(audio_files))
282
+ zip_file.writestr("comic.js", js_content)
283
+
284
+ buffer.seek(0)
285
+ return buffer.getvalue()
286
+
287
+ def _generate_readme(self, title: str, metadata: Optional[Dict]) -> str:
288
+ """Generate README for ZIP package"""
289
+ readme = f"""
290
+ {title}
291
+ {'=' * len(title)}
292
+
293
+ Generated with MythForge AI Comic Generator
294
+
295
+ Contents:
296
+ ---------
297
+ - {title}_full_page.png : Complete 6-panel manga page (2148x3192)
298
+ - {title}_panel_1.png to _6 : Individual panel images (1024x1024 each)
299
+ - {title}_panel_1_audio.mp3 to _6 : Audio narration for each panel
300
+ - {title}_metadata.json : Comic metadata and generation info
301
+ - README.txt : This file
302
+
303
+ """
304
+ if metadata:
305
+ readme += "\nMetadata:\n---------\n"
306
+ if metadata.get('prompt'):
307
+ readme += f"Story Prompt: {metadata['prompt']}\n"
308
+ if metadata.get('style'):
309
+ readme += f"Visual Style: {metadata['style']}\n"
310
+ if metadata.get('timestamp'):
311
+ readme += f"Generated: {metadata['timestamp']}\n"
312
+
313
+ readme += """
314
+ Usage:
315
+ ------
316
+ - View the full comic page in any image viewer
317
+ - Play panel audio files sequentially while viewing
318
+ - Use individual panels for editing or social media
319
+ - metadata.json contains the story data in JSON format
320
+
321
+ For best experience, open the web viewer (if included) in a browser.
322
+
323
+ Powered by:
324
+ -----------
325
+ - SambaNova Llama 3.1 70B (Story Generation)
326
+ - Stable Diffusion XL (Image Generation)
327
+ - ElevenLabs (Voice Synthesis)
328
+ """
329
+ return readme
330
+
331
+ def _generate_html_viewer(self, title: str, panel_count: int, metadata: Optional[Dict]) -> str:
332
+ """Generate HTML viewer for web package"""
333
+ html = f"""<!DOCTYPE html>
334
+ <html lang="en">
335
+ <head>
336
+ <meta charset="UTF-8">
337
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
338
+ <title>{title} - MythForge Comic</title>
339
+ <link rel="stylesheet" href="style.css">
340
+ </head>
341
+ <body>
342
+ <div class="container">
343
+ <header>
344
+ <h1>{title}</h1>
345
+ <p class="tagline">Generated with MythForge AI</p>
346
+ </header>
347
+
348
+ <main>
349
+ <div class="comic-viewer">
350
+ <img src="comic.png" alt="{title}" id="comic-page" />
351
+
352
+ <div class="panel-overlay">
353
+ """
354
+ # Add clickable panel regions (2x3 grid)
355
+ # Panel positions based on comic_layout.py dimensions
356
+ panel_positions = [
357
+ (0, 0), # Panel 1: top-left
358
+ (1, 0), # Panel 2: top-right
359
+ (0, 1), # Panel 3: middle-left
360
+ (1, 1), # Panel 4: middle-right
361
+ (0, 2), # Panel 5: bottom-left
362
+ (1, 2), # Panel 6: bottom-right
363
+ ]
364
+
365
+ for i, (col, row) in enumerate(panel_positions):
366
+ panel_num = i + 1
367
+ html += f' <div class="panel-region" data-panel="{panel_num}" data-panel-label="Panel {panel_num}" data-col="{col}" data-row="{row}"></div>\n'
368
+
369
+ html += """ </div>
370
+ </div>
371
+
372
+ <div class="audio-controls">
373
+ <div id="current-panel-indicator">Now Playing: Introduction</div>
374
+ <h2>Comic Audio Tracks</h2>
375
+ <div class="audio-grid">
376
+ <div class="audio-item">
377
+ <span>📖 Introduction</span>
378
+ <audio id="audio-intro" src="audio/intro.mp3" preload="auto"></audio>
379
+ <button onclick="playTrack('intro')">▶ Play</button>
380
+ </div>
381
+ """
382
+ for i in range(panel_count):
383
+ html += f""" <div class="audio-item">
384
+ <span>Panel {i+1}</span>
385
+ <audio id="audio-{i+1}" src="audio/panel_{i+1}.mp3" preload="auto"></audio>
386
+ <button onclick="playPanel({i+1})">▶ Play</button>
387
+ </div>
388
+ """
389
+
390
+ html += """ <div class="audio-item">
391
+ <span>🎬 Conclusion</span>
392
+ <audio id="audio-conclusion" src="audio/conclusion.mp3" preload="auto"></audio>
393
+ <button onclick="playTrack('conclusion')">▶ Play</button>
394
+ </div>
395
+ </div>
396
+ <div class="playback-controls">
397
+ <button onclick="playAll()" class="btn-primary">▶ Play All (8 Tracks)</button>
398
+ <button onclick="stopAll()" class="btn-secondary">⏹ Stop</button>
399
+ </div>
400
+ </div>
401
+ </main>
402
+
403
+ <footer>
404
+ """
405
+ if metadata and metadata.get('prompt'):
406
+ html += f' <p><strong>Story:</strong> {metadata["prompt"]}</p>\n'
407
+
408
+ html += """ <p><strong>Powered by:</strong> OpenAI • Gemini • Modal • ElevenLabs • LlamaIndex • Blaxel • Claude Code • SambaNova • Gradio</p>
409
+ </footer>
410
+ </div>
411
+
412
+ <script src="comic.js"></script>
413
+ </body>
414
+ </html>
415
+ """
416
+ return html
417
+
418
+ def _generate_css(self) -> str:
419
+ """Generate CSS for web viewer"""
420
+ return """
421
+ * {
422
+ margin: 0;
423
+ padding: 0;
424
+ box-sizing: border-box;
425
+ }
426
+
427
+ body {
428
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
429
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
430
+ color: #333;
431
+ min-height: 100vh;
432
+ padding: 20px;
433
+ }
434
+
435
+ .container {
436
+ max-width: 1200px;
437
+ margin: 0 auto;
438
+ background: white;
439
+ border-radius: 12px;
440
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
441
+ overflow: hidden;
442
+ }
443
+
444
+ header {
445
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
446
+ color: white;
447
+ padding: 30px;
448
+ text-align: center;
449
+ }
450
+
451
+ header h1 {
452
+ font-size: 2.5rem;
453
+ margin-bottom: 10px;
454
+ }
455
+
456
+ .tagline {
457
+ font-size: 1rem;
458
+ opacity: 0.9;
459
+ }
460
+
461
+ main {
462
+ padding: 30px;
463
+ }
464
+
465
+ .comic-viewer {
466
+ position: relative;
467
+ margin-bottom: 30px;
468
+ text-align: center;
469
+ }
470
+
471
+ #comic-page {
472
+ max-width: 100%;
473
+ height: auto;
474
+ border-radius: 8px;
475
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
476
+ }
477
+
478
+ .panel-overlay {
479
+ position: absolute;
480
+ top: 0;
481
+ left: 50%;
482
+ transform: translateX(-50%);
483
+ width: 100%;
484
+ max-width: 100%;
485
+ height: 100%;
486
+ display: grid;
487
+ grid-template-columns: repeat(2, 1fr);
488
+ grid-template-rows: repeat(3, 1fr);
489
+ gap: 2%;
490
+ padding: 2%;
491
+ }
492
+
493
+ .panel-region {
494
+ cursor: pointer;
495
+ border: 5px solid transparent;
496
+ border-radius: 8px;
497
+ transition: all 0.3s ease;
498
+ position: relative;
499
+ box-shadow: 0 0 0 rgba(102, 126, 234, 0);
500
+ }
501
+
502
+ .panel-region::before {
503
+ content: attr(data-panel-label);
504
+ position: absolute;
505
+ top: 8px;
506
+ left: 8px;
507
+ background: rgba(0, 0, 0, 0.7);
508
+ color: white;
509
+ padding: 4px 10px;
510
+ border-radius: 4px;
511
+ font-size: 14px;
512
+ font-weight: bold;
513
+ opacity: 0;
514
+ transition: opacity 0.3s ease;
515
+ z-index: 10;
516
+ }
517
+
518
+ .panel-region:hover::before {
519
+ opacity: 1;
520
+ }
521
+
522
+ .panel-region:hover {
523
+ border-color: rgba(102, 126, 234, 0.8);
524
+ background: rgba(102, 126, 234, 0.1);
525
+ box-shadow: 0 0 15px rgba(102, 126, 234, 0.4);
526
+ }
527
+
528
+ .panel-region.active {
529
+ border-color: #667eea;
530
+ background: rgba(102, 126, 234, 0.25);
531
+ animation: pulse-glow 1.5s infinite;
532
+ box-shadow: 0 0 30px rgba(102, 126, 234, 0.8), 0 0 50px rgba(102, 126, 234, 0.5);
533
+ }
534
+
535
+ .panel-region.active::before {
536
+ opacity: 1;
537
+ background: #667eea;
538
+ }
539
+
540
+ @keyframes pulse-glow {
541
+ 0%, 100% {
542
+ transform: scale(1);
543
+ box-shadow: 0 0 30px rgba(102, 126, 234, 0.8), 0 0 50px rgba(102, 126, 234, 0.5);
544
+ }
545
+ 50% {
546
+ transform: scale(1.03);
547
+ box-shadow: 0 0 40px rgba(102, 126, 234, 1), 0 0 70px rgba(102, 126, 234, 0.7);
548
+ }
549
+ }
550
+
551
+ .audio-controls {
552
+ background: #f7f7f7;
553
+ padding: 20px;
554
+ border-radius: 8px;
555
+ }
556
+
557
+ .audio-controls h2 {
558
+ margin-bottom: 15px;
559
+ color: #667eea;
560
+ }
561
+
562
+ #current-panel-indicator {
563
+ background: #667eea;
564
+ color: white;
565
+ padding: 12px 20px;
566
+ border-radius: 6px;
567
+ text-align: center;
568
+ font-weight: bold;
569
+ font-size: 16px;
570
+ margin-bottom: 15px;
571
+ display: none;
572
+ animation: fade-in 0.3s ease;
573
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
574
+ }
575
+
576
+ @keyframes fade-in {
577
+ from { opacity: 0; transform: translateY(-10px); }
578
+ to { opacity: 1; transform: translateY(0); }
579
+ }
580
+
581
+ .audio-grid {
582
+ display: grid;
583
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
584
+ gap: 15px;
585
+ margin-bottom: 20px;
586
+ }
587
+
588
+ .audio-item {
589
+ background: white;
590
+ padding: 15px;
591
+ border-radius: 6px;
592
+ text-align: center;
593
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
594
+ }
595
+
596
+ .audio-item span {
597
+ display: block;
598
+ font-weight: 600;
599
+ margin-bottom: 10px;
600
+ color: #667eea;
601
+ }
602
+
603
+ .audio-item button {
604
+ width: 100%;
605
+ padding: 8px;
606
+ background: #667eea;
607
+ color: white;
608
+ border: none;
609
+ border-radius: 4px;
610
+ cursor: pointer;
611
+ font-size: 14px;
612
+ transition: background 0.3s;
613
+ }
614
+
615
+ .audio-item button:hover {
616
+ background: #5568d3;
617
+ }
618
+
619
+ .playback-controls {
620
+ display: flex;
621
+ justify-content: center;
622
+ gap: 15px;
623
+ }
624
+
625
+ .btn-primary, .btn-secondary {
626
+ padding: 12px 30px;
627
+ font-size: 16px;
628
+ border: none;
629
+ border-radius: 6px;
630
+ cursor: pointer;
631
+ transition: all 0.3s;
632
+ }
633
+
634
+ .btn-primary {
635
+ background: #667eea;
636
+ color: white;
637
+ }
638
+
639
+ .btn-primary:hover {
640
+ background: #5568d3;
641
+ transform: translateY(-2px);
642
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
643
+ }
644
+
645
+ .btn-secondary {
646
+ background: #e0e0e0;
647
+ color: #333;
648
+ }
649
+
650
+ .btn-secondary:hover {
651
+ background: #d0d0d0;
652
+ }
653
+
654
+ footer {
655
+ background: #f0f0f0;
656
+ padding: 20px;
657
+ text-align: center;
658
+ color: #666;
659
+ }
660
+
661
+ footer p {
662
+ margin: 5px 0;
663
+ }
664
+ """
665
+
666
+ def _generate_javascript(self, panel_count: int) -> str:
667
+ """Generate JavaScript for web viewer"""
668
+ return f"""
669
+ let currentPanel = 0;
670
+ let isPlaying = false;
671
+
672
+ // Play specific panel
673
+ function playPanel(panelNum) {{
674
+ stopAll();
675
+
676
+ const audio = document.getElementById(`audio-${{panelNum}}`);
677
+ const panel = document.querySelector(`[data-panel="${{panelNum}}"]`);
678
+
679
+ if (audio) {{
680
+ audio.play();
681
+ highlightPanel(panelNum);
682
+
683
+ audio.onended = () => {{
684
+ unhighlightPanel(panelNum);
685
+ }};
686
+ }}
687
+ }}
688
+
689
+ // Play specific track (intro/conclusion)
690
+ function playTrack(trackType) {{
691
+ stopAll();
692
+
693
+ const audio = document.getElementById(`audio-${{trackType}}`);
694
+
695
+ if (audio) {{
696
+ audio.play();
697
+ const indicator = document.getElementById('current-panel-indicator');
698
+ if (indicator) {{
699
+ indicator.textContent = `Now Playing: ${{trackType === 'intro' ? 'Introduction' : 'Conclusion'}}`;
700
+ }}
701
+
702
+ audio.onended = () => {{
703
+ if (indicator) {{
704
+ indicator.textContent = 'Playback Complete';
705
+ }}
706
+ }};
707
+ }}
708
+ }}
709
+
710
+ // Play all tracks sequentially (intro + panels + conclusion)
711
+ async function playAll() {{
712
+ if (isPlaying) return;
713
+
714
+ isPlaying = true;
715
+ currentPanel = 0;
716
+
717
+ // Play intro
718
+ if (!isPlaying) return;
719
+ await playTrackAsync('intro');
720
+
721
+ // Play all panels
722
+ for (let i = 1; i <= {panel_count}; i++) {{
723
+ if (!isPlaying) break;
724
+
725
+ await playPanelAsync(i);
726
+ }}
727
+
728
+ // Play conclusion
729
+ if (isPlaying) {{
730
+ await playTrackAsync('conclusion');
731
+ }}
732
+
733
+ isPlaying = false;
734
+ currentPanel = 0;
735
+ }}
736
+
737
+ // Play intro/conclusion track async
738
+ function playTrackAsync(trackType) {{
739
+ return new Promise((resolve) => {{
740
+ const audio = document.getElementById(`audio-${{trackType}}`);
741
+
742
+ if (!audio) {{
743
+ resolve();
744
+ return;
745
+ }}
746
+
747
+ const indicator = document.getElementById('current-panel-indicator');
748
+ if (indicator) {{
749
+ indicator.textContent = `Now Playing: ${{trackType === 'intro' ? 'Introduction' : 'Conclusion'}}`;
750
+ }}
751
+
752
+ audio.play();
753
+
754
+ audio.onended = () => {{
755
+ // Add 2-second pause after track ends
756
+ setTimeout(() => {{
757
+ if (indicator) {{
758
+ indicator.textContent = 'Transitioning...';
759
+ }}
760
+ resolve();
761
+ }}, 2000);
762
+ }};
763
+ }});
764
+ }}
765
+
766
+ // Play panel and wait for completion
767
+ function playPanelAsync(panelNum) {{
768
+ return new Promise((resolve) => {{
769
+ const audio = document.getElementById(`audio-${{panelNum}}`);
770
+ const panel = document.querySelector(`[data-panel="${{panelNum}}"]`);
771
+
772
+ if (!audio) {{
773
+ resolve();
774
+ return;
775
+ }}
776
+
777
+ highlightPanel(panelNum);
778
+ audio.play();
779
+
780
+ audio.onended = () => {{
781
+ // Add 2-second pause after audio ends before moving to next panel
782
+ setTimeout(() => {{
783
+ unhighlightPanel(panelNum);
784
+ resolve();
785
+ }}, 2000); // 2 second pause for smoother transitions
786
+ }};
787
+ }});
788
+ }}
789
+
790
+ // Stop all audio
791
+ function stopAll() {{
792
+ isPlaying = false;
793
+
794
+ // Stop intro
795
+ const introAudio = document.getElementById('audio-intro');
796
+ if (introAudio) {{
797
+ introAudio.pause();
798
+ introAudio.currentTime = 0;
799
+ }}
800
+
801
+ // Stop all panels
802
+ for (let i = 1; i <= {panel_count}; i++) {{
803
+ const audio = document.getElementById(`audio-${{i}}`);
804
+ if (audio) {{
805
+ audio.pause();
806
+ audio.currentTime = 0;
807
+ }}
808
+ unhighlightPanel(i);
809
+ }}
810
+
811
+ // Stop conclusion
812
+ const conclusionAudio = document.getElementById('audio-conclusion');
813
+ if (conclusionAudio) {{
814
+ conclusionAudio.pause();
815
+ conclusionAudio.currentTime = 0;
816
+ }}
817
+
818
+ // Hide current panel indicator
819
+ const indicator = document.getElementById('current-panel-indicator');
820
+ if (indicator) {{
821
+ indicator.style.display = 'none';
822
+ }}
823
+ }}
824
+
825
+ // Highlight panel
826
+ function highlightPanel(panelNum) {{
827
+ const panel = document.querySelector(`[data-panel="${{panelNum}}"]`);
828
+ if (panel) {{
829
+ panel.classList.add('active');
830
+
831
+ // Scroll panel into view with smooth animation
832
+ panel.scrollIntoView({{
833
+ behavior: 'smooth',
834
+ block: 'center',
835
+ inline: 'center'
836
+ }});
837
+
838
+ // Update currently playing indicator
839
+ updateCurrentPanelIndicator(panelNum);
840
+ }}
841
+ }}
842
+
843
+ // Remove highlight
844
+ function unhighlightPanel(panelNum) {{
845
+ const panel = document.querySelector(`[data-panel="${{panelNum}}"]`);
846
+ if (panel) {{
847
+ panel.classList.remove('active');
848
+ }}
849
+ }}
850
+
851
+ // Update current panel indicator
852
+ function updateCurrentPanelIndicator(panelNum) {{
853
+ const indicator = document.getElementById('current-panel-indicator');
854
+ if (indicator) {{
855
+ indicator.textContent = `Now Playing: Panel ${{panelNum}}`;
856
+ indicator.style.display = 'block';
857
+ }}
858
+ }}
859
+
860
+ // Add click handlers to panel regions
861
+ document.addEventListener('DOMContentLoaded', () => {{
862
+ const panels = document.querySelectorAll('.panel-region');
863
+ panels.forEach(panel => {{
864
+ panel.addEventListener('click', () => {{
865
+ const panelNum = parseInt(panel.dataset.panel);
866
+ playPanel(panelNum);
867
+ }});
868
+ }});
869
+ }});
870
+ """
modal_image_generator.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal SDXL Image Generator Client for MythForge Comics
2
+
3
+ Client interface to Modal SDXL deployment for comic panel generation.
4
+ """
5
+
6
+ import modal
7
+ from PIL import Image
8
+ from io import BytesIO
9
+ from typing import List, Optional
10
+ from config import Config
11
+
12
+
13
+ class ModalImageGenerator:
14
+ """Client for Modal SDXL image generation"""
15
+
16
+ def __init__(self):
17
+ """Initialize Modal client"""
18
+ self.enabled = Config.ENABLE_MODAL
19
+ self.app_name = "mythforge-sdxl"
20
+
21
+ if self.enabled:
22
+ try:
23
+ # Get reference to deployed Modal SDXLModel class
24
+ # In Modal 1.0+, we access deployed functions directly via modal.Function.lookup()
25
+ self.model = modal.Cls.from_name(self.app_name, "SDXLModel")
26
+ print(f"✅ Modal using SDXL (connected to {self.app_name})")
27
+ except Exception as e:
28
+ print(f"⚠️ Modal app not found: {e}")
29
+ print(" Run: modal deploy modal_sdxl.py")
30
+ self.enabled = False
31
+
32
+ def generate_panel(
33
+ self,
34
+ prompt: str,
35
+ style: str = "manga",
36
+ num_inference_steps: int = 25,
37
+ seed: Optional[int] = None
38
+ ) -> Image.Image:
39
+ """
40
+ Generate a single comic panel
41
+
42
+ Args:
43
+ prompt: Scene description
44
+ style: Visual style (manga, anime, comic, etc.)
45
+ num_inference_steps: Quality (25 = good, 50 = best)
46
+ seed: Random seed for reproducibility
47
+
48
+ Returns:
49
+ PIL Image (1024x1024)
50
+ """
51
+ if not self.enabled:
52
+ raise RuntimeError("Modal image generation not enabled")
53
+
54
+ # Style enhancements
55
+ style_prompts = {
56
+ "manga": "black and white manga style, ink drawing, dramatic shading",
57
+ "shonen": "shonen manga style, dynamic action poses, speed lines",
58
+ "shoujo": "shoujo manga style, sparkles, flowers, romantic",
59
+ "anime": "anime style, cel shaded, vibrant colors",
60
+ "comic": "western comic book style, bold lines, halftone",
61
+ "webcomic": "digital webcomic style, clean lines, modern",
62
+ "cartoon": "cartoon style, simplified, expressive",
63
+ }
64
+
65
+ style_enhancement = style_prompts.get(style, "")
66
+ enhanced_prompt = f"{prompt}, {style_enhancement}, high quality, detailed"
67
+
68
+ print(f"Generating image: {enhanced_prompt[:60]}...")
69
+
70
+ # Call Modal function
71
+ try:
72
+ # Call the generate method on the deployed class
73
+ image_bytes = self.model().generate.remote(
74
+ prompt=enhanced_prompt,
75
+ num_inference_steps=num_inference_steps,
76
+ seed=seed
77
+ )
78
+
79
+ # Convert bytes to PIL Image
80
+ image = Image.open(BytesIO(image_bytes))
81
+ return image
82
+
83
+ except Exception as e:
84
+ print(f"❌ Modal generation failed: {e}")
85
+ raise
86
+
87
+ async def generate_comic_panels(
88
+ self,
89
+ panels_data: List[dict],
90
+ style: str = "manga",
91
+ num_inference_steps: int = 25
92
+ ) -> List[Image.Image]:
93
+ """
94
+ Generate multiple comic panels (async for compatibility with app)
95
+
96
+ Args:
97
+ panels_data: List of panel dictionaries with scene_description
98
+ style: Visual style
99
+ num_inference_steps: Quality setting
100
+
101
+ Returns:
102
+ List of PIL Images
103
+ """
104
+ if not self.enabled:
105
+ raise RuntimeError("Modal image generation not enabled")
106
+
107
+ images = []
108
+
109
+ for i, panel in enumerate(panels_data):
110
+ scene = panel.get("scene_description", "")
111
+ if not scene:
112
+ scene = f"Comic panel {i+1}"
113
+
114
+ print(f"🎨 Generating panel {i+1}/{len(panels_data)}: {scene[:50]}...")
115
+
116
+ try:
117
+ image = self.generate_panel(
118
+ prompt=scene,
119
+ style=style,
120
+ num_inference_steps=num_inference_steps,
121
+ seed=i # Different seed per panel
122
+ )
123
+ images.append(image)
124
+ print(f" ✅ Panel {i+1} generated")
125
+
126
+ except Exception as e:
127
+ print(f" ❌ Panel {i+1} failed: {e}")
128
+ # Create fallback placeholder
129
+ placeholder = Image.new('RGB', (1024, 1024), color=(200, 200, 200))
130
+ images.append(placeholder)
131
+
132
+ return images
133
+
134
+ def generate_batch(
135
+ self,
136
+ prompts: List[str],
137
+ style: str = "manga",
138
+ num_inference_steps: int = 25
139
+ ) -> List[Image.Image]:
140
+ """
141
+ Generate multiple panels using batch function (faster)
142
+
143
+ Args:
144
+ prompts: List of scene descriptions
145
+ style: Visual style
146
+ num_inference_steps: Quality setting
147
+
148
+ Returns:
149
+ List of PIL Images
150
+ """
151
+ if not self.enabled:
152
+ raise RuntimeError("Modal image generation not enabled")
153
+
154
+ print(f"Generating {len(prompts)} panels in batch...")
155
+
156
+ try:
157
+ # Call batch generation function
158
+ generate_fn = modal.Function.lookup(self.app_name, "generate_comic_panels")
159
+ images_bytes = generate_fn.remote(
160
+ prompts=prompts,
161
+ style=style,
162
+ num_inference_steps=num_inference_steps
163
+ )
164
+
165
+ # Convert all to PIL Images
166
+ images = [Image.open(BytesIO(img_bytes)) for img_bytes in images_bytes]
167
+
168
+ print(f"✅ Generated {len(images)} panels")
169
+ return images
170
+
171
+ except Exception as e:
172
+ print(f"❌ Batch generation failed: {e}")
173
+ raise
174
+
175
+ @classmethod
176
+ def is_available(cls) -> bool:
177
+ """Check if Modal SDXL is available"""
178
+ try:
179
+ modal.App.lookup("mythforge-sdxl", create_if_missing=False)
180
+ return True
181
+ except:
182
+ return False
183
+
184
+
185
+ # Test function
186
+ if __name__ == "__main__":
187
+ print("Testing Modal Image Generator...")
188
+ print("=" * 60)
189
+
190
+ generator = ModalImageGenerator()
191
+
192
+ if not generator.enabled:
193
+ print("❌ Modal not enabled")
194
+ print(" 1. Deploy: modal deploy modal_sdxl.py")
195
+ print(" 2. Enable: Set ENABLE_MODAL=true in .env")
196
+ else:
197
+ print("✅ Modal enabled")
198
+
199
+ # Test single generation
200
+ test_prompt = "A wizard discovering a glowing magical spellbook in an ancient library"
201
+ print(f"\nTest prompt: {test_prompt}")
202
+
203
+ try:
204
+ image = generator.generate_panel(test_prompt, style="manga")
205
+ image.save("test_modal_panel.png")
206
+ print(f"✅ Test image saved to test_modal_panel.png")
207
+ print(f" Size: {image.size}")
208
+ except Exception as e:
209
+ print(f"❌ Test failed: {e}")
modal_sdxl.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal SDXL Deployment for MythForge Comic Generator
2
+
3
+ Deploys Stable Diffusion XL on Modal's serverless GPU infrastructure
4
+ for high-quality 1024x1024 comic panel generation.
5
+
6
+ Compatible with Modal 1.0+ API
7
+ """
8
+
9
+ import modal
10
+ from pathlib import Path
11
+
12
+ # Create Modal app
13
+ app = modal.App("mythforge-sdxl")
14
+
15
+
16
+ # Function to download model weights during image build
17
+ def download_sdxl_model():
18
+ """Download SDXL model weights"""
19
+ from diffusers import StableDiffusionXLPipeline
20
+ import torch
21
+
22
+ print("Downloading SDXL model weights...")
23
+ StableDiffusionXLPipeline.from_pretrained(
24
+ "stabilityai/stable-diffusion-xl-base-1.0",
25
+ torch_dtype=torch.float16,
26
+ variant="fp16",
27
+ use_safetensors=True,
28
+ )
29
+ print("✅ SDXL model downloaded")
30
+
31
+
32
+ # Define the image with SDXL dependencies (downloads models at build time)
33
+ sdxl_image = (
34
+ modal.Image.debian_slim(python_version="3.11")
35
+ .pip_install(
36
+ "numpy==1.26.3", # Required by diffusers schedulers
37
+ "diffusers==0.25.0",
38
+ "transformers==4.36.2",
39
+ "accelerate==0.25.0",
40
+ "safetensors==0.4.1",
41
+ "torch==2.1.2",
42
+ "pillow==10.1.0",
43
+ "huggingface-hub==0.20.0", # Pin compatible version
44
+ )
45
+ # Download model weights during image build
46
+ .run_function(
47
+ download_sdxl_model,
48
+ gpu="A10G", # Need GPU to download FP16 weights
49
+ )
50
+ )
51
+
52
+
53
+ @app.cls(
54
+ image=sdxl_image,
55
+ gpu="A10G", # A10G GPU - good balance of cost/performance
56
+ timeout=300, # 5 minute timeout
57
+ scaledown_window=60, # Keep warm for 1 minute
58
+ )
59
+ class SDXLModel:
60
+ """SDXL model for comic panel generation"""
61
+
62
+ @modal.enter()
63
+ def load_models(self):
64
+ """Load models when container starts"""
65
+ from diffusers import StableDiffusionXLPipeline
66
+ import torch
67
+
68
+ print("Loading SDXL pipeline...")
69
+ self.pipe = StableDiffusionXLPipeline.from_pretrained(
70
+ "stabilityai/stable-diffusion-xl-base-1.0",
71
+ torch_dtype=torch.float16,
72
+ variant="fp16",
73
+ use_safetensors=True,
74
+ ).to("cuda")
75
+
76
+ # Enable memory optimizations
77
+ self.pipe.enable_attention_slicing()
78
+ print("✅ SDXL pipeline loaded")
79
+
80
+ @modal.method()
81
+ def generate(
82
+ self,
83
+ prompt: str,
84
+ negative_prompt: str = "",
85
+ num_inference_steps: int = 25,
86
+ guidance_scale: float = 7.5,
87
+ seed: int = None,
88
+ ) -> bytes:
89
+ """
90
+ Generate a comic panel image
91
+
92
+ Args:
93
+ prompt: Scene description for the panel
94
+ negative_prompt: What to avoid in the image
95
+ num_inference_steps: Quality vs speed (25 is good balance)
96
+ guidance_scale: How closely to follow prompt (7.5 is default)
97
+ seed: Random seed for reproducibility
98
+
99
+ Returns:
100
+ PNG image as bytes
101
+ """
102
+ import torch
103
+ from io import BytesIO
104
+
105
+ # Set seed if provided
106
+ if seed is not None:
107
+ generator = torch.Generator("cuda").manual_seed(seed)
108
+ else:
109
+ generator = None
110
+
111
+ # Default negative prompt for comic art
112
+ if not negative_prompt:
113
+ negative_prompt = (
114
+ "blurry, low quality, distorted, deformed, ugly, "
115
+ "duplicate, watermark, signature, text, words, "
116
+ "bad anatomy, extra limbs, poorly drawn"
117
+ )
118
+
119
+ # Enhance prompt for comic style
120
+ enhanced_prompt = f"{prompt}, comic book style, manga art, high quality, detailed, vibrant colors, professional illustration"
121
+
122
+ print(f"Generating image with prompt: {enhanced_prompt[:100]}...")
123
+
124
+ # Generate image
125
+ result = self.pipe(
126
+ prompt=enhanced_prompt,
127
+ negative_prompt=negative_prompt,
128
+ num_inference_steps=num_inference_steps,
129
+ guidance_scale=guidance_scale,
130
+ generator=generator,
131
+ height=1024,
132
+ width=1024,
133
+ )
134
+
135
+ image = result.images[0]
136
+
137
+ # Convert to bytes
138
+ buffer = BytesIO()
139
+ image.save(buffer, format="PNG")
140
+ return buffer.getvalue()
141
+
142
+
143
+ # Function to generate comic panels (called from client)
144
+ @app.function(
145
+ image=sdxl_image,
146
+ gpu="A10G",
147
+ timeout=600, # 10 minute timeout for batch generation
148
+ )
149
+ def generate_comic_panels(
150
+ prompts: list[str],
151
+ style: str = "manga",
152
+ num_inference_steps: int = 25,
153
+ ) -> list[bytes]:
154
+ """
155
+ Generate multiple comic panels in batch
156
+
157
+ Args:
158
+ prompts: List of scene descriptions (one per panel)
159
+ style: Visual style (manga, anime, comic, etc.)
160
+ num_inference_steps: Quality setting
161
+
162
+ Returns:
163
+ List of PNG images as bytes
164
+ """
165
+ model = SDXLModel()
166
+
167
+ images = []
168
+ for i, prompt in enumerate(prompts):
169
+ print(f"Generating panel {i+1}/{len(prompts)}...")
170
+
171
+ # Style-specific enhancements
172
+ style_prompts = {
173
+ "manga": "black and white manga style, ink drawing, dramatic shading",
174
+ "shonen": "shonen manga style, dynamic action poses, speed lines",
175
+ "shoujo": "shoujo manga style, sparkles, flowers, romantic",
176
+ "anime": "anime style, cel shaded, vibrant colors",
177
+ "comic": "western comic book style, bold lines, halftone",
178
+ "webcomic": "digital webcomic style, clean lines, modern",
179
+ "cartoon": "cartoon style, simplified, expressive",
180
+ }
181
+
182
+ style_enhancement = style_prompts.get(style, "")
183
+ full_prompt = f"{prompt}, {style_enhancement}"
184
+
185
+ image_bytes = model.generate(
186
+ prompt=full_prompt,
187
+ num_inference_steps=num_inference_steps,
188
+ )
189
+ images.append(image_bytes)
190
+
191
+ return images
192
+
193
+
194
+ # CLI for testing
195
+ @app.local_entrypoint()
196
+ def main(prompt: str = "A wizard in an ancient library"):
197
+ """Test the SDXL deployment"""
198
+ print(f"Testing SDXL with prompt: {prompt}")
199
+
200
+ model = SDXLModel()
201
+ image_bytes = model.generate.remote(prompt)
202
+
203
+ # Save test image
204
+ output_path = Path("test_sdxl_output.png")
205
+ output_path.write_bytes(image_bytes)
206
+
207
+ print(f"✅ Image saved to {output_path}")
208
+ print(f" Size: {len(image_bytes):,} bytes ({len(image_bytes)/1024:.1f} KB)")
209
+
210
+
211
+ if __name__ == "__main__":
212
+ # Can test locally with:
213
+ # modal run modal_sdxl.py
214
+ pass
multimodal_analyzer.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal Analyzer using Google Gemini"""
2
+ from typing import Optional
3
+ from PIL import Image
4
+ import google.generativeai as genai
5
+ from config import Config
6
+
7
+ class MultimodalAnalyzer:
8
+ """Analyze images and provide style descriptions using Gemini"""
9
+
10
+ def __init__(self):
11
+ """Initialize Gemini client"""
12
+ self.model = None
13
+ self.enabled = Config.ENABLE_GEMINI and Config.GEMINI_API_KEY is not None
14
+
15
+ if self.enabled:
16
+ print("✅ Gemini analyzer enabled")
17
+ else:
18
+ print("⚠️ Gemini analyzer disabled (set ENABLE_GEMINI=true in .env)")
19
+
20
+ def _init_client(self):
21
+ """Lazy initialize Gemini"""
22
+ if self.model is None:
23
+ if not Config.GEMINI_API_KEY:
24
+ raise ValueError("GEMINI_API_KEY not set in environment")
25
+ genai.configure(api_key=Config.GEMINI_API_KEY)
26
+ # Use Gemini 2.5 Flash - production-ready with balanced performance
27
+ # Falls back to 2.0 Flash if 2.5 not available
28
+ try:
29
+ self.model = genai.GenerativeModel('gemini-2.5-flash')
30
+ print("✅ Using Gemini 2.5 Flash")
31
+ except Exception:
32
+ try:
33
+ self.model = genai.GenerativeModel('gemini-2.0-flash')
34
+ print("⚠️ Using Gemini 2.0 Flash (2.5 not available)")
35
+ except Exception:
36
+ self.model = genai.GenerativeModel('gemini-2.0-flash-exp')
37
+ print("⚠️ Using Gemini 2.0 Flash Experimental (fallback)")
38
+
39
+ async def analyze_reference_image(self, image: Image.Image) -> str:
40
+ """
41
+ Analyze visual style from reference image
42
+
43
+ Args:
44
+ image: PIL Image to analyze
45
+
46
+ Returns:
47
+ Style description string
48
+ """
49
+ self._init_client()
50
+
51
+ try:
52
+ prompt = """Analyze this image and describe its visual style in detail.
53
+ Focus on:
54
+ - Art style (realistic, cartoon, anime, etc.)
55
+ - Color palette and mood
56
+ - Lighting and atmosphere
57
+ - Artistic technique
58
+ - Overall aesthetic
59
+
60
+ Provide a concise description (2-3 sentences) suitable for guiding image generation."""
61
+
62
+ response = self.model.generate_content([prompt, image])
63
+ return response.text
64
+
65
+ except Exception as e:
66
+ print(f"Error analyzing image: {e}")
67
+ return "fantasy illustration style with vibrant colors"
68
+
69
+ async def analyze_panel_quality(self, image: Image.Image, panel_data: dict) -> float:
70
+ """
71
+ Analyze quality of a comic panel image
72
+
73
+ Args:
74
+ image: Generated panel image
75
+ panel_data: Panel metadata with scene_description
76
+
77
+ Returns:
78
+ Quality score from 0-10
79
+ """
80
+ if not self.enabled:
81
+ return 8.0 # Default score when disabled
82
+
83
+ self._init_client()
84
+
85
+ try:
86
+ scene = panel_data.get("scene_description", "")
87
+ prompt = f"""Analyze this comic panel image and rate its quality from 0-10.
88
+
89
+ Expected scene: {scene}
90
+
91
+ Rate based on:
92
+ - Visual clarity and composition
93
+ - Adherence to scene description
94
+ - Artistic quality
95
+ - Suitability for comic storytelling
96
+
97
+ Respond with ONLY a number from 0-10."""
98
+
99
+ response = self.model.generate_content([prompt, image])
100
+ score_text = response.text.strip()
101
+
102
+ # Extract numeric score
103
+ try:
104
+ score = float(score_text)
105
+ return min(max(score, 0), 10) # Clamp to 0-10
106
+ except ValueError:
107
+ # Try to extract first number
108
+ import re
109
+ match = re.search(r'(\d+\.?\d*)', score_text)
110
+ if match:
111
+ return min(max(float(match.group(1)), 0), 10)
112
+ return 7.0 # Default if parsing fails
113
+
114
+ except Exception as e:
115
+ print(f"⚠️ Gemini quality analysis failed: {e}")
116
+ return 7.0 # Default score on error
117
+
118
+ def estimate_cost(self) -> float:
119
+ """Estimate cost for Gemini API call"""
120
+ # Gemini has generous free tier
121
+ return 0.0
mythforge_mcp_memory_client.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MythForge MCP Memory Client - Minimal client for LlamaIndex memory operations only
3
+
4
+ This client only handles story memory operations via MCP protocol.
5
+ Used by app_comic.py to access memory through MCP instead of direct calls.
6
+ """
7
+ import asyncio
8
+ import json
9
+ from typing import Optional, Dict, Any
10
+ from pathlib import Path
11
+
12
+ try:
13
+ from mcp import ClientSession, StdioServerParameters
14
+ from mcp.client.stdio import stdio_client
15
+ MCP_AVAILABLE = True
16
+ except ImportError:
17
+ MCP_AVAILABLE = False
18
+
19
+
20
+ class MythForgeMCPMemoryClient:
21
+ """
22
+ Minimal MCP client wrapper for LlamaIndex memory operations only.
23
+
24
+ Provides the same interface as StoryMemory for retrieve_context and store_story,
25
+ but routes calls through MCP server instead of direct LlamaIndex access.
26
+ """
27
+
28
+ def __init__(self, server_script: str = "mythforge_mcp_server.py", cwd: Optional[str] = None):
29
+ """
30
+ Initialize MCP memory client.
31
+
32
+ Args:
33
+ server_script: Path to the MCP server script
34
+ cwd: Working directory for the server
35
+ """
36
+ if not MCP_AVAILABLE:
37
+ raise ImportError("MCP client not available. Install: pip install mcp")
38
+
39
+ self.server_script = Path(server_script).resolve()
40
+ self.cwd = Path(cwd).resolve() if cwd else self.server_script.parent
41
+
42
+ self.server_params = StdioServerParameters(
43
+ command="python",
44
+ args=[str(self.server_script)],
45
+ cwd=str(self.cwd)
46
+ )
47
+
48
+ async def _call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
49
+ """Internal method to call MCP tool with proper context management"""
50
+ async with stdio_client(self.server_params) as (read, write):
51
+ async with ClientSession(read, write) as session:
52
+ await session.initialize()
53
+ result = await session.call_tool(tool_name, arguments=arguments)
54
+ return result
55
+
56
+ async def retrieve_context(self, query: str, top_k: int = 3) -> str:
57
+ """
58
+ Retrieve relevant story context based on query (MCP version).
59
+
60
+ Args:
61
+ query: Search query
62
+ top_k: Number of results to return
63
+
64
+ Returns:
65
+ Retrieved context as string
66
+ """
67
+ try:
68
+ result = await self._call_tool(
69
+ "search_story_memory",
70
+ arguments={"query": query, "top_k": top_k}
71
+ )
72
+
73
+ # Extract result from MCP response
74
+ if hasattr(result, 'content') and result.content:
75
+ content_item = result.content[0]
76
+ if hasattr(content_item, 'text'):
77
+ try:
78
+ result_dict = json.loads(content_item.text)
79
+ return result_dict.get("context", "")
80
+ except json.JSONDecodeError:
81
+ return content_item.text
82
+
83
+ return ""
84
+
85
+ except Exception as e:
86
+ print(f"⚠️ MCP memory retrieval failed: {e}")
87
+ return "Error retrieving context."
88
+
89
+ async def store_story(self, story_text: str, metadata: Dict) -> None:
90
+ """
91
+ Store story in knowledge graph (MCP version).
92
+
93
+ Args:
94
+ story_text: The story text
95
+ metadata: Story metadata (style, tone, etc.)
96
+ """
97
+ try:
98
+ result = await self._call_tool(
99
+ "store_story_in_memory",
100
+ arguments={
101
+ "story_text": story_text,
102
+ "title": metadata.get("title"),
103
+ "style": metadata.get("style"),
104
+ "metadata": metadata
105
+ }
106
+ )
107
+
108
+ # Check if successful
109
+ if hasattr(result, 'content') and result.content:
110
+ content_item = result.content[0]
111
+ if hasattr(content_item, 'text'):
112
+ try:
113
+ result_dict = json.loads(content_item.text)
114
+ if result_dict.get("success"):
115
+ print(f"✅ Story stored via MCP. Total stories: {result_dict.get('total_stories', 0)}")
116
+ else:
117
+ print(f"⚠️ MCP storage failed: {result_dict.get('error', 'Unknown error')}")
118
+ except json.JSONDecodeError:
119
+ pass
120
+
121
+ except Exception as e:
122
+ print(f"⚠️ MCP memory storage failed: {e}")
123
+
124
+ def retrieve_context_sync(self, query: str, top_k: int = 3) -> str:
125
+ """
126
+ Synchronous wrapper for retrieve_context.
127
+ Creates a fresh connection for each call.
128
+ """
129
+ try:
130
+ # Use get_event_loop() if one exists, otherwise create new
131
+ try:
132
+ loop = asyncio.get_event_loop()
133
+ if loop.is_running():
134
+ # If loop is running, we need to use a different approach
135
+ # For now, create a new loop in a thread
136
+ import concurrent.futures
137
+ with concurrent.futures.ThreadPoolExecutor() as executor:
138
+ future = executor.submit(asyncio.run, self.retrieve_context(query, top_k))
139
+ return future.result()
140
+ else:
141
+ return loop.run_until_complete(self.retrieve_context(query, top_k))
142
+ except RuntimeError:
143
+ # No event loop, create one
144
+ return asyncio.run(self.retrieve_context(query, top_k))
145
+ except Exception as e:
146
+ print(f"⚠️ MCP memory retrieval (sync) failed: {e}")
147
+ return ""
148
+
149
+ def store_story_sync(self, story_text: str, metadata: Dict) -> None:
150
+ """
151
+ Synchronous wrapper for store_story.
152
+ Creates a fresh connection for each call.
153
+ """
154
+ try:
155
+ try:
156
+ loop = asyncio.get_event_loop()
157
+ if loop.is_running():
158
+ import concurrent.futures
159
+ with concurrent.futures.ThreadPoolExecutor() as executor:
160
+ future = executor.submit(asyncio.run, self.store_story(story_text, metadata))
161
+ future.result()
162
+ else:
163
+ loop.run_until_complete(self.store_story(story_text, metadata))
164
+ except RuntimeError:
165
+ asyncio.run(self.store_story(story_text, metadata))
166
+ except Exception as e:
167
+ print(f"⚠️ MCP memory storage (sync) failed: {e}")
168
+
169
+
170
+ # Global client instance (lazy initialized)
171
+ _mcp_memory_client = None
172
+
173
+
174
+ def get_mcp_memory_client() -> Optional[MythForgeMCPMemoryClient]:
175
+ """
176
+ Get or create global MCP memory client instance.
177
+
178
+ Returns:
179
+ MythForgeMCPMemoryClient instance or None if MCP not available
180
+ """
181
+ global _mcp_memory_client
182
+
183
+ from config import Config
184
+
185
+ # Only create if MCP is enabled
186
+ if not Config.ENABLE_MCP:
187
+ return None
188
+
189
+ if _mcp_memory_client is None:
190
+ try:
191
+ _mcp_memory_client = MythForgeMCPMemoryClient()
192
+ print("✅ MCP Memory Client initialized")
193
+ except Exception as e:
194
+ print(f"⚠️ Failed to initialize MCP Memory Client: {e}")
195
+ return None
196
+
197
+ return _mcp_memory_client
mythforge_mcp_server.py ADDED
@@ -0,0 +1,1020 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MythForge MCP Server - Exposes comic generation capabilities via Model Context Protocol
3
+
4
+ This server is opt-in via ENABLE_MCP=true in .env for backward compatibility.
5
+ Run separately: python mythforge_mcp_server.py
6
+ """
7
+ import os
8
+ import sys
9
+ import asyncio
10
+ import json
11
+ import base64
12
+ from typing import List, Optional, Dict, Any
13
+ from pathlib import Path
14
+ from datetime import datetime
15
+
16
+ from mcp.server.fastmcp import FastMCP, Context
17
+ from config import Config
18
+
19
+ # Only proceed if MCP is enabled
20
+ if not Config.ENABLE_MCP:
21
+ print("⚠️ MCP server disabled. Set ENABLE_MCP=true in .env to enable.")
22
+ sys.exit(0)
23
+
24
+ # Initialize MCP server
25
+ mcp = FastMCP("MythForge", json_response=True)
26
+
27
+ # Lazy-loaded app instance (only created when needed)
28
+ _comic_app = None
29
+ _character_manager = None
30
+ _export_service = None
31
+
32
+
33
+ # def get_comic_app():
34
+ # """Lazy load ComicGeneratorApp (only if MCP is enabled)"""
35
+ # global _comic_app
36
+ # if _comic_app is None:
37
+ # try:
38
+ # from app_comic import ComicGeneratorApp
39
+ # _comic_app = ComicGeneratorApp()
40
+ # print("✅ ComicGeneratorApp initialized for MCP")
41
+ # except Exception as e:
42
+ # print(f"❌ Failed to initialize ComicGeneratorApp: {e}")
43
+ # raise
44
+ # return _comic_app
45
+
46
+ def get_story_memory():
47
+ """Get StoryMemory instance directly (for MCP server use)"""
48
+ try:
49
+ from story_memory import StoryMemory
50
+ from config import Config
51
+
52
+ if Config.ENABLE_LLAMAINDEX:
53
+ return StoryMemory()
54
+ return None
55
+ except Exception as e:
56
+ print(f"⚠️ StoryMemory not available: {e}")
57
+ return None
58
+
59
+
60
+ def get_character_manager():
61
+ """Lazy load CharacterConsistencyManager (optional)"""
62
+ global _character_manager
63
+ if _character_manager is None:
64
+ try:
65
+ from character_consistency_manager import CharacterConsistencyManager
66
+ if Config.ENABLE_NANO_BANANA:
67
+ _character_manager = CharacterConsistencyManager()
68
+ print("✅ CharacterConsistencyManager initialized for MCP")
69
+ else:
70
+ print("⚠️ Character consistency disabled (ENABLE_NANO_BANANA=false)")
71
+ except Exception as e:
72
+ print(f"⚠️ Character consistency not available: {e}")
73
+ return _character_manager
74
+
75
+
76
+ def get_export_service():
77
+ """Lazy load ExportService"""
78
+ global _export_service
79
+ if _export_service is None:
80
+ try:
81
+ from export_service import ExportService
82
+ _export_service = ExportService()
83
+ except Exception as e:
84
+ print(f"❌ Failed to initialize ExportService: {e}")
85
+ raise
86
+ return _export_service
87
+
88
+
89
+ # ============================================================================
90
+ # CORE COMIC GENERATION TOOLS
91
+ # ============================================================================
92
+
93
+ @mcp.tool()
94
+ async def generate_comic(
95
+ prompt: str,
96
+ style: str = "manga",
97
+ num_panels: int = 6,
98
+ include_audio: bool = True,
99
+ include_bubbles: bool = True,
100
+ ctx: Context = None
101
+ ) -> Dict[str, Any]:
102
+ """
103
+ Generate a complete comic from a text prompt.
104
+
105
+ Args:
106
+ prompt: Story prompt (e.g., "A wizard discovers a magical spellbook")
107
+ style: Art style (manga, cartoon, anime, comic)
108
+ num_panels: Number of panels (default 6)
109
+ include_audio: Generate voice narration
110
+ include_bubbles: Add speech bubbles with dialogue
111
+
112
+ Returns:
113
+ Dictionary with:
114
+ - success: bool
115
+ - comic_id: str (directory name)
116
+ - output_dir: str (full path)
117
+ - metadata: Dict (title, timestamp, etc.)
118
+ - panels: List[str] (panel image paths)
119
+ - audio_files: List[str] (audio file paths)
120
+ - manga_page: str (full page image path)
121
+ """
122
+ if ctx:
123
+ await ctx.info(f"🎨 Generating comic: {prompt[:50]}...")
124
+
125
+ try:
126
+ app = get_comic_app()
127
+
128
+ # Create progress callback if context available
129
+ progress_callback = None
130
+ if ctx:
131
+ async def progress(step: float, desc: str = ""):
132
+ await ctx.report_progress(int(step * 100), 100, desc)
133
+ progress_callback = progress
134
+
135
+ # Generate comic
136
+ result = await app.generate_comic_async(
137
+ prompt=prompt,
138
+ style=style,
139
+ include_audio=include_audio,
140
+ include_bubbles=include_bubbles,
141
+ progress=progress_callback
142
+ )
143
+
144
+ # Extract output directory
145
+ metadata = result.get("metadata", {})
146
+ title = metadata.get("title", "MythForge_Comic").replace(" ", "_")
147
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
148
+ comic_id = f"{title}_{timestamp}"
149
+
150
+ # Save outputs to directory
151
+ output_dir = Path("./auto_generated_comics") / comic_id
152
+ output_dir.mkdir(parents=True, exist_ok=True)
153
+
154
+ # Save manga page
155
+ manga_page_path = None
156
+ if result.get("manga_page"):
157
+ manga_page_path = output_dir / "manga_page.png"
158
+ result["manga_page"].save(str(manga_page_path))
159
+
160
+ # Save panel images
161
+ panel_paths = []
162
+ for i, panel_img in enumerate(result.get("panel_images", []), 1):
163
+ panel_path = output_dir / f"panel_{i:02d}.png"
164
+ panel_img.save(str(panel_path))
165
+ panel_paths.append(str(panel_path))
166
+
167
+ # Copy audio files
168
+ audio_paths = []
169
+ for i, audio_path in enumerate(result.get("audio_files", []), 1):
170
+ if audio_path and os.path.exists(audio_path):
171
+ import shutil
172
+ dest_path = output_dir / f"panel_{i:02d}_audio.mp3"
173
+ shutil.copy(audio_path, dest_path)
174
+ audio_paths.append(str(dest_path))
175
+
176
+ # Save metadata
177
+ metadata_path = output_dir / "metadata.json"
178
+ with open(metadata_path, 'w') as f:
179
+ json.dump({
180
+ **metadata,
181
+ "comic_id": comic_id,
182
+ "output_dir": str(output_dir),
183
+ "panel_count": len(panel_paths),
184
+ "audio_count": len(audio_paths)
185
+ }, f, indent=2)
186
+
187
+ if ctx:
188
+ await ctx.info(f"✅ Comic generated: {comic_id}")
189
+
190
+ return {
191
+ "success": True,
192
+ "comic_id": comic_id,
193
+ "output_dir": str(output_dir),
194
+ "metadata": metadata,
195
+ "panels": panel_paths,
196
+ "audio_files": audio_paths,
197
+ "manga_page": str(manga_page_path) if manga_page_path else None
198
+ }
199
+
200
+ except Exception as e:
201
+ error_msg = f"Failed to generate comic: {str(e)}"
202
+ if ctx:
203
+ await ctx.error(error_msg)
204
+ return {
205
+ "success": False,
206
+ "error": error_msg
207
+ }
208
+
209
+
210
+ @mcp.tool()
211
+ async def generate_comic_with_characters(
212
+ prompt: str,
213
+ character_names: List[str],
214
+ num_panels: int = 6,
215
+ art_style: str = "manga",
216
+ include_surprise_character: bool = True,
217
+ ctx: Context = None
218
+ ) -> Dict[str, Any]:
219
+ """
220
+ Generate comic with specific characters (uses character consistency system).
221
+
222
+ Args:
223
+ prompt: Story prompt
224
+ character_names: List of character names to include
225
+ num_panels: Number of panels
226
+ art_style: Visual style (manga, anime, comic, etc.)
227
+ include_surprise_character: Add AI-generated surprise character
228
+
229
+ Returns:
230
+ Dictionary with generation results
231
+ """
232
+ if ctx:
233
+ await ctx.info(f"🎨 Generating comic with characters: {', '.join(character_names)}")
234
+
235
+ try:
236
+ manager = get_character_manager()
237
+ if not manager:
238
+ return {
239
+ "success": False,
240
+ "error": "Character consistency not available. Set ENABLE_NANO_BANANA=true"
241
+ }
242
+
243
+ result = manager.generate_comic_from_prompt(
244
+ story_prompt=prompt,
245
+ num_panels=num_panels,
246
+ art_style=art_style,
247
+ include_surprise_character=include_surprise_character
248
+ )
249
+
250
+ if ctx:
251
+ await ctx.info("✅ Comic with character consistency generated")
252
+
253
+ return {
254
+ "success": True,
255
+ "characters": result.get("characters", {}),
256
+ "panels": [str(p) for p in result.get("panels", [])],
257
+ "panel_info": result.get("panel_info", []),
258
+ "surprise_character": result.get("surprise_character"),
259
+ "output_dir": result.get("output_dir")
260
+ }
261
+
262
+ except Exception as e:
263
+ error_msg = f"Failed to generate comic with characters: {str(e)}"
264
+ if ctx:
265
+ await ctx.error(error_msg)
266
+ return {
267
+ "success": False,
268
+ "error": error_msg
269
+ }
270
+
271
+
272
+ # ============================================================================
273
+ # CHARACTER MANAGEMENT TOOLS
274
+ # ============================================================================
275
+
276
+ @mcp.tool()
277
+ async def create_character(
278
+ name: str,
279
+ description: str,
280
+ reference_image_path: Optional[str] = None,
281
+ ctx: Context = None
282
+ ) -> Dict[str, Any]:
283
+ """
284
+ Create a new character profile for consistency.
285
+
286
+ Args:
287
+ name: Character name
288
+ description: Physical description
289
+ reference_image_path: Optional path to reference image
290
+
291
+ Returns:
292
+ Character profile information
293
+ """
294
+ if ctx:
295
+ await ctx.info(f"👤 Creating character: {name}")
296
+
297
+ try:
298
+ manager = get_character_manager()
299
+ if not manager:
300
+ return {
301
+ "success": False,
302
+ "error": "Character consistency not available. Set ENABLE_NANO_BANANA=true"
303
+ }
304
+
305
+ # Create character
306
+ character = manager.create_character(
307
+ name=name,
308
+ description=description,
309
+ reference_image_path=reference_image_path
310
+ )
311
+
312
+ if ctx:
313
+ await ctx.info(f"✅ Character created: {name}")
314
+
315
+ return {
316
+ "success": True,
317
+ "character": character
318
+ }
319
+
320
+ except Exception as e:
321
+ error_msg = f"Failed to create character: {str(e)}"
322
+ if ctx:
323
+ await ctx.error(error_msg)
324
+ return {
325
+ "success": False,
326
+ "error": error_msg
327
+ }
328
+
329
+
330
+ @mcp.tool()
331
+ async def list_characters(ctx: Context = None) -> List[Dict[str, Any]]:
332
+ """List all available characters in the library."""
333
+ try:
334
+ manager = get_character_manager()
335
+ if not manager:
336
+ return []
337
+
338
+ characters = manager.list_all_characters()
339
+ return [{"name": name, "profile": profile} for name, profile in characters.items()]
340
+
341
+ except Exception as e:
342
+ if ctx:
343
+ await ctx.error(f"Failed to list characters: {str(e)}")
344
+ return []
345
+
346
+
347
+ @mcp.tool()
348
+ async def get_character_info(character_name: str, ctx: Context = None) -> Dict[str, Any]:
349
+ """Get detailed information about a character."""
350
+ try:
351
+ manager = get_character_manager()
352
+ if not manager:
353
+ return {
354
+ "success": False,
355
+ "error": "Character consistency not available"
356
+ }
357
+
358
+ info = manager.get_character_info_summary(character_name)
359
+ return {
360
+ "success": True,
361
+ "character_name": character_name,
362
+ "info": info
363
+ }
364
+
365
+ except Exception as e:
366
+ return {
367
+ "success": False,
368
+ "error": str(e)
369
+ }
370
+
371
+
372
+ # ============================================================================
373
+ # EXPORT & FILE MANAGEMENT TOOLS
374
+ # ============================================================================
375
+
376
+ @mcp.tool()
377
+ async def export_comic(
378
+ comic_id: str,
379
+ format: str = "zip", # zip, pdf, web
380
+ ctx: Context = None
381
+ ) -> Dict[str, Any]:
382
+ """
383
+ Export comic in specified format.
384
+
385
+ Args:
386
+ comic_id: Comic directory name or ID
387
+ format: Export format (zip, pdf, web)
388
+
389
+ Returns:
390
+ Path to exported file
391
+ """
392
+ if ctx:
393
+ await ctx.info(f"📦 Exporting comic {comic_id} as {format}")
394
+
395
+ try:
396
+ # Find comic directory
397
+ comics_dir = Path("./auto_generated_comics")
398
+ comic_dir = comics_dir / comic_id
399
+
400
+ if not comic_dir.exists():
401
+ return {
402
+ "success": False,
403
+ "error": f"Comic {comic_id} not found"
404
+ }
405
+
406
+ # Load metadata
407
+ metadata_path = comic_dir / "metadata.json"
408
+ if metadata_path.exists():
409
+ with open(metadata_path, 'r') as f:
410
+ metadata = json.load(f)
411
+ else:
412
+ metadata = {}
413
+
414
+ # Load manga page
415
+ manga_page_path = comic_dir / "manga_page.png"
416
+ if not manga_page_path.exists():
417
+ return {
418
+ "success": False,
419
+ "error": "Manga page not found"
420
+ }
421
+
422
+ from PIL import Image
423
+ manga_page = Image.open(manga_page_path)
424
+
425
+ # Load panel images
426
+ panel_images = []
427
+ for i in range(1, 7):
428
+ panel_path = comic_dir / f"panel_{i:02d}.png"
429
+ if panel_path.exists():
430
+ panel_images.append(Image.open(panel_path))
431
+
432
+ # Load audio files
433
+ audio_files = []
434
+ for i in range(1, 7):
435
+ audio_path = comic_dir / f"panel_{i:02d}_audio.mp3"
436
+ if audio_path.exists():
437
+ audio_files.append(str(audio_path))
438
+
439
+ export_service = get_export_service()
440
+ title = metadata.get("title", comic_id).replace(" ", "_")
441
+
442
+ if format == "pdf":
443
+ pdf_bytes = export_service.generate_comic_pdf(
444
+ comic_page=manga_page,
445
+ title=metadata.get("title", "MythForge Comic"),
446
+ metadata=metadata
447
+ )
448
+ output_path = comic_dir / f"{title}.pdf"
449
+ with open(output_path, 'wb') as f:
450
+ f.write(pdf_bytes)
451
+
452
+ elif format == "zip":
453
+ zip_bytes = export_service.generate_comic_zip(
454
+ comic_page=manga_page,
455
+ panel_images=panel_images,
456
+ audio_files=audio_files,
457
+ title=title,
458
+ metadata=metadata
459
+ )
460
+ output_path = comic_dir / f"{title}.zip"
461
+ with open(output_path, 'wb') as f:
462
+ f.write(zip_bytes)
463
+
464
+ elif format == "web":
465
+ web_bytes = export_service.generate_web_package(
466
+ comic_page=manga_page,
467
+ audio_files=audio_files,
468
+ title=title,
469
+ metadata=metadata
470
+ )
471
+ output_path = comic_dir / f"{title}_web.zip"
472
+ with open(output_path, 'wb') as f:
473
+ f.write(web_bytes)
474
+ else:
475
+ return {
476
+ "success": False,
477
+ "error": f"Unknown format: {format}"
478
+ }
479
+
480
+ if ctx:
481
+ await ctx.info(f"✅ Exported to {output_path}")
482
+
483
+ return {
484
+ "success": True,
485
+ "format": format,
486
+ "output_path": str(output_path)
487
+ }
488
+
489
+ except Exception as e:
490
+ error_msg = f"Failed to export comic: {str(e)}"
491
+ if ctx:
492
+ await ctx.error(error_msg)
493
+ return {
494
+ "success": False,
495
+ "error": error_msg
496
+ }
497
+
498
+
499
+ @mcp.tool()
500
+ async def list_generated_comics(ctx: Context = None) -> List[Dict[str, Any]]:
501
+ """List all generated comics with metadata."""
502
+ try:
503
+ comics_dir = Path("./auto_generated_comics")
504
+ if not comics_dir.exists():
505
+ return []
506
+
507
+ comics = []
508
+ for comic_dir in comics_dir.iterdir():
509
+ if comic_dir.is_dir():
510
+ metadata_path = comic_dir / "metadata.json"
511
+ if metadata_path.exists():
512
+ with open(metadata_path, 'r') as f:
513
+ metadata = json.load(f)
514
+ comics.append({
515
+ "comic_id": comic_dir.name,
516
+ "title": metadata.get("title", comic_dir.name),
517
+ "timestamp": metadata.get("timestamp"),
518
+ "style": metadata.get("style"),
519
+ "panel_count": metadata.get("panel_count", 0)
520
+ })
521
+
522
+ return sorted(comics, key=lambda x: x.get("timestamp", ""), reverse=True)
523
+
524
+ except Exception as e:
525
+ if ctx:
526
+ await ctx.error(f"Failed to list comics: {str(e)}")
527
+ return []
528
+
529
+
530
+ # ============================================================================
531
+ # STORY MEMORY & CONTEXT TOOLS (Enhanced LlamaIndex Integration)
532
+ # ============================================================================
533
+
534
+ @mcp.tool()
535
+ async def search_story_memory(
536
+ query: str,
537
+ top_k: int = 3,
538
+ ctx: Context = None
539
+ ) -> Dict[str, Any]:
540
+ """
541
+ Search previous stories for context using LlamaIndex vector search.
542
+
543
+ Args:
544
+ query: Search query (e.g., "wizard discovers spellbook")
545
+ top_k: Number of results to return
546
+
547
+ Returns:
548
+ Dictionary with:
549
+ - success: bool
550
+ - query: Original query
551
+ - context: Retrieved context string
552
+ - top_k: Number of results
553
+ - has_results: Whether results were found
554
+ """
555
+ if ctx:
556
+ await ctx.info(f"🔍 Searching story memory: {query[:50]}...")
557
+
558
+ try:
559
+ # Use StoryMemory directly, not through app
560
+ story_memory = get_story_memory()
561
+ if not story_memory:
562
+ return {
563
+ "success": False,
564
+ "error": "Story memory not available. Set ENABLE_LLAMAINDEX=true",
565
+ "query": query,
566
+ "context": "",
567
+ "has_results": False
568
+ }
569
+
570
+ context = app.story_memory.retrieve_context(query, top_k=top_k)
571
+ has_results = context and context != "No stories in memory yet." and "Error" not in context
572
+
573
+ if ctx:
574
+ await ctx.info(f"✅ Found {top_k} relevant stories" if has_results else "⚠️ No stories found")
575
+
576
+ return {
577
+ "success": True,
578
+ "query": query,
579
+ "context": context,
580
+ "top_k": top_k,
581
+ "has_results": has_results
582
+ }
583
+
584
+ except Exception as e:
585
+ error_msg = f"Failed to search story memory: {str(e)}"
586
+ if ctx:
587
+ await ctx.error(error_msg)
588
+ return {
589
+ "success": False,
590
+ "error": error_msg,
591
+ "query": query,
592
+ "context": "",
593
+ "has_results": False
594
+ }
595
+
596
+
597
+ @mcp.tool()
598
+ async def store_story_in_memory(
599
+ story_text: str,
600
+ title: Optional[str] = None,
601
+ style: Optional[str] = None,
602
+ metadata: Optional[Dict[str, Any]] = None,
603
+ ctx: Context = None
604
+ ) -> Dict[str, Any]:
605
+ """
606
+ Store a story in LlamaIndex memory for future retrieval.
607
+
608
+ Args:
609
+ story_text: The story text to store
610
+ title: Optional story title
611
+ style: Optional art style (manga, cartoon, etc.)
612
+ metadata: Optional additional metadata
613
+
614
+ Returns:
615
+ Dictionary with storage result
616
+ """
617
+ if ctx:
618
+ await ctx.info("💾 Storing story in memory...")
619
+
620
+ try:
621
+ story_memory = get_story_memory()
622
+ if not story_memory:
623
+ return {
624
+ "success": False,
625
+ "error": "Story memory not available. Set ENABLE_LLAMAINDEX=true"
626
+ }
627
+
628
+ # Prepare metadata FIRST (before using it)
629
+ story_metadata = {
630
+ "title": title or "Untitled Story",
631
+ "style": style or "manga",
632
+ **(metadata or {})
633
+ }
634
+
635
+ # Store story
636
+ story_memory.store_story(story_text, story_metadata)
637
+
638
+ # Get updated stats
639
+ stats = story_memory.get_memory_stats()
640
+
641
+ if ctx:
642
+ await ctx.info(f"✅ Story stored. Total stories: {stats['total_stories']}")
643
+
644
+ return {
645
+ "success": True,
646
+ "title": story_metadata["title"],
647
+ "characters_extracted": len(story_memory.character_db),
648
+ "total_stories": stats["total_stories"],
649
+ "total_characters": stats["total_characters"]
650
+ }
651
+
652
+ except Exception as e:
653
+ error_msg = f"Failed to store story: {str(e)}"
654
+ if ctx:
655
+ await ctx.error(error_msg)
656
+ return {
657
+ "success": False,
658
+ "error": error_msg
659
+ }
660
+
661
+
662
+ @mcp.tool()
663
+ async def get_character_from_memory(
664
+ character_name: str,
665
+ ctx: Context = None
666
+ ) -> Dict[str, Any]:
667
+ """
668
+ Get stored information about a character from memory.
669
+
670
+ Args:
671
+ character_name: Name of the character
672
+
673
+ Returns:
674
+ Character information dictionary
675
+ """
676
+ if ctx:
677
+ await ctx.info(f"👤 Retrieving character: {character_name}")
678
+
679
+ try:
680
+ app = get_comic_app()
681
+ if not app.story_memory:
682
+ return {
683
+ "success": False,
684
+ "error": "Story memory not available",
685
+ "character_name": character_name
686
+ }
687
+
688
+ char_info = app.story_memory.get_character_info(character_name)
689
+
690
+ if not char_info:
691
+ return {
692
+ "success": False,
693
+ "error": f"Character '{character_name}' not found in memory",
694
+ "character_name": character_name
695
+ }
696
+
697
+ # Get consistency prompt
698
+ consistency_prompt = app.story_memory.get_character_consistency_prompt(character_name)
699
+
700
+ if ctx:
701
+ await ctx.info(f"✅ Found character: {character_name}")
702
+
703
+ return {
704
+ "success": True,
705
+ "character_name": character_name,
706
+ "appearances": char_info.get("appearances", 0),
707
+ "descriptions": char_info.get("descriptions", []),
708
+ "visual_attributes": char_info.get("visual_attributes", {}),
709
+ "consistency_prompt": consistency_prompt
710
+ }
711
+
712
+ except Exception as e:
713
+ error_msg = f"Failed to get character: {str(e)}"
714
+ if ctx:
715
+ await ctx.error(error_msg)
716
+ return {
717
+ "success": False,
718
+ "error": error_msg,
719
+ "character_name": character_name
720
+ }
721
+
722
+
723
+ @mcp.tool()
724
+ async def list_characters_in_memory(ctx: Context = None) -> Dict[str, Any]:
725
+ """
726
+ List all characters stored in story memory.
727
+
728
+ Returns:
729
+ Dictionary with list of characters and statistics
730
+ """
731
+ try:
732
+ app = get_comic_app()
733
+ if not app.story_memory:
734
+ return {
735
+ "success": False,
736
+ "error": "Story memory not available",
737
+ "characters": []
738
+ }
739
+
740
+ all_characters = app.story_memory.get_all_characters()
741
+ stats = app.story_memory.get_memory_stats()
742
+
743
+ # Format character list
744
+ character_list = []
745
+ for name, info in all_characters.items():
746
+ character_list.append({
747
+ "name": name,
748
+ "appearances": info.get("appearances", 0),
749
+ "has_descriptions": len(info.get("descriptions", [])) > 0,
750
+ "has_visual_attributes": bool(info.get("visual_attributes", {}))
751
+ })
752
+
753
+ return {
754
+ "success": True,
755
+ "total_characters": len(character_list),
756
+ "total_stories": stats.get("total_stories", 0),
757
+ "characters": character_list
758
+ }
759
+
760
+ except Exception as e:
761
+ error_msg = f"Failed to list characters: {str(e)}"
762
+ if ctx:
763
+ await ctx.error(error_msg)
764
+ return {
765
+ "success": False,
766
+ "error": error_msg,
767
+ "characters": []
768
+ }
769
+
770
+
771
+ @mcp.tool()
772
+ async def get_memory_statistics(ctx: Context = None) -> Dict[str, Any]:
773
+ """
774
+ Get statistics about the story memory system.
775
+
776
+ Returns:
777
+ Dictionary with memory statistics
778
+ """
779
+ try:
780
+ app = get_comic_app()
781
+ if not app.story_memory:
782
+ return {
783
+ "success": False,
784
+ "error": "Story memory not available",
785
+ "enabled": False
786
+ }
787
+
788
+ stats = app.story_memory.get_memory_stats()
789
+
790
+ return {
791
+ "success": True,
792
+ "enabled": True,
793
+ "total_characters": stats.get("total_characters", 0),
794
+ "total_stories": stats.get("total_stories", 0),
795
+ "multimodal_enabled": stats.get("multimodal_enabled", False),
796
+ "character_names": stats.get("characters", []),
797
+ "multimodal_docs": stats.get("multimodal_docs", 0)
798
+ }
799
+
800
+ except Exception as e:
801
+ return {
802
+ "success": False,
803
+ "error": str(e),
804
+ "enabled": False
805
+ }
806
+
807
+
808
+ @mcp.tool()
809
+ async def get_character_consistency_context(
810
+ character_name: str,
811
+ ctx: Context = None
812
+ ) -> Dict[str, Any]:
813
+ """
814
+ Get character consistency prompt for maintaining character appearance across stories.
815
+
816
+ Args:
817
+ character_name: Name of the character
818
+
819
+ Returns:
820
+ Consistency prompt and character information
821
+ """
822
+ if ctx:
823
+ await ctx.info(f"🎭 Getting consistency context for: {character_name}")
824
+
825
+ try:
826
+ app = get_comic_app()
827
+ if not app.story_memory:
828
+ return {
829
+ "success": False,
830
+ "error": "Story memory not available",
831
+ "character_name": character_name
832
+ }
833
+
834
+ char_info = app.story_memory.get_character_info(character_name)
835
+ if not char_info:
836
+ return {
837
+ "success": False,
838
+ "error": f"Character '{character_name}' not found",
839
+ "character_name": character_name,
840
+ "consistency_prompt": ""
841
+ }
842
+
843
+ consistency_prompt = app.story_memory.get_character_consistency_prompt(character_name)
844
+
845
+ return {
846
+ "success": True,
847
+ "character_name": character_name,
848
+ "consistency_prompt": consistency_prompt,
849
+ "appearances": char_info.get("appearances", 0),
850
+ "descriptions": char_info.get("descriptions", []),
851
+ "visual_attributes": char_info.get("visual_attributes", {})
852
+ }
853
+
854
+ except Exception as e:
855
+ error_msg = f"Failed to get consistency context: {str(e)}"
856
+ if ctx:
857
+ await ctx.error(error_msg)
858
+ return {
859
+ "success": False,
860
+ "error": error_msg,
861
+ "character_name": character_name,
862
+ "consistency_prompt": ""
863
+ }
864
+
865
+
866
+ # ============================================================================
867
+ # UTILITY TOOLS
868
+ # ============================================================================
869
+
870
+ @mcp.tool()
871
+ async def check_api_status(ctx: Context = None) -> Dict[str, Any]:
872
+ """Check status of all integrated APIs (Modal, SambaNova, ElevenLabs, etc.)."""
873
+ status = {
874
+ "openai": Config.OPENAI_API_KEY is not None,
875
+ "modal": Config.MODAL_TOKEN is not None and Config.ENABLE_MODAL,
876
+ "sambanova": Config.SAMBANOVA_API_KEY is not None and Config.ENABLE_SAMBANOVA,
877
+ "elevenlabs": Config.ELEVENLABS_API_KEY is not None,
878
+ "gemini": Config.GEMINI_API_KEY is not None and Config.ENABLE_GEMINI,
879
+ "blaxel": Config.BLAXEL_API_KEY is not None and Config.ENABLE_BLAXEL,
880
+ "nano_banana": Config.ENABLE_NANO_BANANA,
881
+ "character_library": Config.ENABLE_CHARACTER_LIBRARY
882
+ }
883
+
884
+ return {
885
+ "apis": status,
886
+ "enabled_features": {
887
+ "comic_mode": Config.ENABLE_COMIC_MODE,
888
+ "mcp": Config.ENABLE_MCP
889
+ }
890
+ }
891
+
892
+
893
+ @mcp.tool()
894
+ async def estimate_generation_cost(
895
+ num_panels: int = 6,
896
+ include_audio: bool = True,
897
+ ctx: Context = None
898
+ ) -> Dict[str, Any]:
899
+ """
900
+ Estimate cost for generating a comic.
901
+
902
+ Returns:
903
+ Dictionary with cost breakdown by service
904
+ """
905
+ costs = {
906
+ "story_generation": {
907
+ "provider": "OpenAI GPT-4o-mini" if Config.USE_OPENAI_PRIMARY else "SambaNova",
908
+ "estimated_cost": 0.001 # Rough estimate
909
+ },
910
+ "image_generation": {
911
+ "provider": "Modal SDXL" if Config.ENABLE_MODAL else "Local",
912
+ "estimated_cost": num_panels * 0.00082 if Config.ENABLE_MODAL else 0.0
913
+ },
914
+ "audio_generation": {
915
+ "provider": "ElevenLabs",
916
+ "estimated_cost": num_panels * 0.005 if include_audio else 0.0
917
+ }
918
+ }
919
+
920
+ total = sum(c["estimated_cost"] for c in costs.values())
921
+
922
+ return {
923
+ "breakdown": costs,
924
+ "total_estimated_cost": total,
925
+ "num_panels": num_panels,
926
+ "include_audio": include_audio
927
+ }
928
+
929
+
930
+ # ============================================================================
931
+ # RESOURCES (for accessing generated content)
932
+ # ============================================================================
933
+
934
+ @mcp.resource("comic://{comic_id}/page")
935
+ def get_comic_page(comic_id: str) -> bytes:
936
+ """Get the full manga page image for a comic."""
937
+ comics_dir = Path("./auto_generated_comics")
938
+ page_path = comics_dir / comic_id / "manga_page.png"
939
+
940
+ if page_path.exists():
941
+ with open(page_path, 'rb') as f:
942
+ return f.read()
943
+ raise FileNotFoundError(f"Comic page not found: {comic_id}")
944
+
945
+
946
+ @mcp.resource("comic://{comic_id}/panel/{panel_num}")
947
+ def get_comic_panel(comic_id: str, panel_num: int) -> bytes:
948
+ """Get individual panel image."""
949
+ comics_dir = Path("./auto_generated_comics")
950
+ panel_path = comics_dir / comic_id / f"panel_{panel_num:02d}.png"
951
+
952
+ if panel_path.exists():
953
+ with open(panel_path, 'rb') as f:
954
+ return f.read()
955
+ raise FileNotFoundError(f"Panel {panel_num} not found for comic {comic_id}")
956
+
957
+
958
+ @mcp.resource("comic://{comic_id}/metadata")
959
+ def get_comic_metadata(comic_id: str) -> str:
960
+ """Get comic metadata JSON."""
961
+ comics_dir = Path("./auto_generated_comics")
962
+ metadata_path = comics_dir / comic_id / "metadata.json"
963
+
964
+ if metadata_path.exists():
965
+ with open(metadata_path, 'r') as f:
966
+ return f.read()
967
+ raise FileNotFoundError(f"Metadata not found for comic {comic_id}")
968
+
969
+
970
+ @mcp.resource("memory://characters/{character_name}")
971
+ def get_character_resource(character_name: str) -> str:
972
+ """Get character information as a resource."""
973
+ try:
974
+ app = get_comic_app()
975
+ if not app.story_memory:
976
+ return json.dumps({"error": "Story memory not available"})
977
+
978
+ char_info = app.story_memory.get_character_info(character_name)
979
+ if not char_info:
980
+ return json.dumps({"error": f"Character '{character_name}' not found"})
981
+
982
+ return json.dumps({
983
+ "character_name": character_name,
984
+ **char_info
985
+ }, indent=2)
986
+
987
+ except Exception as e:
988
+ return json.dumps({"error": str(e)})
989
+
990
+
991
+ @mcp.resource("memory://statistics")
992
+ def get_memory_statistics_resource() -> str:
993
+ """Get memory statistics as a resource."""
994
+ try:
995
+ app = get_comic_app()
996
+ if not app.story_memory:
997
+ return json.dumps({"enabled": False})
998
+
999
+ stats = app.story_memory.get_memory_stats()
1000
+ return json.dumps(stats, indent=2)
1001
+
1002
+ except Exception as e:
1003
+ return json.dumps({"error": str(e)})
1004
+
1005
+
1006
+ # ============================================================================
1007
+ # MAIN ENTRY POINT
1008
+ # ============================================================================
1009
+
1010
+ if __name__ == "__main__":
1011
+ if not Config.ENABLE_MCP:
1012
+ print("⚠️ MCP server is disabled. Set ENABLE_MCP=true in .env to enable.")
1013
+ sys.exit(0)
1014
+
1015
+ print("🚀 Starting MythForge MCP Server...")
1016
+ print(f"✅ MCP enabled: {Config.ENABLE_MCP}")
1017
+ print("📡 Server ready. Connect via stdio transport.")
1018
+
1019
+ # Run with stdio transport (for Claude Code, MCP Inspector, etc.)
1020
+ mcp.run(transport="stdio")
nano_banana_client.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nano Banana (Gemini 2.5/3 Pro Image) API Client
3
+ Handles image generation with character consistency using Google's Gemini Image models
4
+ """
5
+
6
+ import os
7
+ import base64
8
+ from io import BytesIO
9
+ from typing import List, Optional, Dict, Union
10
+ from PIL import Image
11
+ import google.generativeai as genai
12
+ from config import Config
13
+
14
+
15
+ class NanoBananaClient:
16
+ """Client for Gemini Image Generation (Nano Banana / Nano Banana Pro)"""
17
+
18
+ def __init__(self, api_key: Optional[str] = None, model: str = "gemini-2.5-flash-image"):
19
+ """
20
+ Initialize Nano Banana client
21
+
22
+ Args:
23
+ api_key: Gemini API key (defaults to Config.GEMINI_API_KEY)
24
+ model: Model name - Options:
25
+ - "gemini-2.5-flash-image" (Nano Banana)
26
+ - "gemini-3-pro-image-preview" (Nano Banana Pro)
27
+ """
28
+ self.api_key = api_key or Config.GEMINI_API_KEY
29
+ if not self.api_key:
30
+ raise ValueError("Gemini API key is required for Nano Banana")
31
+
32
+ genai.configure(api_key=self.api_key)
33
+ self.model_name = model
34
+ self.model = genai.GenerativeModel(model)
35
+
36
+ # Conversation history for iterative generation
37
+ self.conversation_history: List[Dict] = []
38
+
39
+ # Display model being used
40
+ model_display = "Gemini 3 Pro Image (Nano Banana Pro)" if "3-pro" in model else "Gemini 2.5 Flash Image (Nano Banana)"
41
+ print(f"✅ Using {model_display}")
42
+
43
+ def _prepare_image_parts(self, images: List[Union[str, Image.Image, bytes]]) -> List:
44
+ """
45
+ Prepare image parts for Gemini API
46
+
47
+ Args:
48
+ images: List of images (file paths, PIL Images, or bytes)
49
+
50
+ Returns:
51
+ List of image parts for API
52
+ """
53
+ image_parts = []
54
+
55
+ for img in images:
56
+ if isinstance(img, str):
57
+ # File path
58
+ with Image.open(img) as pil_img:
59
+ image_parts.append(pil_img)
60
+ elif isinstance(img, Image.Image):
61
+ # PIL Image
62
+ image_parts.append(img)
63
+ elif isinstance(img, bytes):
64
+ # Bytes
65
+ pil_img = Image.open(BytesIO(img))
66
+ image_parts.append(pil_img)
67
+ else:
68
+ raise ValueError(f"Unsupported image type: {type(img)}")
69
+
70
+ return image_parts
71
+
72
+ def generate_image(
73
+ self,
74
+ prompt: str,
75
+ reference_images: Optional[List[Union[str, Image.Image, bytes]]] = None,
76
+ aspect_ratio: str = "1:1",
77
+ num_images: int = 1,
78
+ safety_filter: str = "default"
79
+ ) -> List[Image.Image]:
80
+ """
81
+ Generate images using Nano Banana
82
+
83
+ Args:
84
+ prompt: Text description of desired image
85
+ reference_images: Optional list of reference images for consistency (up to 14)
86
+ aspect_ratio: One of "1:1", "16:9", "9:16", "21:9"
87
+ num_images: Number of images to generate (1-4)
88
+ safety_filter: Safety filter level
89
+
90
+ Returns:
91
+ List of generated PIL Images
92
+ """
93
+ # Validate reference images count
94
+ if reference_images and len(reference_images) > Config.NANO_BANANA_MAX_REFERENCES:
95
+ print(f"Warning: Too many reference images ({len(reference_images)}). Using first {Config.NANO_BANANA_MAX_REFERENCES}.")
96
+ reference_images = reference_images[:Config.NANO_BANANA_MAX_REFERENCES]
97
+
98
+ # Build prompt parts
99
+ parts = []
100
+
101
+ # Add reference images if provided
102
+ if reference_images:
103
+ image_parts = self._prepare_image_parts(reference_images)
104
+ parts.extend(image_parts)
105
+
106
+ # Add reference instruction
107
+ parts.append(f"Using the reference image(s) above, generate: {prompt}")
108
+ else:
109
+ parts.append(prompt)
110
+
111
+ # Add aspect ratio instruction
112
+ parts.append(f"\nAspect ratio: {aspect_ratio}")
113
+
114
+ try:
115
+ # Generate image
116
+ response = self.model.generate_content(
117
+ parts,
118
+ generation_config={
119
+ "temperature": 0.8,
120
+ "candidate_count": num_images
121
+ }
122
+ )
123
+
124
+ # Store in conversation history
125
+ self.conversation_history.append({
126
+ "prompt": prompt,
127
+ "reference_count": len(reference_images) if reference_images else 0,
128
+ "response": response
129
+ })
130
+
131
+ # Extract images from response
132
+ generated_images = []
133
+
134
+ # Note: Actual image extraction depends on Gemini API response format
135
+ # This is a placeholder - adjust based on actual API response structure
136
+ if hasattr(response, 'images'):
137
+ for img_data in response.images:
138
+ if isinstance(img_data, bytes):
139
+ generated_images.append(Image.open(BytesIO(img_data)))
140
+ elif hasattr(img_data, 'data'):
141
+ generated_images.append(Image.open(BytesIO(img_data.data)))
142
+
143
+ # Fallback: Check response parts
144
+ if not generated_images and hasattr(response, 'parts'):
145
+ for part in response.parts:
146
+ if hasattr(part, 'inline_data'):
147
+ img_bytes = part.inline_data.data
148
+ generated_images.append(Image.open(BytesIO(img_bytes)))
149
+
150
+ if not generated_images:
151
+ raise ValueError("No images returned from Gemini API")
152
+
153
+ print(f"Generated {len(generated_images)} image(s)")
154
+ return generated_images
155
+
156
+ except Exception as e:
157
+ print(f"Error generating image with Nano Banana: {e}")
158
+ raise
159
+
160
+ def generate_with_character_consistency(
161
+ self,
162
+ prompt: str,
163
+ character_references: List[Union[str, Image.Image, bytes]],
164
+ character_description: str,
165
+ scene_number: int = 1
166
+ ) -> Image.Image:
167
+ """
168
+ Generate image maintaining character consistency
169
+
170
+ Args:
171
+ prompt: Scene description
172
+ character_references: Reference images of the character
173
+ character_description: Detailed text description of character
174
+ scene_number: Scene/panel number for context
175
+
176
+ Returns:
177
+ Generated PIL Image with consistent character
178
+ """
179
+ # Build consistency-focused prompt
180
+ consistency_prompt = f"""CHARACTER CONSISTENCY REQUIREMENTS:
181
+ {character_description}
182
+
183
+ IMPORTANT: Maintain the EXACT same character appearance from the reference images.
184
+ - Same facial features, hair, clothing, body type
185
+ - Same artistic style and color palette
186
+ - Consistent character identity across all details
187
+
188
+ SCENE {scene_number}:
189
+ {prompt}
190
+
191
+ Generate this scene while keeping the character IDENTICAL to the reference images."""
192
+
193
+ return self.generate_image(
194
+ prompt=consistency_prompt,
195
+ reference_images=character_references,
196
+ aspect_ratio="1:1",
197
+ num_images=1
198
+ )[0]
199
+
200
+ def generate_character_sheet(
201
+ self,
202
+ character_description: str,
203
+ style: str = "manga"
204
+ ) -> List[Image.Image]:
205
+ """
206
+ Generate character reference sheet with multiple views
207
+
208
+ Args:
209
+ character_description: Detailed character description
210
+ style: Art style (manga, anime, comic, etc.)
211
+
212
+ Returns:
213
+ List of character views (front, side, expressions, etc.)
214
+ """
215
+ views = [
216
+ "front view, standing pose, full body",
217
+ "side profile view, standing pose",
218
+ "facial close-up, neutral expression",
219
+ "facial close-up, happy expression"
220
+ ]
221
+
222
+ character_sheets = []
223
+
224
+ for i, view in enumerate(views):
225
+ prompt = f"{character_description}, {view}, {style} art style, character reference sheet, clean white background, professional character design"
226
+
227
+ # Use previous views as references for consistency
228
+ reference_images = character_sheets if i > 0 else None
229
+
230
+ try:
231
+ img = self.generate_image(
232
+ prompt=prompt,
233
+ reference_images=reference_images,
234
+ aspect_ratio="1:1",
235
+ num_images=1
236
+ )[0]
237
+
238
+ character_sheets.append(img)
239
+ print(f"Generated character sheet view {i+1}/{len(views)}")
240
+
241
+ except Exception as e:
242
+ print(f"Error generating character sheet view {i+1}: {e}")
243
+ continue
244
+
245
+ return character_sheets
246
+
247
+ def reset_conversation(self):
248
+ """Reset conversation history for new session"""
249
+ self.conversation_history = []
250
+ print("Conversation history reset")
251
+
252
+ def get_conversation_context(self) -> str:
253
+ """Get summary of conversation history"""
254
+ if not self.conversation_history:
255
+ return "No conversation history"
256
+
257
+ summary = f"Conversation with {len(self.conversation_history)} interactions:\n"
258
+ for i, interaction in enumerate(self.conversation_history, 1):
259
+ summary += f"{i}. Prompt: {interaction['prompt'][:50]}... (Refs: {interaction['reference_count']})\n"
260
+
261
+ return summary
262
+
263
+
264
+ # Example usage
265
+ if __name__ == "__main__":
266
+ # Test Nano Banana client
267
+ client = NanoBananaClient(model="gemini-2.5-flash-image")
268
+
269
+ # Test 1: Simple generation
270
+ print("\n=== Test 1: Simple Image Generation ===")
271
+ try:
272
+ images = client.generate_image(
273
+ prompt="An elderly yogi with long white beard, wearing saffron robes, meditating in a vibrant jungle",
274
+ aspect_ratio="1:1",
275
+ num_images=1
276
+ )
277
+ print(f"Success! Generated {len(images)} image(s)")
278
+ except Exception as e:
279
+ print(f"Test 1 failed: {e}")
280
+
281
+ # Test 2: Character sheet generation
282
+ print("\n=== Test 2: Character Sheet Generation ===")
283
+ try:
284
+ character_desc = "Elderly Indian yogi, long flowing white beard, wrinkled wise face, orange saffron robes, meditation beads, peaceful expression, lean muscular build"
285
+ sheets = client.generate_character_sheet(
286
+ character_description=character_desc,
287
+ style="manga"
288
+ )
289
+ print(f"Success! Generated {len(sheets)} character views")
290
+ except Exception as e:
291
+ print(f"Test 2 failed: {e}")
292
+
293
+ print("\n=== Tests Complete ===")
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=6.0.0
2
+ openai>=1.54.0
3
+ python-dotenv>=1.0.0
4
+ google-adk>=0.1.0
5
+ numpy>=1.24.0,<2.0
6
+ transformers>=4.46.0
7
+ diffusers>=0.31.0
8
+ torch>=2.0.0
9
+ accelerate>=0.20.0
10
+ pillow==10.4.0
11
+ elevenlabs>=1.0.0
12
+ llama-index>=0.11.0
13
+ llama-index-core>=0.11.0
14
+ llama-index-embeddings-openai>=0.2.0
15
+ google-generativeai>=0.8.0
16
+ reportlab>=4.0.0
17
+ markdown>=3.5.0
18
+ requests>=2.31.0
19
+ modal>=0.63.0
20
+
21
+ # Nano Banana + LlamaIndex Multimodal Character Consistency
22
+ llama-index-multi-modal-llms-gemini>=0.2.0
23
+ llama-index-vector-stores-chroma>=0.2.0
24
+ chromadb>=0.4.0
25
+ pillow>=10.0.0
26
+
27
+ mcp>=1.22.0
28
+ fastmcp>=2.13.1
sambanova_generator.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SambaNova Cloud Integration for MythForge Comic Generator
2
+
3
+ Uses Llama 3.3 70B for ultra-fast comic story and dialogue generation.
4
+ 461 tokens/sec - 50x cheaper than OpenAI GPT-4o-mini.
5
+ 128k context window for complex comic narratives.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import requests
11
+ from typing import List, Dict, Optional
12
+ from config import Config
13
+
14
+
15
+ class SambanovaGenerator:
16
+ """SambaNova Cloud text generation using Llama 3.3 70B"""
17
+
18
+ def __init__(self):
19
+ """Initialize SambaNova client"""
20
+ self.api_key = Config.SAMBANOVA_API_KEY
21
+ self.base_url = os.getenv("SAMBANOVA_BASE_URL", "https://api.sambanova.ai/v1")
22
+ # Updated to use currently available production model (Llama 3.3 70B - 128k context)
23
+ self.model = os.getenv("SAMBANOVA_MODEL", "Meta-Llama-3.3-70B-Instruct")
24
+ self.enabled = Config.ENABLE_SAMBANOVA and self.api_key is not None
25
+
26
+ # Cost tracking
27
+ self.total_cost = 0.0
28
+ self.input_cost_per_million = 0.60 # $0.60 per 1M input tokens
29
+ self.output_cost_per_million = 1.20 # $1.20 per 1M output tokens
30
+
31
+ if self.enabled:
32
+ print(f"✅ SambaNova using {self.model}")
33
+
34
+ def generate_comic_story(
35
+ self,
36
+ prompt: str,
37
+ panel_count: int = 6,
38
+ style: str = "manga",
39
+ temperature: float = 0.7
40
+ ) -> Dict:
41
+ """
42
+ Generate comic story with scene descriptions for each panel
43
+
44
+ Args:
45
+ prompt: User's story prompt
46
+ panel_count: Number of panels (default 6 for manga)
47
+ style: Visual style (manga, cartoon, etc.)
48
+ temperature: Creativity level (0.0-1.0)
49
+
50
+ Returns:
51
+ Dict with story text, panel scenes, and dialogue
52
+ """
53
+ if not self.enabled:
54
+ raise ValueError("SambaNova is not enabled or API key not set")
55
+
56
+ # Create system prompt for comic generation
57
+ system_prompt = f"""You are a creative comic story writer specializing in {style} style.
58
+ Generate a complete {panel_count}-panel comic story with:
59
+ 1. Engaging narrative suitable for visual storytelling
60
+ 2. Clear scene descriptions for each panel
61
+ 3. Character dialogue in speech bubble format (MINIMUM 2 sentences per panel for story progression)
62
+ 4. Action and emotion cues for illustrations
63
+
64
+ Format your response as JSON with this structure:
65
+ {{
66
+ "title": "Story Title",
67
+ "panels": [
68
+ {{
69
+ "panel_number": 1,
70
+ "scene_description": "Detailed visual description",
71
+ "dialogue": "Character: First sentence. Second sentence. Optional third sentence.",
72
+ "action": "What's happening"
73
+ }},
74
+ ...
75
+ ]
76
+ }}"""
77
+
78
+ # Create user prompt
79
+ user_prompt = f"""Create a {panel_count}-panel {style} comic story based on this prompt:
80
+
81
+ "{prompt}"
82
+
83
+ Make it visually dynamic with clear scenes for each panel. Include rich character dialogue with AT LEAST 2 sentences per panel to deliver the storyline effectively."""
84
+
85
+ # Make API request
86
+ try:
87
+ response = self._call_api(
88
+ system_prompt=system_prompt,
89
+ user_prompt=user_prompt,
90
+ temperature=temperature,
91
+ max_tokens=2000
92
+ )
93
+
94
+ # Parse response
95
+ story_data = self._parse_response(response, panel_count)
96
+
97
+ # Track cost
98
+ if "usage" in response:
99
+ self._update_cost(response["usage"])
100
+
101
+ return story_data
102
+
103
+ except Exception as e:
104
+ raise RuntimeError(f"SambaNova generation failed: {e}")
105
+
106
+ def generate_panel_dialogue(
107
+ self,
108
+ scene_description: str,
109
+ characters: List[str],
110
+ panel_number: int,
111
+ style: str = "manga"
112
+ ) -> Dict:
113
+ """
114
+ Generate dialogue for a specific comic panel
115
+
116
+ Args:
117
+ scene_description: What's happening in the panel
118
+ characters: List of character names in this panel
119
+ panel_number: Panel number (1-6)
120
+ style: Visual style
121
+
122
+ Returns:
123
+ Dict with dialogue and character assignments
124
+ """
125
+ if not self.enabled:
126
+ raise ValueError("SambaNova is not enabled")
127
+
128
+ system_prompt = f"""You are a dialogue writer for {style} comics.
129
+ Write natural, engaging dialogue for comic speech bubbles."""
130
+
131
+ user_prompt = f"""Panel {panel_number} scene: {scene_description}
132
+
133
+ Characters present: {', '.join(characters)}
134
+
135
+ Write dialogue for this panel. Format as:
136
+ Character Name: "Dialogue text"
137
+
138
+ Keep it concise for speech bubbles (max 20 words per bubble)."""
139
+
140
+ try:
141
+ response = self._call_api(
142
+ system_prompt=system_prompt,
143
+ user_prompt=user_prompt,
144
+ temperature=0.8,
145
+ max_tokens=200
146
+ )
147
+
148
+ dialogue_text = response["choices"][0]["message"]["content"]
149
+
150
+ # Parse dialogue into character: text pairs
151
+ dialogue_data = self._parse_dialogue(dialogue_text, characters)
152
+
153
+ # Track cost
154
+ if "usage" in response:
155
+ self._update_cost(response["usage"])
156
+
157
+ return dialogue_data
158
+
159
+ except Exception as e:
160
+ raise RuntimeError(f"Dialogue generation failed: {e}")
161
+
162
+ def _call_api(
163
+ self,
164
+ system_prompt: str,
165
+ user_prompt: str,
166
+ temperature: float = 0.7,
167
+ max_tokens: int = 2000
168
+ ) -> Dict:
169
+ """
170
+ Make API call to SambaNova Cloud
171
+
172
+ Args:
173
+ system_prompt: System instructions
174
+ user_prompt: User's request
175
+ temperature: Creativity level
176
+ max_tokens: Max response length
177
+
178
+ Returns:
179
+ API response dict
180
+ """
181
+ headers = {
182
+ "Authorization": f"Bearer {self.api_key}",
183
+ "Content-Type": "application/json"
184
+ }
185
+
186
+ payload = {
187
+ "model": self.model,
188
+ "messages": [
189
+ {"role": "system", "content": system_prompt},
190
+ {"role": "user", "content": user_prompt}
191
+ ],
192
+ "temperature": temperature,
193
+ "max_tokens": max_tokens,
194
+ "stream": False
195
+ }
196
+
197
+ response = requests.post(
198
+ f"{self.base_url}/chat/completions",
199
+ headers=headers,
200
+ json=payload,
201
+ timeout=60
202
+ )
203
+
204
+ if response.status_code != 200:
205
+ raise RuntimeError(
206
+ f"SambaNova API error {response.status_code}: {response.text}"
207
+ )
208
+
209
+ return response.json()
210
+
211
+ def _parse_response(self, response: Dict, panel_count: int) -> Dict:
212
+ """Parse SambaNova response into structured comic data"""
213
+ content = response["choices"][0]["message"]["content"]
214
+
215
+ # Strip markdown code blocks if present (```json ... ``` or ``` ... ```)
216
+ content = content.strip()
217
+ if content.startswith("```"):
218
+ # Remove first ``` line
219
+ lines = content.split("\n")
220
+ if lines[0].startswith("```"):
221
+ lines = lines[1:]
222
+ # Remove last ``` line
223
+ if lines and lines[-1].strip() == "```":
224
+ lines = lines[:-1]
225
+ content = "\n".join(lines)
226
+
227
+ try:
228
+ # Try to parse as JSON
229
+ story_data = json.loads(content)
230
+
231
+ # Extract character names from dialogue if not provided
232
+ if "panels" in story_data:
233
+ for panel in story_data["panels"]:
234
+ if panel.get("dialogue") and not panel.get("character_name"):
235
+ # Try to extract character name from "Character: dialogue" format
236
+ dialogue = panel["dialogue"]
237
+ if ":" in dialogue:
238
+ parts = dialogue.split(":", 1)
239
+ character_name = parts[0].strip()
240
+ # Check if it looks like a character name (not a description)
241
+ if not character_name.startswith("(") and len(character_name.split()) <= 3:
242
+ panel["character_name"] = character_name
243
+ # Remove character name from dialogue to avoid duplication
244
+ panel["dialogue"] = parts[1].strip()
245
+
246
+ # Ensure we have the right number of panels
247
+ if "panels" in story_data and len(story_data["panels"]) != panel_count:
248
+ # Adjust panels if needed
249
+ panels = story_data["panels"][:panel_count]
250
+ if len(panels) < panel_count:
251
+ # Duplicate last panel to fill
252
+ while len(panels) < panel_count:
253
+ panels.append(panels[-1].copy())
254
+ panels[-1]["panel_number"] = len(panels)
255
+ story_data["panels"] = panels
256
+
257
+ return story_data
258
+
259
+ except json.JSONDecodeError:
260
+ # Fallback: Parse as plain text
261
+ return self._fallback_parse(content, panel_count)
262
+
263
+ def _fallback_parse(self, content: str, panel_count: int) -> Dict:
264
+ """Fallback parser if JSON fails"""
265
+ # Split content into panels (simple heuristic)
266
+ lines = content.strip().split("\n")
267
+ panels = []
268
+
269
+ for i in range(panel_count):
270
+ panels.append({
271
+ "panel_number": i + 1,
272
+ "scene_description": f"Panel {i+1} from story",
273
+ "dialogue": "",
274
+ "action": lines[i] if i < len(lines) else "Continuation"
275
+ })
276
+
277
+ return {
278
+ "title": "Comic Story",
279
+ "panels": panels
280
+ }
281
+
282
+ def _parse_dialogue(self, dialogue_text: str, characters: List[str]) -> Dict:
283
+ """Parse dialogue text into structured format"""
284
+ dialogues = []
285
+ lines = dialogue_text.strip().split("\n")
286
+
287
+ for line in lines:
288
+ if ":" in line:
289
+ parts = line.split(":", 1)
290
+ character = parts[0].strip()
291
+ text = parts[1].strip().strip('"').strip("'")
292
+
293
+ # Only include if character is in the scene
294
+ if any(char.lower() in character.lower() for char in characters):
295
+ dialogues.append({
296
+ "character": character,
297
+ "text": text
298
+ })
299
+
300
+ return {"dialogues": dialogues}
301
+
302
+ def _update_cost(self, usage: Dict):
303
+ """Update cost tracking based on token usage"""
304
+ input_tokens = usage.get("prompt_tokens", 0)
305
+ output_tokens = usage.get("completion_tokens", 0)
306
+
307
+ input_cost = (input_tokens / 1_000_000) * self.input_cost_per_million
308
+ output_cost = (output_tokens / 1_000_000) * self.output_cost_per_million
309
+
310
+ cost = input_cost + output_cost
311
+ self.total_cost += cost
312
+
313
+ return cost
314
+
315
+ def get_cost(self) -> float:
316
+ """Get total cost of all SambaNova calls"""
317
+ return self.total_cost
318
+
319
+ def estimate_cost(self, prompt: str, panel_count: int = 6) -> float:
320
+ """
321
+ Estimate cost for generating a comic
322
+
323
+ Args:
324
+ prompt: User's story prompt
325
+ panel_count: Number of panels
326
+
327
+ Returns:
328
+ Estimated cost in USD
329
+ """
330
+ # Rough estimation based on typical usage
331
+ avg_input_tokens = len(prompt.split()) * 1.3 # Words to tokens ratio
332
+ avg_output_tokens = panel_count * 150 # ~150 tokens per panel
333
+
334
+ input_cost = (avg_input_tokens / 1_000_000) * self.input_cost_per_million
335
+ output_cost = (avg_output_tokens / 1_000_000) * self.output_cost_per_million
336
+
337
+ return input_cost + output_cost
338
+
339
+ @classmethod
340
+ def is_available(cls) -> bool:
341
+ """Check if SambaNova is configured and available"""
342
+ return Config.ENABLE_SAMBANOVA and Config.SAMBANOVA_API_KEY is not None
343
+
344
+
345
+ # Test function
346
+ if __name__ == "__main__":
347
+ print("Testing SambaNova Generator...")
348
+ print("=" * 60)
349
+
350
+ generator = SambanovaGenerator()
351
+
352
+ if not generator.enabled:
353
+ print("❌ SambaNova is not enabled")
354
+ print(f" ENABLE_SAMBANOVA = {Config.ENABLE_SAMBANOVA}")
355
+ print(f" API Key set = {Config.SAMBANOVA_API_KEY is not None}")
356
+ print()
357
+ print("To enable:")
358
+ print("1. Set SAMBANOVA_API_KEY in .env file")
359
+ print("2. Set ENABLE_SAMBANOVA=true in .env file")
360
+ else:
361
+ print("✅ SambaNova enabled")
362
+ print(f" Model: {generator.model}")
363
+ print(f" Base URL: {generator.base_url}")
364
+ print()
365
+
366
+ # Estimate cost
367
+ test_prompt = "A wizard discovers a magical library"
368
+ estimated_cost = generator.estimate_cost(test_prompt, panel_count=6)
369
+ print(f"Estimated cost for 6-panel comic: ${estimated_cost:.6f}")
370
+ print()
371
+
372
+ print("Ready for comic generation!")
speech_bubble_overlay.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speech Bubble Overlay System for MythForge Comic Generator
2
+
3
+ Adds comic-style speech bubbles with dialogue to manga panels.
4
+ """
5
+
6
+ from typing import List, Dict, Tuple, Optional
7
+ from PIL import Image, ImageDraw, ImageFont
8
+ import textwrap
9
+
10
+
11
+ class SpeechBubbleOverlay:
12
+ """Add speech bubbles with dialogue to comic panels"""
13
+
14
+ def __init__(self):
15
+ """Initialize speech bubble system"""
16
+ # Bubble styling
17
+ self.bubble_color = (255, 255, 255) # White
18
+ self.bubble_border = (0, 0, 0) # Black
19
+ self.border_width = 3
20
+ self.text_color = (0, 0, 0) # Black text
21
+
22
+ # Padding and sizing
23
+ self.padding = 15 # Inside bubble
24
+ self.margin = 30 # From panel edges
25
+ self.max_width_ratio = 0.7 # Max 70% of panel width
26
+
27
+ # Font settings (using default PIL font for now)
28
+ self.font_size = 24
29
+ self.line_spacing = 8
30
+
31
+ # Try to load a better font if available
32
+ try:
33
+ self.font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", self.font_size)
34
+ self.name_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", self.font_size - 4)
35
+ except:
36
+ # Fallback to default
37
+ self.font = ImageFont.load_default()
38
+ self.name_font = ImageFont.load_default()
39
+
40
+ def add_dialogue_to_panel(
41
+ self,
42
+ panel: Image.Image,
43
+ dialogue: str,
44
+ character_name: Optional[str] = None,
45
+ position: str = "top"
46
+ ) -> Image.Image:
47
+ """
48
+ Add speech bubble with dialogue to a panel
49
+
50
+ Args:
51
+ panel: PIL Image of the comic panel
52
+ dialogue: Text to display in bubble
53
+ character_name: Optional character name to show
54
+ position: "top", "middle", or "bottom"
55
+
56
+ Returns:
57
+ Panel with speech bubble overlay
58
+ """
59
+ # Create a copy to avoid modifying original
60
+ panel_copy = panel.copy()
61
+ draw = ImageDraw.Draw(panel_copy)
62
+
63
+ # Wrap text to fit in bubble
64
+ max_width = int(panel.width * self.max_width_ratio)
65
+ wrapped_text = self._wrap_text(dialogue, max_width)
66
+
67
+ # Calculate bubble dimensions
68
+ bubble_width, bubble_height = self._calculate_bubble_size(
69
+ wrapped_text,
70
+ character_name
71
+ )
72
+
73
+ # Calculate bubble position
74
+ x, y = self._calculate_position(
75
+ panel.width,
76
+ panel.height,
77
+ bubble_width,
78
+ bubble_height,
79
+ position
80
+ )
81
+
82
+ # Draw bubble
83
+ self._draw_bubble(draw, x, y, bubble_width, bubble_height)
84
+
85
+ # Draw character name if provided
86
+ text_y = y + self.padding
87
+ if character_name:
88
+ text_y = self._draw_character_name(
89
+ draw, x, text_y, bubble_width, character_name
90
+ )
91
+ text_y += self.line_spacing
92
+
93
+ # Draw dialogue text
94
+ self._draw_text(draw, x, text_y, bubble_width, wrapped_text)
95
+
96
+ return panel_copy
97
+
98
+ def add_multiple_bubbles(
99
+ self,
100
+ panel: Image.Image,
101
+ dialogues: List[Dict]
102
+ ) -> Image.Image:
103
+ """
104
+ Add multiple speech bubbles to a panel
105
+
106
+ Args:
107
+ panel: PIL Image of the comic panel
108
+ dialogues: List of dialogue dicts with keys:
109
+ - text: Dialogue text
110
+ - character: Character name (optional)
111
+ - position: "top", "middle", "bottom" (optional)
112
+
113
+ Returns:
114
+ Panel with multiple speech bubbles
115
+ """
116
+ panel_copy = panel.copy()
117
+
118
+ for dialogue_data in dialogues:
119
+ text = dialogue_data.get("text", "")
120
+ character = dialogue_data.get("character")
121
+ position = dialogue_data.get("position", "top")
122
+
123
+ if text:
124
+ panel_copy = self.add_dialogue_to_panel(
125
+ panel_copy,
126
+ text,
127
+ character,
128
+ position
129
+ )
130
+
131
+ return panel_copy
132
+
133
+ def _wrap_text(self, text: str, max_width: int) -> List[str]:
134
+ """Wrap text to fit within max width"""
135
+ # Estimate characters per line (rough approximation)
136
+ avg_char_width = self.font_size * 0.6
137
+ chars_per_line = int(max_width / avg_char_width)
138
+
139
+ # Wrap text
140
+ wrapper = textwrap.TextWrapper(
141
+ width=chars_per_line,
142
+ break_long_words=False,
143
+ break_on_hyphens=False
144
+ )
145
+
146
+ return wrapper.wrap(text)
147
+
148
+ def _calculate_bubble_size(
149
+ self,
150
+ wrapped_lines: List[str],
151
+ character_name: Optional[str]
152
+ ) -> Tuple[int, int]:
153
+ """Calculate required bubble dimensions"""
154
+ # Calculate text dimensions
155
+ max_line_width = 0
156
+ for line in wrapped_lines:
157
+ bbox = self.font.getbbox(line)
158
+ line_width = bbox[2] - bbox[0]
159
+ max_line_width = max(max_line_width, line_width)
160
+
161
+ # Check character name width
162
+ if character_name:
163
+ name_bbox = self.name_font.getbbox(character_name.upper())
164
+ name_width = name_bbox[2] - name_bbox[0]
165
+ max_line_width = max(max_line_width, name_width)
166
+
167
+ # Calculate bubble size with padding
168
+ bubble_width = max_line_width + (2 * self.padding)
169
+
170
+ # Calculate height
171
+ line_height = self.font_size + self.line_spacing
172
+ text_height = len(wrapped_lines) * line_height
173
+
174
+ if character_name:
175
+ text_height += self.font_size + self.line_spacing # Name height
176
+
177
+ bubble_height = text_height + (2 * self.padding)
178
+
179
+ return (int(bubble_width), int(bubble_height))
180
+
181
+ def _calculate_position(
182
+ self,
183
+ panel_width: int,
184
+ panel_height: int,
185
+ bubble_width: int,
186
+ bubble_height: int,
187
+ position: str
188
+ ) -> Tuple[int, int]:
189
+ """Calculate bubble position on panel"""
190
+ # Center horizontally
191
+ x = (panel_width - bubble_width) // 2
192
+
193
+ # Position vertically
194
+ if position == "top":
195
+ y = self.margin
196
+ elif position == "bottom":
197
+ y = panel_height - bubble_height - self.margin
198
+ else: # middle
199
+ y = (panel_height - bubble_height) // 2
200
+
201
+ return (x, y)
202
+
203
+ def _draw_bubble(
204
+ self,
205
+ draw: ImageDraw.Draw,
206
+ x: int,
207
+ y: int,
208
+ width: int,
209
+ height: int
210
+ ):
211
+ """Draw speech bubble shape"""
212
+ # Draw white rectangle with black border
213
+ draw.rectangle(
214
+ [x, y, x + width, y + height],
215
+ fill=self.bubble_color,
216
+ outline=self.bubble_border,
217
+ width=self.border_width
218
+ )
219
+
220
+ # Optional: Add rounded corners in future
221
+ # For now, simple rectangle
222
+
223
+ def _draw_character_name(
224
+ self,
225
+ draw: ImageDraw.Draw,
226
+ x: int,
227
+ y: int,
228
+ bubble_width: int,
229
+ character_name: str
230
+ ) -> int:
231
+ """Draw character name at top of bubble"""
232
+ name_text = character_name.upper()
233
+
234
+ # Get text dimensions
235
+ bbox = self.name_font.getbbox(name_text)
236
+ text_width = bbox[2] - bbox[0]
237
+ text_height = bbox[3] - bbox[1]
238
+
239
+ # Center name in bubble
240
+ text_x = x + (bubble_width - text_width) // 2
241
+
242
+ # Draw name in bold (simulate by drawing multiple times)
243
+ for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]:
244
+ draw.text(
245
+ (text_x + offset[0], y + offset[1]),
246
+ name_text,
247
+ fill=self.text_color,
248
+ font=self.name_font
249
+ )
250
+
251
+ return y + text_height
252
+
253
+ def _draw_text(
254
+ self,
255
+ draw: ImageDraw.Draw,
256
+ x: int,
257
+ y: int,
258
+ bubble_width: int,
259
+ wrapped_lines: List[str]
260
+ ):
261
+ """Draw wrapped dialogue text"""
262
+ line_height = self.font_size + self.line_spacing
263
+
264
+ for i, line in enumerate(wrapped_lines):
265
+ # Get line dimensions for centering
266
+ bbox = self.font.getbbox(line)
267
+ text_width = bbox[2] - bbox[0]
268
+
269
+ # Center line in bubble
270
+ text_x = x + (bubble_width - text_width) // 2
271
+ text_y = y + (i * line_height)
272
+
273
+ # Draw text
274
+ draw.text(
275
+ (text_x, text_y),
276
+ line,
277
+ fill=self.text_color,
278
+ font=self.font
279
+ )
280
+
281
+
282
+ # Test/demo function
283
+ if __name__ == "__main__":
284
+ print("=" * 60)
285
+ print("SPEECH BUBBLE OVERLAY TEST")
286
+ print("=" * 60)
287
+ print()
288
+
289
+ # Create test panel
290
+ panel = Image.new('RGB', (1024, 1024), color=(200, 220, 255))
291
+
292
+ # Add title to panel
293
+ draw = ImageDraw.Draw(panel)
294
+ draw.text((512, 50), "Test Comic Panel", fill=(0, 0, 0), anchor='mt')
295
+
296
+ # Initialize bubble system
297
+ bubble_overlay = SpeechBubbleOverlay()
298
+
299
+ # Test 1: Single bubble with character name
300
+ print("Test 1: Single bubble with character name")
301
+ panel_with_bubble = bubble_overlay.add_dialogue_to_panel(
302
+ panel,
303
+ dialogue="Welcome to MythForge! This is a test of the speech bubble system.",
304
+ character_name="Narrator",
305
+ position="top"
306
+ )
307
+
308
+ panel_with_bubble.save("test_bubble_single.png")
309
+ print("✅ Saved: test_bubble_single.png")
310
+ print()
311
+
312
+ # Test 2: Multiple bubbles
313
+ print("Test 2: Multiple bubbles")
314
+ dialogues = [
315
+ {
316
+ "text": "Hello! I'm testing the speech bubble system!",
317
+ "character": "Character 1",
318
+ "position": "top"
319
+ },
320
+ {
321
+ "text": "This looks amazing! The bubbles work great!",
322
+ "character": "Character 2",
323
+ "position": "bottom"
324
+ }
325
+ ]
326
+
327
+ panel_multi = bubble_overlay.add_multiple_bubbles(panel.copy(), dialogues)
328
+ panel_multi.save("test_bubble_multiple.png")
329
+ print("✅ Saved: test_bubble_multiple.png")
330
+ print()
331
+
332
+ # Test 3: Long text wrapping
333
+ print("Test 3: Long text wrapping")
334
+ long_text = (
335
+ "This is a much longer piece of dialogue that will need to wrap "
336
+ "across multiple lines to fit properly in the speech bubble without "
337
+ "going outside the panel boundaries."
338
+ )
339
+
340
+ panel_long = bubble_overlay.add_dialogue_to_panel(
341
+ panel.copy(),
342
+ dialogue=long_text,
343
+ character_name="Wizard",
344
+ position="middle"
345
+ )
346
+
347
+ panel_long.save("test_bubble_long.png")
348
+ print("✅ Saved: test_bubble_long.png")
349
+ print()
350
+
351
+ print("=" * 60)
352
+ print("Test complete! Check output images.")
353
+ print("=" * 60)
story_memory.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Story Memory Service using LlamaIndex with Multimodal Support"""
2
+ import json
3
+ import os
4
+ import re
5
+ from typing import Dict, List, Optional
6
+ from llama_index.core import Document, VectorStoreIndex, StorageContext, load_index_from_storage
7
+ from llama_index.core.node_parser import SimpleNodeParser
8
+ from llama_index.embeddings.openai import OpenAIEmbedding
9
+ from config import Config
10
+
11
+ # Multimodal support (optional, requires additional packages)
12
+ try:
13
+ from llama_index.multi_modal_llms.gemini import GeminiMultiModal
14
+ from llama_index.core.indices.multi_modal import MultiModalVectorStoreIndex
15
+ MULTIMODAL_AVAILABLE = True
16
+ except ImportError:
17
+ MULTIMODAL_AVAILABLE = False
18
+ print("Note: Multimodal support not available. Install llama-index-multi-modal-llms-gemini for image support.")
19
+
20
+ class StoryMemory:
21
+ """Maintain character consistency and story memory using LlamaIndex"""
22
+
23
+ def __init__(self, persist_dir: str = "./story_memory", enable_multimodal: bool = False):
24
+ """Initialize Story Memory with LlamaIndex
25
+
26
+ Args:
27
+ persist_dir: Directory to persist vector index and character database
28
+ enable_multimodal: Enable multimodal (text + image) indexing
29
+ """
30
+ self.persist_dir = persist_dir
31
+ self.character_db_path = os.path.join(persist_dir, "characters.json")
32
+ self.character_db: Dict[str, Dict] = {}
33
+ self.index: Optional[VectorStoreIndex] = None
34
+ self.multimodal_index: Optional[MultiModalVectorStoreIndex] = None
35
+ self.enable_multimodal = enable_multimodal and MULTIMODAL_AVAILABLE and Config.ENABLE_CHARACTER_LIBRARY
36
+
37
+ # Create persist directory if it doesn't exist
38
+ os.makedirs(persist_dir, exist_ok=True)
39
+ os.makedirs(os.path.join(persist_dir, "multimodal"), exist_ok=True)
40
+
41
+ # Load existing character database
42
+ self._load_character_db()
43
+
44
+ # Load or create index
45
+ self._load_or_create_index()
46
+
47
+ # Initialize multimodal index if enabled
48
+ if self.enable_multimodal:
49
+ self._load_or_create_multimodal_index()
50
+
51
+ def _load_character_db(self):
52
+ """Load character database from disk"""
53
+ if os.path.exists(self.character_db_path):
54
+ try:
55
+ with open(self.character_db_path, 'r') as f:
56
+ self.character_db = json.load(f)
57
+ print(f"Loaded {len(self.character_db)} characters from memory")
58
+ except Exception as e:
59
+ print(f"Error loading character database: {e}")
60
+ self.character_db = {}
61
+
62
+ def _save_character_db(self):
63
+ """Save character database to disk"""
64
+ try:
65
+ with open(self.character_db_path, 'w') as f:
66
+ json.dump(self.character_db, f, indent=2)
67
+ except Exception as e:
68
+ print(f"Error saving character database: {e}")
69
+
70
+ def _load_or_create_index(self):
71
+ """Load existing index or create new one"""
72
+ try:
73
+ if os.path.exists(os.path.join(self.persist_dir, "docstore.json")):
74
+ # Load existing index
75
+ storage_context = StorageContext.from_defaults(persist_dir=self.persist_dir)
76
+ self.index = load_index_from_storage(storage_context)
77
+ print("Loaded existing story index")
78
+ else:
79
+ # Create new index
80
+ self.index = VectorStoreIndex([])
81
+ self.index.storage_context.persist(persist_dir=self.persist_dir)
82
+ print("Created new story index")
83
+ except Exception as e:
84
+ print(f"Error with index: {e}")
85
+ # Create fresh index on error
86
+ self.index = VectorStoreIndex([])
87
+
88
+ def _load_or_create_multimodal_index(self):
89
+ """Load or create multimodal index for images + text"""
90
+ if not MULTIMODAL_AVAILABLE:
91
+ print("Multimodal support not available")
92
+ return
93
+
94
+ try:
95
+ multimodal_dir = os.path.join(self.persist_dir, "multimodal")
96
+ if os.path.exists(os.path.join(multimodal_dir, "docstore.json")):
97
+ # Load existing multimodal index
98
+ storage_context = StorageContext.from_defaults(persist_dir=multimodal_dir)
99
+ self.multimodal_index = load_index_from_storage(storage_context)
100
+ print("Loaded existing multimodal index")
101
+ else:
102
+ # Create new multimodal index
103
+ self.multimodal_index = MultiModalVectorStoreIndex([])
104
+ self.multimodal_index.storage_context.persist(persist_dir=multimodal_dir)
105
+ print("Created new multimodal index")
106
+ except Exception as e:
107
+ print(f"Error with multimodal index: {e}")
108
+ self.multimodal_index = None
109
+
110
+ def _extract_characters(self, story_text: str) -> List[Dict]:
111
+ """
112
+ Extract character names and descriptions from story text
113
+ Simple extraction based on capitalized names and context
114
+ """
115
+ characters = []
116
+
117
+ # Look for patterns like "Name was/is/had..."
118
+ pattern = r'([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(was|is|had|wore|carried|felt|thought|said)'
119
+ matches = re.findall(pattern, story_text)
120
+
121
+ # Get unique character names
122
+ char_names = list(set([match[0] for match in matches]))
123
+
124
+ for name in char_names:
125
+ # Extract sentences mentioning this character
126
+ sentences = [s.strip() for s in story_text.split('.') if name in s]
127
+ description = '. '.join(sentences[:2]) # First 2 mentions
128
+
129
+ characters.append({
130
+ "name": name,
131
+ "description": description,
132
+ "mentions": len(sentences)
133
+ })
134
+
135
+ return characters
136
+
137
+ def store_story(self, story_text: str, metadata: Dict):
138
+ """
139
+ Store story in knowledge graph
140
+
141
+ Args:
142
+ story_text: The story text
143
+ metadata: Story metadata (style, tone, etc.)
144
+ """
145
+ try:
146
+ # Extract and store characters
147
+ characters = self._extract_characters(story_text)
148
+
149
+ for char in characters:
150
+ char_name = char["name"]
151
+ if char_name not in self.character_db:
152
+ self.character_db[char_name] = {
153
+ "name": char_name,
154
+ "descriptions": [],
155
+ "appearances": 0,
156
+ "visual_attributes": {}
157
+ }
158
+
159
+ # Update character info
160
+ self.character_db[char_name]["descriptions"].append(char["description"])
161
+ self.character_db[char_name]["appearances"] += 1
162
+
163
+ # Extract visual attributes from metadata
164
+ if "style" in metadata:
165
+ self.character_db[char_name]["visual_attributes"]["style"] = metadata["style"]
166
+
167
+ # Save character database
168
+ self._save_character_db()
169
+
170
+ # Index the story
171
+ doc = Document(
172
+ text=story_text,
173
+ metadata={
174
+ **metadata,
175
+ "characters": [c["name"] for c in characters]
176
+ }
177
+ )
178
+
179
+ self.index.insert(doc)
180
+ self.index.storage_context.persist(persist_dir=self.persist_dir)
181
+
182
+ print(f"Stored story with {len(characters)} characters")
183
+
184
+ except Exception as e:
185
+ print(f"Error storing story: {e}")
186
+
187
+ def get_character_info(self, character_name: str) -> Optional[Dict]:
188
+ """
189
+ Get stored information about a character
190
+
191
+ Args:
192
+ character_name: Name of the character
193
+
194
+ Returns:
195
+ Character information dictionary or None
196
+ """
197
+ return self.character_db.get(character_name)
198
+
199
+ def get_all_characters(self) -> Dict[str, Dict]:
200
+ """Get all stored characters"""
201
+ return self.character_db
202
+
203
+ def retrieve_context(self, query: str, top_k: int = 3) -> str:
204
+ """
205
+ Retrieve relevant story context based on query
206
+
207
+ Args:
208
+ query: Search query
209
+ top_k: Number of results to return
210
+
211
+ Returns:
212
+ Retrieved context as string
213
+ """
214
+ try:
215
+ if self.index is None:
216
+ return "No stories in memory yet."
217
+
218
+ query_engine = self.index.as_query_engine(similarity_top_k=top_k)
219
+ response = query_engine.query(query)
220
+
221
+ return str(response)
222
+
223
+ except Exception as e:
224
+ print(f"Error retrieving context: {e}")
225
+ return "Error retrieving context."
226
+
227
+ def get_character_consistency_prompt(self, character_name: str) -> str:
228
+ """
229
+ Generate a prompt to maintain character consistency
230
+
231
+ Args:
232
+ character_name: Name of the character
233
+
234
+ Returns:
235
+ Consistency prompt for story generation
236
+ """
237
+ char_info = self.get_character_info(character_name)
238
+
239
+ if not char_info:
240
+ return ""
241
+
242
+ prompt = f"\nCharacter Consistency Note:\n"
243
+ prompt += f"- {character_name} has appeared in {char_info['appearances']} previous stories\n"
244
+
245
+ if char_info['descriptions']:
246
+ prompt += f"- Previous description: {char_info['descriptions'][0]}\n"
247
+
248
+ if char_info['visual_attributes']:
249
+ prompt += f"- Visual style: {char_info['visual_attributes'].get('style', 'N/A')}\n"
250
+
251
+ return prompt
252
+
253
+ def get_memory_stats(self) -> Dict:
254
+ """Get statistics about stored memories"""
255
+ stats = {
256
+ "total_characters": len(self.character_db),
257
+ "characters": list(self.character_db.keys()),
258
+ "total_stories": len(self.index.docstore.docs) if self.index else 0,
259
+ "multimodal_enabled": self.enable_multimodal
260
+ }
261
+
262
+ if self.multimodal_index:
263
+ stats["multimodal_docs"] = len(self.multimodal_index.docstore.docs) if hasattr(self.multimodal_index, 'docstore') else 0
264
+
265
+ return stats
266
+
267
+ def store_character_with_images(
268
+ self,
269
+ character_name: str,
270
+ character_description: str,
271
+ image_paths: List[str],
272
+ metadata: Optional[Dict] = None
273
+ ):
274
+ """
275
+ Store character with associated images in multimodal index
276
+
277
+ Args:
278
+ character_name: Name of the character
279
+ character_description: Text description
280
+ image_paths: List of paths to character images
281
+ metadata: Optional metadata
282
+ """
283
+ if not self.enable_multimodal or not self.multimodal_index:
284
+ print("Multimodal indexing not enabled. Storing text only.")
285
+ # Fallback to text-only storage
286
+ self.store_story(
287
+ story_text=f"Character: {character_name}\n{character_description}",
288
+ metadata={
289
+ "type": "character",
290
+ "character_name": character_name,
291
+ **(metadata or {})
292
+ }
293
+ )
294
+ return
295
+
296
+ try:
297
+ from llama_index.core.schema import ImageDocument
298
+
299
+ # Create text document
300
+ text_doc = Document(
301
+ text=f"Character: {character_name}\n{character_description}",
302
+ metadata={
303
+ "type": "character",
304
+ "character_name": character_name,
305
+ **(metadata or {})
306
+ }
307
+ )
308
+
309
+ # Create image documents
310
+ image_docs = []
311
+ for img_path in image_paths:
312
+ if os.path.exists(img_path):
313
+ img_doc = ImageDocument(
314
+ image_path=img_path,
315
+ metadata={
316
+ "character_name": character_name,
317
+ "type": "character_image",
318
+ **(metadata or {})
319
+ }
320
+ )
321
+ image_docs.append(img_doc)
322
+
323
+ # Index documents
324
+ if image_docs:
325
+ all_docs = [text_doc] + image_docs
326
+ self.multimodal_index.insert_nodes(all_docs)
327
+
328
+ # Persist
329
+ multimodal_dir = os.path.join(self.persist_dir, "multimodal")
330
+ self.multimodal_index.storage_context.persist(persist_dir=multimodal_dir)
331
+
332
+ print(f"Stored character '{character_name}' with {len(image_docs)} images in multimodal index")
333
+ else:
334
+ print(f"No valid images found for {character_name}. Storing text only.")
335
+ self.multimodal_index.insert(text_doc)
336
+
337
+ except Exception as e:
338
+ print(f"Error storing character with images: {e}")
339
+ # Fallback to text-only
340
+ self.store_story(
341
+ story_text=f"Character: {character_name}\n{character_description}",
342
+ metadata={"type": "character", "character_name": character_name}
343
+ )
344
+
345
+ def retrieve_character_with_images(
346
+ self,
347
+ character_name: str,
348
+ top_k: int = 3
349
+ ) -> Dict:
350
+ """
351
+ Retrieve character information including images
352
+
353
+ Args:
354
+ character_name: Name of the character
355
+ top_k: Number of results to return
356
+
357
+ Returns:
358
+ Dictionary with character info and image paths
359
+ """
360
+ if not self.enable_multimodal or not self.multimodal_index:
361
+ # Fallback to text-only retrieval
362
+ return {
363
+ "character_name": character_name,
364
+ "description": self.retrieve_context(character_name, top_k=top_k),
365
+ "images": []
366
+ }
367
+
368
+ try:
369
+ query_engine = self.multimodal_index.as_query_engine(similarity_top_k=top_k)
370
+ response = query_engine.query(f"Character: {character_name}")
371
+
372
+ # Extract image paths from retrieved nodes
373
+ image_paths = []
374
+ if hasattr(response, 'source_nodes'):
375
+ for node in response.source_nodes:
376
+ if hasattr(node, 'metadata') and node.metadata.get('type') == 'character_image':
377
+ img_path = node.metadata.get('image_path')
378
+ if img_path and os.path.exists(img_path):
379
+ image_paths.append(img_path)
380
+
381
+ return {
382
+ "character_name": character_name,
383
+ "description": str(response),
384
+ "images": image_paths
385
+ }
386
+
387
+ except Exception as e:
388
+ print(f"Error retrieving character with images: {e}")
389
+ return {
390
+ "character_name": character_name,
391
+ "description": "Error retrieving character",
392
+ "images": []
393
+ }
test_mcp_memory.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mythforge_mcp_memory_client import get_mcp_memory_client
2
+
3
+ client = get_mcp_memory_client()
4
+ if client:
5
+ # Test retrieve
6
+ context = client.retrieve_context_sync("wizard", top_k=3)
7
+ print(f"Retrieved: {context[:100]}")
8
+
9
+ # Test store
10
+ client.store_story_sync(
11
+ "A wizard discovers a spellbook",
12
+ {"title": "Test Story", "style": "manga"}
13
+ )
14
+ print("✅ Stored via MCP")
15
+ else:
16
+ print("❌ MCP client not available")