Spaces:
Build error
Build error
Upload 27 files
Browse files- .env.example +20 -0
- .gitignore +23 -0
- README.md +145 -7
- app.py +17 -5
- app_comic.py +1192 -0
- audio_generator.py +437 -0
- auto_character_intelligence.py +358 -0
- blaxel_agent.py +182 -0
- blaxel_config.py +74 -0
- character_consistency_manager.py +689 -0
- character_extractor.py +363 -0
- comic_generator.py +293 -0
- comic_layout.py +403 -0
- config.py +74 -0
- demo_automatic_comic.py +175 -0
- export_service.py +870 -0
- modal_image_generator.py +209 -0
- modal_sdxl.py +214 -0
- multimodal_analyzer.py +121 -0
- mythforge_mcp_memory_client.py +197 -0
- mythforge_mcp_server.py +1020 -0
- nano_banana_client.py +293 -0
- requirements.txt +28 -0
- sambanova_generator.py +372 -0
- speech_bubble_overlay.py +353 -0
- story_memory.py +393 -0
- test_mcp_memory.py +16 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.0.
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|