Spaces:
Sleeping
Sleeping
Initial deployment of Character Forge
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +90 -0
- .streamlit/config.toml +22 -0
- .streamlit/secrets.toml.template +12 -0
- DEPLOYMENT_CHECKLIST.md +189 -0
- Dockerfile +25 -9
- HUGGINGFACE_DEPLOYMENT.md +199 -0
- LICENSE.txt +680 -0
- NOTICE.txt +80 -0
- README.md +234 -15
- READY_TO_DEPLOY.md +190 -0
- RELEASE_NOTES.md +347 -0
- app.py +33 -0
- apply_agpl_license.py +252 -0
- character_forge_image/api_server.py +584 -0
- character_forge_image/app.py +189 -0
- character_forge_image/config/__init__.py +1 -0
- character_forge_image/config/settings.md +207 -0
- character_forge_image/config/settings.py +263 -0
- character_forge_image/configs/training_dataset_config.json +0 -0
- character_forge_image/core/__init__.py +5 -0
- character_forge_image/core/backend_router.md +169 -0
- character_forge_image/core/backend_router.py +422 -0
- character_forge_image/core/comfyui_client.py +360 -0
- character_forge_image/core/config/__init__.py +1 -0
- character_forge_image/core/config/settings.md +207 -0
- character_forge_image/core/config/settings.py +253 -0
- character_forge_image/core/flux_client.py +296 -0
- character_forge_image/core/gemini_client.md +262 -0
- character_forge_image/core/gemini_client.py +239 -0
- character_forge_image/core/omnigen2_client.md +412 -0
- character_forge_image/core/omnigen2_client.py +236 -0
- character_forge_image/models/__init__.py +6 -0
- character_forge_image/models/generation_request.md +57 -0
- character_forge_image/models/generation_request.py +122 -0
- character_forge_image/models/generation_result.md +61 -0
- character_forge_image/models/generation_result.py +160 -0
- character_forge_image/pages/01_🔥_Character_Forge.py +535 -0
- character_forge_image/pages/02_🎬_Composition_Assistant.py +308 -0
- character_forge_image/pages/03_📸_Standard_Interface.py +232 -0
- character_forge_image/pages/04_📚_Library.py +226 -0
- character_forge_image/pages/05_🎭_Character_Persistence.py +378 -0
- character_forge_image/plugins/__init__.py +11 -0
- character_forge_image/plugins/comfyui_plugin.py +192 -0
- character_forge_image/plugins/gemini_plugin.py +99 -0
- character_forge_image/plugins/omnigen2_plugin.py +108 -0
- character_forge_image/plugins/plugin_registry.yaml +21 -0
- character_forge_image/requirements.txt +35 -0
- character_forge_image/services/__init__.py +17 -0
- character_forge_image/services/character_forge_service.md +370 -0
- character_forge_image/services/character_forge_service.py +1167 -0
.gitignore
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Character Forge - Git Ignore
|
| 2 |
+
# Licensed under GNU AGPL v3.0
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*$py.class
|
| 8 |
+
*.so
|
| 9 |
+
.Python
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
MANIFEST
|
| 26 |
+
|
| 27 |
+
# Virtual Environment
|
| 28 |
+
venv/
|
| 29 |
+
ENV/
|
| 30 |
+
env/
|
| 31 |
+
|
| 32 |
+
# IDE
|
| 33 |
+
.vscode/
|
| 34 |
+
.idea/
|
| 35 |
+
*.swp
|
| 36 |
+
*.swo
|
| 37 |
+
*~
|
| 38 |
+
|
| 39 |
+
# OS
|
| 40 |
+
.DS_Store
|
| 41 |
+
Thumbs.db
|
| 42 |
+
|
| 43 |
+
# Generated Content - DO NOT COMMIT
|
| 44 |
+
outputs/
|
| 45 |
+
output/
|
| 46 |
+
character_forge_image/outputs/
|
| 47 |
+
*.log
|
| 48 |
+
generation.log
|
| 49 |
+
|
| 50 |
+
# Test Files
|
| 51 |
+
test_*.png
|
| 52 |
+
test_*.jpg
|
| 53 |
+
tests/test_output/
|
| 54 |
+
**/test_output/
|
| 55 |
+
|
| 56 |
+
# User Generated Images
|
| 57 |
+
*.png
|
| 58 |
+
*.jpg
|
| 59 |
+
*.jpeg
|
| 60 |
+
*.webp
|
| 61 |
+
*.gif
|
| 62 |
+
!docs/assets/*.png
|
| 63 |
+
!docs/assets/*.jpg
|
| 64 |
+
|
| 65 |
+
# Library/Cache
|
| 66 |
+
.library/
|
| 67 |
+
**/.library/
|
| 68 |
+
|
| 69 |
+
# Temporary Files
|
| 70 |
+
tmp/
|
| 71 |
+
temp/
|
| 72 |
+
*.tmp
|
| 73 |
+
|
| 74 |
+
# Environment variables
|
| 75 |
+
.env
|
| 76 |
+
.env.local
|
| 77 |
+
|
| 78 |
+
# Secrets (never commit API keys!)
|
| 79 |
+
*_api_key.txt
|
| 80 |
+
secrets.toml
|
| 81 |
+
.streamlit/secrets.toml
|
| 82 |
+
|
| 83 |
+
# HuggingFace
|
| 84 |
+
.huggingface/
|
| 85 |
+
|
| 86 |
+
# ComfyUI (if present)
|
| 87 |
+
comfyui/models/
|
| 88 |
+
comfyui/output/
|
| 89 |
+
comfyui/temp/
|
| 90 |
+
comfyui/input/
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Streamlit Configuration for HuggingFace Spaces
|
| 2 |
+
# Character Forge - Licensed under GNU AGPL v3.0
|
| 3 |
+
|
| 4 |
+
[server]
|
| 5 |
+
port = 7860
|
| 6 |
+
address = "0.0.0.0"
|
| 7 |
+
headless = true
|
| 8 |
+
enableCORS = false
|
| 9 |
+
enableXsrfProtection = false
|
| 10 |
+
maxUploadSize = 20
|
| 11 |
+
|
| 12 |
+
[browser]
|
| 13 |
+
gatherUsageStats = false
|
| 14 |
+
serverAddress = "0.0.0.0"
|
| 15 |
+
serverPort = 7860
|
| 16 |
+
|
| 17 |
+
[theme]
|
| 18 |
+
primaryColor = "#FF6B35"
|
| 19 |
+
backgroundColor = "#0E1117"
|
| 20 |
+
secondaryBackgroundColor = "#262730"
|
| 21 |
+
textColor = "#FAFAFA"
|
| 22 |
+
font = "sans serif"
|
.streamlit/secrets.toml.template
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Streamlit Secrets Template
|
| 2 |
+
# ===========================
|
| 3 |
+
# Copy this file to secrets.toml and add your API keys
|
| 4 |
+
# NEVER commit secrets.toml to version control!
|
| 5 |
+
|
| 6 |
+
# For HuggingFace Spaces:
|
| 7 |
+
# Add these as Repository Secrets in your Space settings
|
| 8 |
+
|
| 9 |
+
[default]
|
| 10 |
+
# Google Gemini API Key
|
| 11 |
+
# Get yours at: https://aistudio.google.com/app/apikey
|
| 12 |
+
GEMINI_API_KEY = "your-gemini-api-key-here"
|
DEPLOYMENT_CHECKLIST.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Character Forge - Deployment Checklist
|
| 2 |
+
|
| 3 |
+
## ✅ Pre-Deployment Cleanup Complete
|
| 4 |
+
|
| 5 |
+
Your project is now clean and ready for deployment!
|
| 6 |
+
|
| 7 |
+
### What Was Removed:
|
| 8 |
+
- ✅ **Output directories** (380 KB of generated images)
|
| 9 |
+
- ✅ **Test files** (test_female_tattoos, test_flux_pipeline)
|
| 10 |
+
- ✅ **Log files** (generation.log)
|
| 11 |
+
- ✅ **User-generated content** (character sheets, compositions)
|
| 12 |
+
- ✅ **Cache directories** (.library, __pycache__)
|
| 13 |
+
|
| 14 |
+
### Current Project Size:
|
| 15 |
+
**2.5 MB** - Perfect for HuggingFace deployment!
|
| 16 |
+
|
| 17 |
+
### Protected by .gitignore:
|
| 18 |
+
The following will NEVER be committed:
|
| 19 |
+
```
|
| 20 |
+
outputs/ # All output directories
|
| 21 |
+
*.png, *.jpg, *.jpeg # Generated images
|
| 22 |
+
*.log # Log files
|
| 23 |
+
.library/ # Cache
|
| 24 |
+
__pycache__/ # Python cache
|
| 25 |
+
.env, secrets.toml # API keys
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## 🚀 HuggingFace Deployment Steps
|
| 31 |
+
|
| 32 |
+
### 1. Create Space on HuggingFace
|
| 33 |
+
|
| 34 |
+
Go to: https://huggingface.co/spaces
|
| 35 |
+
|
| 36 |
+
**Settings:**
|
| 37 |
+
- Owner: `ghmk` (or your username)
|
| 38 |
+
- Space name: `character_forge`
|
| 39 |
+
- License: **`agpl-3.0`** ⚠️ IMPORTANT
|
| 40 |
+
- SDK: **Docker**
|
| 41 |
+
- Template: **Streamlit**
|
| 42 |
+
- Hardware: **CPU Basic (Free)**
|
| 43 |
+
- Visibility: Public or Private
|
| 44 |
+
|
| 45 |
+
### 2. Upload Files
|
| 46 |
+
|
| 47 |
+
**Required files only:**
|
| 48 |
+
```
|
| 49 |
+
character_forge_release/
|
| 50 |
+
├── character_forge_image/ # Main app
|
| 51 |
+
│ ├── app.py
|
| 52 |
+
│ ├── config/
|
| 53 |
+
│ ├── services/
|
| 54 |
+
│ └── ui/
|
| 55 |
+
├── .streamlit/
|
| 56 |
+
│ └── config.toml
|
| 57 |
+
├── Dockerfile
|
| 58 |
+
├── requirements.txt
|
| 59 |
+
├── LICENSE
|
| 60 |
+
├── NOTICE
|
| 61 |
+
├── README.md
|
| 62 |
+
└── .gitignore
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
**DO NOT UPLOAD:**
|
| 66 |
+
- ❌ outputs/ directory
|
| 67 |
+
- ❌ .log files
|
| 68 |
+
- ❌ test files
|
| 69 |
+
- ❌ apply_agpl_license.py (utility, not needed)
|
| 70 |
+
- ❌ cleanup_for_deployment.py (utility, not needed)
|
| 71 |
+
|
| 72 |
+
### 3. Add Secret
|
| 73 |
+
|
| 74 |
+
**CRITICAL:** In Space Settings → Repository Secrets:
|
| 75 |
+
- Name: `GEMINI_API_KEY`
|
| 76 |
+
- Value: Your actual API key
|
| 77 |
+
|
| 78 |
+
### 4. Wait for Build (3-5 minutes)
|
| 79 |
+
|
| 80 |
+
### 5. Test Your Deployment
|
| 81 |
+
|
| 82 |
+
Visit: `https://huggingface.co/spaces/ghmk/character_forge`
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 📦 GitHub Upload (Optional)
|
| 87 |
+
|
| 88 |
+
If you want to also put it on GitHub:
|
| 89 |
+
|
| 90 |
+
### Option A: Create New Repo on GitHub
|
| 91 |
+
|
| 92 |
+
1. Go to https://github.com/new
|
| 93 |
+
2. Repository name: `character-forge`
|
| 94 |
+
3. License: **GNU Affero General Public License v3.0**
|
| 95 |
+
4. Create repository
|
| 96 |
+
|
| 97 |
+
### Option B: Push to GitHub
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
cd D:/NBBLocal/character_forge_release
|
| 101 |
+
|
| 102 |
+
# Initialize git (if not already)
|
| 103 |
+
git init
|
| 104 |
+
|
| 105 |
+
# Add files (respects .gitignore)
|
| 106 |
+
git add .
|
| 107 |
+
|
| 108 |
+
# Commit
|
| 109 |
+
git commit -m "Initial release of Character Forge
|
| 110 |
+
|
| 111 |
+
- Multi-angle character sheet generation
|
| 112 |
+
- Composition assistant
|
| 113 |
+
- Gemini API and ComfyUI backends
|
| 114 |
+
- Licensed under GNU AGPL v3.0"
|
| 115 |
+
|
| 116 |
+
# Add remote
|
| 117 |
+
git remote add origin https://github.com/YOUR_USERNAME/character-forge.git
|
| 118 |
+
|
| 119 |
+
# Push
|
| 120 |
+
git branch -M main
|
| 121 |
+
git push -u origin main
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
The `.gitignore` file will automatically prevent outputs and generated content from being committed!
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## 🔒 License Compliance
|
| 129 |
+
|
| 130 |
+
Your project is properly licensed under **GNU AGPL v3.0**:
|
| 131 |
+
|
| 132 |
+
✅ LICENSE file contains full AGPL v3.0 text
|
| 133 |
+
✅ NOTICE file explains user content ownership
|
| 134 |
+
✅ README.md has AGPL badge and explanation
|
| 135 |
+
✅ Source files have license headers
|
| 136 |
+
|
| 137 |
+
**What this means:**
|
| 138 |
+
- ✓ Free for everyone to use
|
| 139 |
+
- ✓ Users own their generated images
|
| 140 |
+
- ✓ Must stay open source if modified
|
| 141 |
+
- ✗ Cannot be integrated into proprietary software
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## 🧹 Running Cleanup Again
|
| 146 |
+
|
| 147 |
+
If you generate more test content, run the cleanup script again:
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
python cleanup_for_deployment.py
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
This script is safe to run anytime - it only removes generated content, never source code.
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## 📊 Project Statistics
|
| 158 |
+
|
| 159 |
+
**Before Cleanup:** 2.9 MB
|
| 160 |
+
**After Cleanup:** 2.5 MB
|
| 161 |
+
**Space Saved:** 0.4 MB
|
| 162 |
+
|
| 163 |
+
**Files Cleaned:**
|
| 164 |
+
- Output directories: 1
|
| 165 |
+
- Images: 0 (already clean)
|
| 166 |
+
- Logs: 0 (already clean)
|
| 167 |
+
- Cache: 0 (already clean)
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## ✅ Final Checklist
|
| 172 |
+
|
| 173 |
+
Before deploying, verify:
|
| 174 |
+
|
| 175 |
+
- [ ] LICENSE file is GNU AGPL v3.0
|
| 176 |
+
- [ ] NOTICE file explains content ownership
|
| 177 |
+
- [ ] README.md has correct license badge
|
| 178 |
+
- [ ] .gitignore is in place
|
| 179 |
+
- [ ] No outputs/ directory exists
|
| 180 |
+
- [ ] No .log files present
|
| 181 |
+
- [ ] No test images remaining
|
| 182 |
+
- [ ] Dockerfile is present
|
| 183 |
+
- [ ] .streamlit/config.toml is present
|
| 184 |
+
- [ ] requirements.txt is present
|
| 185 |
+
- [ ] You have your GEMINI_API_KEY ready
|
| 186 |
+
|
| 187 |
+
**All set? Deploy now!**
|
| 188 |
+
|
| 189 |
+
See `HUGGINGFACE_DEPLOYMENT.md` for detailed deployment instructions.
|
Dockerfile
CHANGED
|
@@ -1,20 +1,36 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
|
|
|
| 5 |
RUN apt-get update && apt-get install -y \
|
| 6 |
-
build-essential \
|
| 7 |
-
curl \
|
| 8 |
git \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
|
| 12 |
-
COPY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
| 1 |
+
# Character Forge - HuggingFace Space Dockerfile
|
| 2 |
+
# Licensed under GNU AGPL v3.0
|
| 3 |
|
| 4 |
+
FROM python:3.10-slim
|
| 5 |
+
|
| 6 |
+
# Set working directory
|
| 7 |
WORKDIR /app
|
| 8 |
|
| 9 |
+
# Install system dependencies
|
| 10 |
RUN apt-get update && apt-get install -y \
|
|
|
|
|
|
|
| 11 |
git \
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
|
| 14 |
+
# Copy requirements first for better caching
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
|
| 17 |
+
# Install Python dependencies
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Copy application files
|
| 21 |
+
COPY . .
|
| 22 |
|
| 23 |
+
# Create output directory
|
| 24 |
+
RUN mkdir -p output
|
| 25 |
|
| 26 |
+
# Expose Streamlit port
|
| 27 |
+
EXPOSE 7860
|
| 28 |
|
| 29 |
+
# Set environment variables for HuggingFace Spaces
|
| 30 |
+
ENV STREAMLIT_SERVER_PORT=7860
|
| 31 |
+
ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0
|
| 32 |
+
ENV STREAMLIT_SERVER_HEADLESS=true
|
| 33 |
+
ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
| 34 |
|
| 35 |
+
# Run the application
|
| 36 |
+
CMD ["streamlit", "run", "character_forge_image/app.py", "--server.port=7860", "--server.address=0.0.0.0"]
|
HUGGINGFACE_DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying Character Forge to HuggingFace Spaces
|
| 2 |
+
|
| 3 |
+
This guide will help you deploy Character Forge to HuggingFace Spaces.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
1. **HuggingFace Account**: Sign up at https://huggingface.co/join
|
| 8 |
+
2. **Gemini API Key**: Get one free at https://aistudio.google.com/app/apikey
|
| 9 |
+
|
| 10 |
+
## Step-by-Step Deployment
|
| 11 |
+
|
| 12 |
+
### 1. Create a New Space
|
| 13 |
+
|
| 14 |
+
Go to https://huggingface.co/spaces and click "Create new Space"
|
| 15 |
+
|
| 16 |
+
Fill in the form:
|
| 17 |
+
- **Owner**: Your username or organization
|
| 18 |
+
- **Space name**: `character_forge` (or your preferred name)
|
| 19 |
+
- **Short description**: "Transform a single image into a complete multi-angle character sheet"
|
| 20 |
+
- **License**: **GNU AGPL v3.0** ⚠️ IMPORTANT
|
| 21 |
+
- **Select SDK**: **Docker**
|
| 22 |
+
- **Docker template**: **Streamlit**
|
| 23 |
+
- **Space hardware**: **CPU Basic (Free)** - sufficient for Gemini API backend
|
| 24 |
+
- **Visibility**: Public or Private (your choice)
|
| 25 |
+
|
| 26 |
+
Click **"Create Space"**
|
| 27 |
+
|
| 28 |
+
### 2. Upload Files
|
| 29 |
+
|
| 30 |
+
You have two options:
|
| 31 |
+
|
| 32 |
+
#### Option A: Git Upload (Recommended)
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
# Clone your new space
|
| 36 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/character_forge
|
| 37 |
+
cd character_forge
|
| 38 |
+
|
| 39 |
+
# Copy Character Forge files
|
| 40 |
+
cp -r /path/to/character_forge_release/* .
|
| 41 |
+
|
| 42 |
+
# Add, commit, and push
|
| 43 |
+
git add .
|
| 44 |
+
git commit -m "Initial deployment of Character Forge"
|
| 45 |
+
git push
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
#### Option B: Web Upload
|
| 49 |
+
|
| 50 |
+
Use the HuggingFace web interface to upload:
|
| 51 |
+
|
| 52 |
+
**Required files/folders:**
|
| 53 |
+
```
|
| 54 |
+
character_forge/ # Main application directory
|
| 55 |
+
├── character_forge_image/ # Streamlit app
|
| 56 |
+
│ ├── app.py # Main entry point
|
| 57 |
+
│ ├── config/ # Configuration
|
| 58 |
+
│ ├── services/ # Backend services
|
| 59 |
+
│ └── ui/ # UI components
|
| 60 |
+
├── .streamlit/
|
| 61 |
+
│ └── config.toml # Streamlit config
|
| 62 |
+
├── Dockerfile # HuggingFace deployment config
|
| 63 |
+
├── requirements.txt # Python dependencies
|
| 64 |
+
├── LICENSE # GNU AGPL v3.0
|
| 65 |
+
├── NOTICE # Important license info
|
| 66 |
+
└── README.md # Documentation
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### 3. Configure Environment Variables
|
| 70 |
+
|
| 71 |
+
This is **CRITICAL** for the app to work!
|
| 72 |
+
|
| 73 |
+
1. Go to your Space settings: `Settings` tab
|
| 74 |
+
2. Click on `Repository secrets`
|
| 75 |
+
3. Add a new secret:
|
| 76 |
+
- **Name**: `GEMINI_API_KEY`
|
| 77 |
+
- **Value**: Your actual Gemini API key from Google AI Studio
|
| 78 |
+
|
| 79 |
+
### 4. Wait for Build
|
| 80 |
+
|
| 81 |
+
HuggingFace will automatically:
|
| 82 |
+
1. Detect the Dockerfile
|
| 83 |
+
2. Build the Docker image
|
| 84 |
+
3. Deploy the application
|
| 85 |
+
4. Show build logs in real-time
|
| 86 |
+
|
| 87 |
+
This takes 3-5 minutes for the first build.
|
| 88 |
+
|
| 89 |
+
### 5. Access Your App
|
| 90 |
+
|
| 91 |
+
Once deployed, your app will be available at:
|
| 92 |
+
```
|
| 93 |
+
https://huggingface.co/spaces/YOUR_USERNAME/character_forge
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## Configuration Details
|
| 97 |
+
|
| 98 |
+
### Dockerfile
|
| 99 |
+
|
| 100 |
+
The included `Dockerfile` is pre-configured for HuggingFace Spaces:
|
| 101 |
+
- Uses Python 3.10
|
| 102 |
+
- Installs all dependencies from requirements.txt
|
| 103 |
+
- Exposes port 7860 (HuggingFace standard)
|
| 104 |
+
- Runs Streamlit with proper server settings
|
| 105 |
+
|
| 106 |
+
### Streamlit Config
|
| 107 |
+
|
| 108 |
+
The `.streamlit/config.toml` file includes:
|
| 109 |
+
- Port 7860 binding
|
| 110 |
+
- Headless mode for server deployment
|
| 111 |
+
- Disabled CORS for HuggingFace proxy
|
| 112 |
+
- Custom theme matching Character Forge branding
|
| 113 |
+
|
| 114 |
+
### Requirements
|
| 115 |
+
|
| 116 |
+
The `requirements.txt` includes minimal dependencies:
|
| 117 |
+
- Streamlit for the UI
|
| 118 |
+
- google-genai for Gemini API
|
| 119 |
+
- Pillow for image processing
|
| 120 |
+
- Other essential libraries
|
| 121 |
+
|
| 122 |
+
**No GPU required** - all processing happens via Gemini API!
|
| 123 |
+
|
| 124 |
+
## Troubleshooting
|
| 125 |
+
|
| 126 |
+
### App Won't Start
|
| 127 |
+
|
| 128 |
+
Check the build logs for errors. Common issues:
|
| 129 |
+
- Missing `GEMINI_API_KEY` secret
|
| 130 |
+
- Incorrect file paths in Dockerfile
|
| 131 |
+
- Missing dependencies in requirements.txt
|
| 132 |
+
|
| 133 |
+
### "Invalid API Key" Error
|
| 134 |
+
|
| 135 |
+
1. Verify your Gemini API key at https://aistudio.google.com/app/apikey
|
| 136 |
+
2. Check the secret name is exactly `GEMINI_API_KEY` (case-sensitive)
|
| 137 |
+
3. Make sure there are no extra spaces in the key value
|
| 138 |
+
|
| 139 |
+
### Slow Performance
|
| 140 |
+
|
| 141 |
+
- Free CPU tier is slower than local GPU
|
| 142 |
+
- Consider upgrading to CPU Upgrade ($0.05/hour) if needed
|
| 143 |
+
- Generation still takes 30-60 seconds per image with Gemini API
|
| 144 |
+
|
| 145 |
+
### Upload Size Limits
|
| 146 |
+
|
| 147 |
+
HuggingFace has file size limits. If you get errors:
|
| 148 |
+
- Don't include large example images in the repo
|
| 149 |
+
- Keep the deployment lean (under 500MB total)
|
| 150 |
+
- Users will upload their own images
|
| 151 |
+
|
| 152 |
+
## Updating Your Space
|
| 153 |
+
|
| 154 |
+
To update after changes:
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
cd character_forge
|
| 158 |
+
git pull origin main # Get latest changes
|
| 159 |
+
git add .
|
| 160 |
+
git commit -m "Update to latest version"
|
| 161 |
+
git push
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
HuggingFace will automatically rebuild and redeploy.
|
| 165 |
+
|
| 166 |
+
## Cost Considerations
|
| 167 |
+
|
| 168 |
+
### HuggingFace Hosting
|
| 169 |
+
- **CPU Basic (Free)**: $0/month - Works great!
|
| 170 |
+
- **CPU Upgrade**: ~$0.05/hour (~$36/month if always on)
|
| 171 |
+
- **Sleep mode**: Free tier sleeps after 48h inactivity
|
| 172 |
+
|
| 173 |
+
### Gemini API Usage
|
| 174 |
+
- **Free tier**: 15 requests/minute, 1500 requests/day
|
| 175 |
+
- **Paid tier**: ~$0.03 per image generation
|
| 176 |
+
- **Character sheet**: ~$0.15 total (5 images)
|
| 177 |
+
|
| 178 |
+
**Total cost for casual use: FREE!**
|
| 179 |
+
|
| 180 |
+
## License Compliance
|
| 181 |
+
|
| 182 |
+
Character Forge is licensed under **GNU AGPL v3.0**:
|
| 183 |
+
|
| 184 |
+
✓ **Your deployment is legal** - hosting on HuggingFace Spaces is fine
|
| 185 |
+
✓ **User-generated content** - Users own their generated images
|
| 186 |
+
✓ **Must keep open source** - Don't remove license files
|
| 187 |
+
✗ **No proprietary versions** - Any modifications must stay AGPL
|
| 188 |
+
|
| 189 |
+
Make sure your Space is marked as **AGPL-3.0 license** in settings!
|
| 190 |
+
|
| 191 |
+
## Support
|
| 192 |
+
|
| 193 |
+
- **Issues**: https://github.com/yourusername/character-forge/issues
|
| 194 |
+
- **Documentation**: See README.md in the repo
|
| 195 |
+
- **HuggingFace Help**: https://huggingface.co/docs/hub/spaces
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
**Ready to deploy? Follow the steps above and you'll be generating character sheets in minutes!**
|
LICENSE.txt
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Character Forge
|
| 2 |
+
Copyright (C) 2025 Gregor Hubert, Max Koch "cronos3k" (GK -/a/- ghmk.de)
|
| 3 |
+
|
| 4 |
+
This program is free software: you can redistribute it and/or modify
|
| 5 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
(at your option) any later version.
|
| 8 |
+
|
| 9 |
+
This program is distributed in the hope that it will be useful,
|
| 10 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
================================================================================
|
| 18 |
+
|
| 19 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 20 |
+
Version 3, 19 November 2007
|
| 21 |
+
|
| 22 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
| 23 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 24 |
+
of this license document, but changing it is not allowed.
|
| 25 |
+
|
| 26 |
+
Preamble
|
| 27 |
+
|
| 28 |
+
The GNU Affero General Public License is a free, copyleft license for
|
| 29 |
+
software and other kinds of works, specifically designed to ensure
|
| 30 |
+
cooperation with the community in the case of network server software.
|
| 31 |
+
|
| 32 |
+
The licenses for most software and other practical works are designed
|
| 33 |
+
to take away your freedom to share and change the works. By contrast,
|
| 34 |
+
our General Public Licenses are intended to guarantee your freedom to
|
| 35 |
+
share and change all versions of a program--to make sure it remains free
|
| 36 |
+
software for all its users.
|
| 37 |
+
|
| 38 |
+
When we speak of free software, we are referring to freedom, not
|
| 39 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 40 |
+
have the freedom to distribute copies of free software (and charge for
|
| 41 |
+
them if you wish), that you receive source code or can get it if you
|
| 42 |
+
want it, that you can change the software or use pieces of it in new
|
| 43 |
+
free programs, and that you know you can do these things.
|
| 44 |
+
|
| 45 |
+
Developers that use our General Public Licenses protect your rights
|
| 46 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
| 47 |
+
you this License which gives you legal permission to copy, distribute
|
| 48 |
+
and/or modify the software.
|
| 49 |
+
|
| 50 |
+
A secondary benefit of defending all users' freedom is that
|
| 51 |
+
improvements made in alternate versions of the program, if they
|
| 52 |
+
receive widespread use, become available for other developers to
|
| 53 |
+
incorporate. Many developers of free software are heartened and
|
| 54 |
+
encouraged by the resulting cooperation. However, in the case of
|
| 55 |
+
software used on network servers, this result may fail to come about.
|
| 56 |
+
The GNU General Public License permits making a modified version and
|
| 57 |
+
letting the public access it on a server without ever releasing its
|
| 58 |
+
source code to the public.
|
| 59 |
+
|
| 60 |
+
The GNU Affero General Public License is designed specifically to
|
| 61 |
+
ensure that, in such cases, the modified source code becomes available
|
| 62 |
+
to the community. It requires the operator of a network server to
|
| 63 |
+
provide the source code of the modified version running there to the
|
| 64 |
+
users of that server. Therefore, public use of a modified version, on
|
| 65 |
+
a publicly accessible server, gives the public access to the source
|
| 66 |
+
code of the modified version.
|
| 67 |
+
|
| 68 |
+
An older license, called the Affero General Public License and
|
| 69 |
+
published by Affero, was designed to accomplish similar goals. This is
|
| 70 |
+
a different license, not a version of the Affero GPL, but Affero has
|
| 71 |
+
released a new version of the Affero GPL which permits relicensing under
|
| 72 |
+
this license.
|
| 73 |
+
|
| 74 |
+
The precise terms and conditions for copying, distribution and
|
| 75 |
+
modification follow.
|
| 76 |
+
|
| 77 |
+
TERMS AND CONDITIONS
|
| 78 |
+
|
| 79 |
+
0. Definitions.
|
| 80 |
+
|
| 81 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
| 82 |
+
|
| 83 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 84 |
+
works, such as semiconductor masks.
|
| 85 |
+
|
| 86 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 87 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 88 |
+
"recipients" may be individuals or organizations.
|
| 89 |
+
|
| 90 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 91 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 92 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 93 |
+
earlier work or a work "based on" the earlier work.
|
| 94 |
+
|
| 95 |
+
A "covered work" means either the unmodified Program or a work based
|
| 96 |
+
on the Program.
|
| 97 |
+
|
| 98 |
+
To "propagate" a work means to do anything with it that, without
|
| 99 |
+
permission, would make you directly or secondarily liable for
|
| 100 |
+
infringement under applicable copyright law, except executing it on a
|
| 101 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 102 |
+
distribution (with or without modification), making available to the
|
| 103 |
+
public, and in some countries other activities as well.
|
| 104 |
+
|
| 105 |
+
To "convey" a work means any kind of propagation that enables other
|
| 106 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 107 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 108 |
+
|
| 109 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 110 |
+
to the extent that it includes a convenient and prominently visible
|
| 111 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 112 |
+
tells the user that there is no warranty for the work (except to the
|
| 113 |
+
extent that warranties are provided), that licensees may convey the
|
| 114 |
+
work under this License, and how to view a copy of this License. If
|
| 115 |
+
the interface presents a list of user commands or options, such as a
|
| 116 |
+
menu, a prominent item in the list meets this criterion.
|
| 117 |
+
|
| 118 |
+
1. Source Code.
|
| 119 |
+
|
| 120 |
+
The "source code" for a work means the preferred form of the work
|
| 121 |
+
for making modifications to it. "Object code" means any non-source
|
| 122 |
+
form of a work.
|
| 123 |
+
|
| 124 |
+
A "Standard Interface" means an interface that either is an official
|
| 125 |
+
standard defined by a recognized standards body, or, in the case of
|
| 126 |
+
interfaces specified for a particular programming language, one that
|
| 127 |
+
is widely used among developers working in that language.
|
| 128 |
+
|
| 129 |
+
The "System Libraries" of an executable work include anything, other
|
| 130 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 131 |
+
packaging a Major Component, but which is not part of that Major
|
| 132 |
+
Component, and (b) serves only to enable use of the work with that
|
| 133 |
+
Major Component, or to implement a Standard Interface for which an
|
| 134 |
+
implementation is available to the public in source code form. A
|
| 135 |
+
"Major Component", in this context, means a major essential component
|
| 136 |
+
(kernel, window system, and so on) of the specific operating system
|
| 137 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 138 |
+
produce the work, or an object code interpreter used to run it.
|
| 139 |
+
|
| 140 |
+
The "Corresponding Source" for a work in object code form means all
|
| 141 |
+
the source code needed to generate, install, and (for an executable
|
| 142 |
+
work) run the object code and to modify the work, including scripts to
|
| 143 |
+
control those activities. However, it does not include the work's
|
| 144 |
+
System Libraries, or general-purpose tools or generally available free
|
| 145 |
+
programs which are used unmodified in performing those activities but
|
| 146 |
+
which are not part of the work. For example, Corresponding Source
|
| 147 |
+
includes interface definition files associated with source files for
|
| 148 |
+
the work, and the source code for shared libraries and dynamically
|
| 149 |
+
linked subprograms that the work is specifically designed to require,
|
| 150 |
+
such as by intimate data communication or control flow between those
|
| 151 |
+
subprograms and other parts of the work.
|
| 152 |
+
|
| 153 |
+
The Corresponding Source need not include anything that users
|
| 154 |
+
can regenerate automatically from other parts of the Corresponding
|
| 155 |
+
Source.
|
| 156 |
+
|
| 157 |
+
The Corresponding Source for a work in source code form is that
|
| 158 |
+
same work.
|
| 159 |
+
|
| 160 |
+
2. Basic Permissions.
|
| 161 |
+
|
| 162 |
+
All rights granted under this License are granted for the term of
|
| 163 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 164 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 165 |
+
permission to run the unmodified Program. The output from running a
|
| 166 |
+
covered work is covered by this License only if the output, given its
|
| 167 |
+
content, constitutes a covered work. This License acknowledges your
|
| 168 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 169 |
+
|
| 170 |
+
You may make, run and propagate covered works that you do not
|
| 171 |
+
convey, without conditions so long as your license otherwise remains
|
| 172 |
+
in force. You may convey covered works to others for the sole purpose
|
| 173 |
+
of having them make modifications exclusively for you, or provide you
|
| 174 |
+
with facilities for running those works, provided that you comply with
|
| 175 |
+
the terms of this License in conveying all material for which you do
|
| 176 |
+
not control copyright. Those thus making or running the covered works
|
| 177 |
+
for you must do so exclusively on your behalf, under your direction
|
| 178 |
+
and control, on terms that prohibit them from making any copies of
|
| 179 |
+
your copyrighted material outside their relationship with you.
|
| 180 |
+
|
| 181 |
+
Conveying under any other circumstances is permitted solely under
|
| 182 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 183 |
+
makes it unnecessary.
|
| 184 |
+
|
| 185 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 186 |
+
|
| 187 |
+
No covered work shall be deemed part of an effective technological
|
| 188 |
+
measure under any applicable law fulfilling obligations under article
|
| 189 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 190 |
+
similar laws prohibiting or restricting circumvention of such
|
| 191 |
+
measures.
|
| 192 |
+
|
| 193 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 194 |
+
circumvention of technological measures to the extent such circumvention
|
| 195 |
+
is effected by exercising rights under this License with respect to
|
| 196 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 197 |
+
modification of the work as a means of enforcing, against the work's
|
| 198 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 199 |
+
technological measures.
|
| 200 |
+
|
| 201 |
+
4. Conveying Verbatim Copies.
|
| 202 |
+
|
| 203 |
+
You may convey verbatim copies of the Program's source code as you
|
| 204 |
+
receive it, in any medium, provided that you conspicuously and
|
| 205 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 206 |
+
keep intact all notices stating that this License and any
|
| 207 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 208 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 209 |
+
recipients a copy of this License along with the Program.
|
| 210 |
+
|
| 211 |
+
You may charge any price or no price for each copy that you convey,
|
| 212 |
+
and you may offer support or warranty protection for a fee.
|
| 213 |
+
|
| 214 |
+
5. Conveying Modified Source Versions.
|
| 215 |
+
|
| 216 |
+
You may convey a work based on the Program, or the modifications to
|
| 217 |
+
produce it from the Program, in the form of source code under the
|
| 218 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 219 |
+
|
| 220 |
+
a) The work must carry prominent notices stating that you modified
|
| 221 |
+
it, and giving a relevant date.
|
| 222 |
+
|
| 223 |
+
b) The work must carry prominent notices stating that it is
|
| 224 |
+
released under this License and any conditions added under section
|
| 225 |
+
7. This requirement modifies the requirement in section 4 to
|
| 226 |
+
"keep intact all notices".
|
| 227 |
+
|
| 228 |
+
c) You must license the entire work, as a whole, under this
|
| 229 |
+
License to anyone who comes into possession of a copy. This
|
| 230 |
+
License will therefore apply, along with any applicable section 7
|
| 231 |
+
additional terms, to the whole of the work, and all its parts,
|
| 232 |
+
regardless of how they are packaged. This License gives no
|
| 233 |
+
permission to license the work in any other way, but it does not
|
| 234 |
+
invalidate such permission if you have separately received it.
|
| 235 |
+
|
| 236 |
+
d) If the work has interactive user interfaces, each must display
|
| 237 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 238 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 239 |
+
work need not make them do so.
|
| 240 |
+
|
| 241 |
+
A compilation of a covered work with other separate and independent
|
| 242 |
+
works, which are not by their nature extensions of the covered work,
|
| 243 |
+
and which are not combined with it such as to form a larger program,
|
| 244 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 245 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 246 |
+
used to limit the access or legal rights of the compilation's users
|
| 247 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 248 |
+
in an aggregate does not cause this License to apply to the other
|
| 249 |
+
parts of the aggregate.
|
| 250 |
+
|
| 251 |
+
6. Conveying Non-Source Forms.
|
| 252 |
+
|
| 253 |
+
You may convey a covered work in object code form under the terms
|
| 254 |
+
of sections 4 and 5, provided that you also convey the
|
| 255 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 256 |
+
in one of these ways:
|
| 257 |
+
|
| 258 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 259 |
+
(including a physical distribution medium), accompanied by the
|
| 260 |
+
Corresponding Source fixed on a durable physical medium
|
| 261 |
+
customarily used for software interchange.
|
| 262 |
+
|
| 263 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 264 |
+
(including a physical distribution medium), accompanied by a
|
| 265 |
+
written offer, valid for at least three years and valid for as
|
| 266 |
+
long as you offer spare parts or customer support for that product
|
| 267 |
+
model, to give anyone who possesses the object code either (1) a
|
| 268 |
+
copy of the Corresponding Source for all the software in the
|
| 269 |
+
product that is covered by this License, on a durable physical
|
| 270 |
+
medium customarily used for software interchange, for a price no
|
| 271 |
+
more than your reasonable cost of physically performing this
|
| 272 |
+
conveying of source, or (2) access to copy the
|
| 273 |
+
Corresponding Source from a network server at no charge.
|
| 274 |
+
|
| 275 |
+
c) Convey individual copies of the object code with a copy of the
|
| 276 |
+
written offer to provide the Corresponding Source. This
|
| 277 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 278 |
+
only if you received the object code with such an offer, in accord
|
| 279 |
+
with subsection 6b.
|
| 280 |
+
|
| 281 |
+
d) Convey the object code by offering access from a designated
|
| 282 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 283 |
+
Corresponding Source in the same way through the same place at no
|
| 284 |
+
further charge. You need not require recipients to copy the
|
| 285 |
+
Corresponding Source along with the object code. If the place to
|
| 286 |
+
copy the object code is a network server, the Corresponding Source
|
| 287 |
+
may be on a different server (operated by you or a third party)
|
| 288 |
+
that supports equivalent copying facilities, provided you maintain
|
| 289 |
+
clear directions next to the object code saying where to find the
|
| 290 |
+
Corresponding Source. Regardless of what server hosts the
|
| 291 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 292 |
+
available for as long as needed to satisfy these requirements.
|
| 293 |
+
|
| 294 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 295 |
+
you inform other peers where the object code and Corresponding
|
| 296 |
+
Source of the work are being offered to the general public at no
|
| 297 |
+
charge under subsection 6d.
|
| 298 |
+
|
| 299 |
+
A separable portion of the object code, whose source code is excluded
|
| 300 |
+
from the Corresponding Source as a System Library, need not be
|
| 301 |
+
included in conveying the object code work.
|
| 302 |
+
|
| 303 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 304 |
+
tangible personal property which is normally used for personal, family,
|
| 305 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 306 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 307 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 308 |
+
product received by a particular user, "normally used" refers to a
|
| 309 |
+
typical or common use of that class of product, regardless of the status
|
| 310 |
+
of the particular user or of the way in which the particular user
|
| 311 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 312 |
+
is a consumer product regardless of whether the product has substantial
|
| 313 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 314 |
+
the only significant mode of use of the product.
|
| 315 |
+
|
| 316 |
+
"Installation Information" for a User Product means any methods,
|
| 317 |
+
procedures, authorization keys, or other information required to install
|
| 318 |
+
and execute modified versions of a covered work in that User Product from
|
| 319 |
+
a modified version of its Corresponding Source. The information must
|
| 320 |
+
suffice to ensure that the continued functioning of the modified object
|
| 321 |
+
code is in no case prevented or interfered with solely because
|
| 322 |
+
modification has been made.
|
| 323 |
+
|
| 324 |
+
If you convey an object code work under this section in, or with, or
|
| 325 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 326 |
+
part of a transaction in which the right of possession and use of the
|
| 327 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 328 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 329 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 330 |
+
by the Installation Information. But this requirement does not apply
|
| 331 |
+
if neither you nor any third party retains the ability to install
|
| 332 |
+
modified object code on the User Product (for example, the work has
|
| 333 |
+
been installed in ROM).
|
| 334 |
+
|
| 335 |
+
The requirement to provide Installation Information does not include a
|
| 336 |
+
requirement to continue to provide support service, warranty, or updates
|
| 337 |
+
for a work that has been modified or installed by the recipient, or for
|
| 338 |
+
the User Product in which it has been modified or installed. Access to a
|
| 339 |
+
network may be denied when the modification itself materially and
|
| 340 |
+
adversely affects the operation of the network or violates the rules and
|
| 341 |
+
protocols for communication across the network.
|
| 342 |
+
|
| 343 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 344 |
+
in accord with this section must be in a format that is publicly
|
| 345 |
+
documented (and with an implementation available to the public in
|
| 346 |
+
source code form), and must require no special password or key for
|
| 347 |
+
unpacking, reading or copying.
|
| 348 |
+
|
| 349 |
+
7. Additional Terms.
|
| 350 |
+
|
| 351 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 352 |
+
License by making exceptions from one or more of its conditions.
|
| 353 |
+
Additional permissions that are applicable to the entire Program shall
|
| 354 |
+
be treated as though they were included in this License, to the extent
|
| 355 |
+
that they are valid under applicable law. If additional permissions
|
| 356 |
+
apply only to part of the Program, that part may be used separately
|
| 357 |
+
under those permissions, but the entire Program remains governed by
|
| 358 |
+
this License without regard to the additional permissions.
|
| 359 |
+
|
| 360 |
+
When you convey a copy of a covered work, you may at your option
|
| 361 |
+
remove any additional permissions from that copy, or from any part of
|
| 362 |
+
it. (Additional permissions may be written to require their own
|
| 363 |
+
removal in certain cases when you modify the work.) You may place
|
| 364 |
+
additional permissions on material, added by you to a covered work,
|
| 365 |
+
for which you have or can give appropriate copyright permission.
|
| 366 |
+
|
| 367 |
+
Notwithstanding any other provision of this License, for material you
|
| 368 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 369 |
+
that material) supplement the terms of this License with terms:
|
| 370 |
+
|
| 371 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 372 |
+
terms of sections 15 and 16 of this License; or
|
| 373 |
+
|
| 374 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 375 |
+
author attributions in that material or in the Appropriate Legal
|
| 376 |
+
Notices displayed by works containing it; or
|
| 377 |
+
|
| 378 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 379 |
+
requiring that modified versions of such material be marked in
|
| 380 |
+
reasonable ways as different from the original version; or
|
| 381 |
+
|
| 382 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 383 |
+
authors of the material; or
|
| 384 |
+
|
| 385 |
+
e) Declining to grant rights under trademark law for use of some
|
| 386 |
+
trade names, trademarks, or service marks; or
|
| 387 |
+
|
| 388 |
+
f) Requiring indemnification of licensors and authors of that
|
| 389 |
+
material by anyone who conveys the material (or modified versions of
|
| 390 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 391 |
+
any liability that these contractual assumptions directly impose on
|
| 392 |
+
those licensors and authors.
|
| 393 |
+
|
| 394 |
+
All other non-permissive additional terms are considered "further
|
| 395 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 396 |
+
received it, or any part of it, contains a notice stating that it is
|
| 397 |
+
governed by this License along with a term that is a further
|
| 398 |
+
restriction, you may remove that term. If a license document contains
|
| 399 |
+
a further restriction but permits relicensing or conveying under this
|
| 400 |
+
License, you may add to a covered work material governed by the terms
|
| 401 |
+
of that license document, provided that the further restriction does
|
| 402 |
+
not survive such relicensing or conveying.
|
| 403 |
+
|
| 404 |
+
If you add terms to a covered work in accord with this section, you
|
| 405 |
+
must place, in the relevant source files, a statement of the
|
| 406 |
+
additional terms that apply to those files, or a notice indicating
|
| 407 |
+
where to find the applicable terms.
|
| 408 |
+
|
| 409 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 410 |
+
form of a separately written license, or stated as exceptions;
|
| 411 |
+
the above requirements apply either way.
|
| 412 |
+
|
| 413 |
+
8. Termination.
|
| 414 |
+
|
| 415 |
+
You may not propagate or modify a covered work except as expressly
|
| 416 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 417 |
+
modify it is void, and will automatically terminate your rights under
|
| 418 |
+
this License (including any patent licenses granted under the third
|
| 419 |
+
paragraph of section 11).
|
| 420 |
+
|
| 421 |
+
However, if you cease all violation of this License, then your
|
| 422 |
+
license from a particular copyright holder is reinstated (a)
|
| 423 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 424 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 425 |
+
holder fails to notify you of the violation by some reasonable means
|
| 426 |
+
prior to 60 days after the cessation.
|
| 427 |
+
|
| 428 |
+
Moreover, your license from a particular copyright holder is
|
| 429 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 430 |
+
violation by some reasonable means, this is the first time you have
|
| 431 |
+
received notice of violation of this License (for any work) from that
|
| 432 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 433 |
+
your receipt of the notice.
|
| 434 |
+
|
| 435 |
+
Termination of your rights under this section does not terminate the
|
| 436 |
+
licenses of parties who have received copies or rights from you under
|
| 437 |
+
this License. If your rights have been terminated and not permanently
|
| 438 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 439 |
+
material under section 10.
|
| 440 |
+
|
| 441 |
+
9. Acceptance Not Required for Having Copies.
|
| 442 |
+
|
| 443 |
+
You are not required to accept this License in order to receive or
|
| 444 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 445 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 446 |
+
to receive a copy likewise does not require acceptance. However,
|
| 447 |
+
nothing other than this License grants you permission to propagate or
|
| 448 |
+
modify any covered work. These actions infringe copyright if you do
|
| 449 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 450 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 451 |
+
|
| 452 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 453 |
+
|
| 454 |
+
Each time you convey a covered work, the recipient automatically
|
| 455 |
+
receives a license from the original licensors, to run, modify and
|
| 456 |
+
propagate that work, subject to this License. You are not responsible
|
| 457 |
+
for enforcing compliance by third parties with this License.
|
| 458 |
+
|
| 459 |
+
An "entity transaction" is a transaction transferring control of an
|
| 460 |
+
organization, or substantially all assets of one, or subdividing an
|
| 461 |
+
organization, or merging organizations. If propagation of a covered
|
| 462 |
+
work results from an entity transaction, each party to that
|
| 463 |
+
transaction who receives a copy of the work also receives whatever
|
| 464 |
+
licenses to the work the party's predecessor in interest had or could
|
| 465 |
+
give under the previous paragraph, plus a right to possession of the
|
| 466 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 467 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 468 |
+
|
| 469 |
+
You may not impose any further restrictions on the exercise of the
|
| 470 |
+
rights granted or affirmed under this License. For example, you may
|
| 471 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 472 |
+
rights granted under this License, and you may not initiate litigation
|
| 473 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 474 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 475 |
+
sale, or importing the Program or any portion of it.
|
| 476 |
+
|
| 477 |
+
11. Patents.
|
| 478 |
+
|
| 479 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 480 |
+
License of the Program or a work on which the Program is based. The
|
| 481 |
+
work thus licensed is called the contributor's "contributor version".
|
| 482 |
+
|
| 483 |
+
A contributor's "essential patent claims" are all patent claims
|
| 484 |
+
owned or controlled by the contributor, whether already acquired or
|
| 485 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 486 |
+
by this License, of making, using, or selling its contributor version,
|
| 487 |
+
but do not include claims that would be infringed only as a
|
| 488 |
+
consequence of further modification of the contributor version. For
|
| 489 |
+
purposes of this definition, "control" includes the right to grant
|
| 490 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 491 |
+
this License.
|
| 492 |
+
|
| 493 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 494 |
+
patent license under the contributor's essential patent claims, to
|
| 495 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 496 |
+
propagate the contents of its contributor version.
|
| 497 |
+
|
| 498 |
+
In the following three paragraphs, a "patent license" is any express
|
| 499 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 500 |
+
(such as an express permission to practice a patent or covenant not to
|
| 501 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 502 |
+
party means to make such an agreement or commitment not to enforce a
|
| 503 |
+
patent against the party.
|
| 504 |
+
|
| 505 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 506 |
+
and the Corresponding Source of the work is not available for anyone
|
| 507 |
+
to copy, free of charge and under the terms of this License, through a
|
| 508 |
+
publicly available network server or other readily accessible means,
|
| 509 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 510 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 511 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 512 |
+
consistent with the requirements of this License, to extend the patent
|
| 513 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 514 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 515 |
+
covered work in a country, or your recipient's use of the covered work
|
| 516 |
+
in a country, would infringe one or more identifiable patents in that
|
| 517 |
+
country that you have reason to believe are valid.
|
| 518 |
+
|
| 519 |
+
If, pursuant to or in connection with a single transaction or
|
| 520 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 521 |
+
covered work, and grant a patent license to some of the parties
|
| 522 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 523 |
+
or convey a specific copy of the covered work, then the patent license
|
| 524 |
+
you grant is automatically extended to all recipients of the covered
|
| 525 |
+
work and works based on it.
|
| 526 |
+
|
| 527 |
+
A patent license is "discriminatory" if it does not include within
|
| 528 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 529 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 530 |
+
specifically granted under this License. You may not convey a covered
|
| 531 |
+
work if you are a party to an arrangement with a third party that is
|
| 532 |
+
in the business of distributing software, under which you make payment
|
| 533 |
+
to the third party based on the extent of your activity of conveying
|
| 534 |
+
the work, and under which the third party grants, to any of the
|
| 535 |
+
parties who would receive the covered work from you, a discriminatory
|
| 536 |
+
patent license (a) in connection with copies of the covered work
|
| 537 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 538 |
+
for and in connection with specific products or compilations that
|
| 539 |
+
contain the covered work, unless you entered into that arrangement,
|
| 540 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 541 |
+
|
| 542 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 543 |
+
any implied license or other defenses to infringement that may
|
| 544 |
+
otherwise be available to you under applicable patent law.
|
| 545 |
+
|
| 546 |
+
12. No Surrender of Others' Freedom.
|
| 547 |
+
|
| 548 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 549 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 550 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 551 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 552 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 553 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 554 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 555 |
+
the Program, the only way you could satisfy both those terms and this
|
| 556 |
+
License would be to refrain entirely from conveying the Program.
|
| 557 |
+
|
| 558 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
| 559 |
+
|
| 560 |
+
Notwithstanding any other provision of this License, if you modify the
|
| 561 |
+
Program, your modified version must prominently offer all users
|
| 562 |
+
interacting with it remotely through a computer network (if your version
|
| 563 |
+
supports such interaction) an opportunity to receive the Corresponding
|
| 564 |
+
Source of your version by providing access to the Corresponding Source
|
| 565 |
+
from a network server at no charge, through some standard or customary
|
| 566 |
+
means of facilitating copying of software. This Corresponding Source
|
| 567 |
+
shall include the Corresponding Source for any work covered by version 3
|
| 568 |
+
of the GNU General Public License that is incorporated pursuant to the
|
| 569 |
+
following paragraph.
|
| 570 |
+
|
| 571 |
+
Notwithstanding any other provision of this License, you have
|
| 572 |
+
permission to link or combine any covered work with a work licensed
|
| 573 |
+
under version 3 of the GNU General Public License into a single
|
| 574 |
+
combined work, and to convey the resulting work. The terms of this
|
| 575 |
+
License will continue to apply to the part which is the covered work,
|
| 576 |
+
but the work with which it is combined will remain governed by version
|
| 577 |
+
3 of the GNU General Public License.
|
| 578 |
+
|
| 579 |
+
14. Revised Versions of this License.
|
| 580 |
+
|
| 581 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 582 |
+
the GNU Affero General Public License from time to time. Such new versions
|
| 583 |
+
will be similar in spirit to the present version, but may differ in detail to
|
| 584 |
+
address new problems or concerns.
|
| 585 |
+
|
| 586 |
+
Each version is given a distinguishing version number. If the
|
| 587 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
| 588 |
+
Public License "or any later version" applies to it, you have the
|
| 589 |
+
option of following the terms and conditions either of that numbered
|
| 590 |
+
version or of any later version published by the Free Software
|
| 591 |
+
Foundation. If the Program does not specify a version number of the
|
| 592 |
+
GNU Affero General Public License, you may choose any version ever published
|
| 593 |
+
by the Free Software Foundation.
|
| 594 |
+
|
| 595 |
+
If the Program specifies that a proxy can decide which future
|
| 596 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
| 597 |
+
public statement of acceptance of a version permanently authorizes you
|
| 598 |
+
to choose that version for the Program.
|
| 599 |
+
|
| 600 |
+
Later license versions may give you additional or different
|
| 601 |
+
permissions. However, no additional obligations are imposed on any
|
| 602 |
+
author or copyright holder as a result of your choosing to follow a
|
| 603 |
+
later version.
|
| 604 |
+
|
| 605 |
+
15. Disclaimer of Warranty.
|
| 606 |
+
|
| 607 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 608 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 609 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 610 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 611 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 612 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 613 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 614 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 615 |
+
|
| 616 |
+
16. Limitation of Liability.
|
| 617 |
+
|
| 618 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 619 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 620 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 621 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 622 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 623 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 624 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 625 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 626 |
+
SUCH DAMAGES.
|
| 627 |
+
|
| 628 |
+
17. Interpretation of Sections 15 and 16.
|
| 629 |
+
|
| 630 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 631 |
+
above cannot be given local legal effect according to their terms,
|
| 632 |
+
reviewing courts shall apply local law that most closely approximates
|
| 633 |
+
an absolute waiver of all civil liability in connection with the
|
| 634 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 635 |
+
copy of the Program in return for a fee.
|
| 636 |
+
|
| 637 |
+
END OF TERMS AND CONDITIONS
|
| 638 |
+
|
| 639 |
+
How to Apply These Terms to Your New Programs
|
| 640 |
+
|
| 641 |
+
If you develop a new program, and you want it to be of the greatest
|
| 642 |
+
possible use to the public, the best way to achieve this is to make it
|
| 643 |
+
free software which everyone can redistribute and change under these terms.
|
| 644 |
+
|
| 645 |
+
To do so, attach the following notices to the program. It is safest
|
| 646 |
+
to attach them to the start of each source file to most effectively
|
| 647 |
+
state the exclusion of warranty; and each file should have at least
|
| 648 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 649 |
+
|
| 650 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 651 |
+
Copyright (C) <year> <name of author>
|
| 652 |
+
|
| 653 |
+
This program is free software: you can redistribute it and/or modify
|
| 654 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 655 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 656 |
+
(at your option) any later version.
|
| 657 |
+
|
| 658 |
+
This program is distributed in the hope that it will be useful,
|
| 659 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 660 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 661 |
+
GNU Affero General Public License for more details.
|
| 662 |
+
|
| 663 |
+
You should have received a copy of the GNU Affero General Public License
|
| 664 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 665 |
+
|
| 666 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 667 |
+
|
| 668 |
+
If your software can interact with users remotely through a computer
|
| 669 |
+
network, you should also make sure that it provides a way for users to
|
| 670 |
+
get its source. For example, if your program is a web application, its
|
| 671 |
+
interface could display a "Source" link that leads users to an archive
|
| 672 |
+
of the code. There are many ways you could offer source, and different
|
| 673 |
+
solutions will be better for different programs; see section 13 for the
|
| 674 |
+
specific requirements.
|
| 675 |
+
|
| 676 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 677 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 678 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
| 679 |
+
<https://www.gnu.org/licenses/>.
|
| 680 |
+
|
NOTICE.txt
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Character Forge - AI Image Generation Platform
|
| 2 |
+
Copyright (C) 2025 Gregor Hubert, Max Koch "cronos3k" (GK -/a/- ghmk.de)
|
| 3 |
+
|
| 4 |
+
This program is free software: you can redistribute it and/or modify
|
| 5 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
(at your option) any later version.
|
| 8 |
+
|
| 9 |
+
================================================================================
|
| 10 |
+
|
| 11 |
+
IMPORTANT: License for Generated Content
|
| 12 |
+
|
| 13 |
+
While this SOFTWARE is licensed under GNU AGPL v3, the OUTPUTS you create
|
| 14 |
+
with this software (images, character sheets, compositions) belong to YOU
|
| 15 |
+
and are NOT covered by the AGPL license.
|
| 16 |
+
|
| 17 |
+
You are free to:
|
| 18 |
+
- Use generated images for any purpose (personal or commercial)
|
| 19 |
+
- Sell your generated artwork
|
| 20 |
+
- Use characters in games, animations, or other projects
|
| 21 |
+
- Keep your generated content private or share it
|
| 22 |
+
|
| 23 |
+
The AGPL only applies to the software code itself, not your creative outputs.
|
| 24 |
+
|
| 25 |
+
================================================================================
|
| 26 |
+
|
| 27 |
+
COMMERCIAL USE RESTRICTIONS
|
| 28 |
+
|
| 29 |
+
The GNU AGPL v3 license means:
|
| 30 |
+
|
| 31 |
+
✓ FREE to use personally or for education
|
| 32 |
+
✓ FREE to modify and improve
|
| 33 |
+
✓ Your generated images/characters are YOURS to use commercially
|
| 34 |
+
|
| 35 |
+
✗ If you integrate this software into a commercial product or service,
|
| 36 |
+
you MUST release your entire codebase under AGPL v3
|
| 37 |
+
✗ You cannot create a proprietary/closed-source product using this code
|
| 38 |
+
✗ If you run this as a network service, you must provide source code
|
| 39 |
+
|
| 40 |
+
This prevents companies from taking this free software and building
|
| 41 |
+
commercial platforms without contributing back to the community.
|
| 42 |
+
|
| 43 |
+
================================================================================
|
| 44 |
+
|
| 45 |
+
THIRD-PARTY DEPENDENCIES
|
| 46 |
+
|
| 47 |
+
This project uses the following third-party software:
|
| 48 |
+
|
| 49 |
+
- Streamlit (Apache License 2.0)
|
| 50 |
+
Copyright (c) Streamlit Inc.
|
| 51 |
+
https://github.com/streamlit/streamlit
|
| 52 |
+
|
| 53 |
+
- google-generativeai (Apache License 2.0)
|
| 54 |
+
Copyright (c) Google LLC
|
| 55 |
+
https://github.com/google/generative-ai-python
|
| 56 |
+
|
| 57 |
+
- Pillow (PIL License)
|
| 58 |
+
Copyright (c) 1997-2011 by Secret Labs AB
|
| 59 |
+
Copyright (c) 1995-2011 by Fredrik Lundh
|
| 60 |
+
https://github.com/python-pillow/Pillow
|
| 61 |
+
|
| 62 |
+
- Gradio (Apache License 2.0)
|
| 63 |
+
Copyright (c) Gradio
|
| 64 |
+
https://github.com/gradio-app/gradio
|
| 65 |
+
|
| 66 |
+
Each dependency retains its original license. See individual packages
|
| 67 |
+
for their specific terms.
|
| 68 |
+
|
| 69 |
+
================================================================================
|
| 70 |
+
|
| 71 |
+
CONTACT
|
| 72 |
+
|
| 73 |
+
For questions about licensing or commercial use:
|
| 74 |
+
- Authors: Gregor Hubert, Max Koch "cronos3k" (GK -/a/- ghmk.de)
|
| 75 |
+
- Project: https://github.com/yourusername/character-forge
|
| 76 |
+
|
| 77 |
+
For commercial licensing inquiries or exceptions to AGPL terms,
|
| 78 |
+
please contact the authors.
|
| 79 |
+
|
| 80 |
+
================================================================================
|
README.md
CHANGED
|
@@ -1,20 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
forums](https://discuss.streamlit.io).
|
|
|
|
| 1 |
+
# 🔥 Character Forge
|
| 2 |
+
|
| 3 |
+
**Professional AI Image Generation with Automated Character Sheets**
|
| 4 |
+
|
| 5 |
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
| 6 |
+
[](https://huggingface.co/spaces)
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## What is Character Forge?
|
| 11 |
+
|
| 12 |
+
Character Forge is a powerful AI image generation platform featuring:
|
| 13 |
+
|
| 14 |
+
✨ **Character Sheet Generation**: Transform a single image into a complete multi-angle character sheet automatically
|
| 15 |
+
🎬 **Composition Assistant**: Smart multi-image composition with auto-generated prompts
|
| 16 |
+
📸 **Standard Interface**: Direct text-to-image and image-to-image generation
|
| 17 |
+
📚 **Library Management**: Save and reuse characters, backgrounds, and styles
|
| 18 |
+
🔌 **Multi-Backend Support**: Use Gemini API (cloud) or run locally with ComfyUI
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## 🚀 Quick Start
|
| 23 |
+
|
| 24 |
+
### Option 1: HuggingFace Spaces (Easiest)
|
| 25 |
+
|
| 26 |
+
Click the "Use this space" button on HuggingFace to deploy your own instance:
|
| 27 |
+
|
| 28 |
+
1. Fork/Duplicate this space
|
| 29 |
+
2. Go to Settings → Repository Secrets
|
| 30 |
+
3. Add your `GEMINI_API_KEY` (get one free at [Google AI Studio](https://aistudio.google.com/app/apikey))
|
| 31 |
+
4. Launch the space!
|
| 32 |
+
|
| 33 |
+
### Option 2: Local Installation
|
| 34 |
+
|
| 35 |
+
**Prerequisites:**
|
| 36 |
+
- Python 3.10 or higher
|
| 37 |
+
- Google Gemini API key (get it [here](https://aistudio.google.com/app/apikey))
|
| 38 |
+
|
| 39 |
+
**Installation:**
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
# Clone the repository
|
| 43 |
+
git clone https://github.com/yourusername/character-forge.git
|
| 44 |
+
cd character-forge
|
| 45 |
+
|
| 46 |
+
# Install dependencies
|
| 47 |
+
pip install -r requirements.txt
|
| 48 |
+
|
| 49 |
+
# Set your API key
|
| 50 |
+
export GEMINI_API_KEY="your-api-key-here" # Linux/Mac
|
| 51 |
+
# OR
|
| 52 |
+
set GEMINI_API_KEY=your-api-key-here # Windows
|
| 53 |
+
|
| 54 |
+
# Run the application
|
| 55 |
+
cd character_forge_image
|
| 56 |
+
streamlit run app.py
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Open your browser to `http://localhost:8501`
|
| 60 |
+
|
| 61 |
---
|
| 62 |
+
|
| 63 |
+
## 🎯 Key Features
|
| 64 |
+
|
| 65 |
+
### 1. Character Forge
|
| 66 |
+
|
| 67 |
+
Transform ONE image into a complete character sheet:
|
| 68 |
+
|
| 69 |
+
- **2 Facial Views**: Front portrait + Side profile
|
| 70 |
+
- **3 Body Views**: Front + Side + Rear full body shots
|
| 71 |
+
- **Auto-Composited**: Single image ready for consistent character generation
|
| 72 |
+
- **Fast**: ~2-3 minutes, fully automated
|
| 73 |
+
- **Cost Effective**: ~$0.15 total for complete sheet (with Gemini API)
|
| 74 |
+
|
| 75 |
+
Perfect for:
|
| 76 |
+
- Game development
|
| 77 |
+
- Animation pipelines
|
| 78 |
+
- Consistent character generation
|
| 79 |
+
- Multi-character scenes
|
| 80 |
+
|
| 81 |
+
### 2. Composition Assistant
|
| 82 |
+
|
| 83 |
+
Intelligently compose multiple images:
|
| 84 |
+
|
| 85 |
+
- Upload 1-3 images
|
| 86 |
+
- Auto-detect image types (subject, background, style reference)
|
| 87 |
+
- AI-generated composition prompts
|
| 88 |
+
- Professional results with minimal manual work
|
| 89 |
+
|
| 90 |
+
### 3. Standard Interface
|
| 91 |
+
|
| 92 |
+
Direct image generation:
|
| 93 |
+
|
| 94 |
+
- Text-to-image
|
| 95 |
+
- Image-to-image transformation
|
| 96 |
+
- Multiple aspect ratios (1:1, 16:9, 9:16, 3:2, 2:3, 3:4, 4:3, 4:5, 5:4, 21:9)
|
| 97 |
+
- Temperature control for creativity vs consistency
|
| 98 |
+
|
| 99 |
+
### 4. Library Management
|
| 100 |
+
|
| 101 |
+
Build your asset library:
|
| 102 |
+
|
| 103 |
+
- Save generated characters
|
| 104 |
+
- Organize backgrounds and environments
|
| 105 |
+
- Store style references
|
| 106 |
+
- Quick access for future compositions
|
| 107 |
+
|
| 108 |
---
|
| 109 |
|
| 110 |
+
## 🔧 Backend Options
|
| 111 |
+
|
| 112 |
+
### Gemini API (Cloud) - Default
|
| 113 |
+
|
| 114 |
+
**Best for getting started:**
|
| 115 |
+
- No local installation needed
|
| 116 |
+
- High quality results
|
| 117 |
+
- ~$0.03 per image
|
| 118 |
+
- Free tier available
|
| 119 |
+
|
| 120 |
+
**Setup:**
|
| 121 |
+
1. Get API key from [Google AI Studio](https://aistudio.google.com/app/apikey)
|
| 122 |
+
2. Set as environment variable or enter in UI
|
| 123 |
+
3. Start generating!
|
| 124 |
+
|
| 125 |
+
### ComfyUI (Local) - Advanced
|
| 126 |
+
|
| 127 |
+
**For power users:**
|
| 128 |
+
- Complete control
|
| 129 |
+
- No per-image costs
|
| 130 |
+
- GPU required
|
| 131 |
+
- Advanced workflows supported
|
| 132 |
|
| 133 |
+
**Setup:** See [COMFYUI_SETUP.md](docs/COMFYUI_SETUP.md)
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## 📖 Documentation
|
| 138 |
+
|
| 139 |
+
- **Quick Start Guide**: You're reading it!
|
| 140 |
+
- **Character Sheet Tutorial**: [docs/CHARACTER_SHEETS.md](docs/CHARACTER_SHEETS.md)
|
| 141 |
+
- **Composition Guide**: [docs/COMPOSITION_ASSISTANT.md](docs/COMPOSITION_ASSISTANT.md)
|
| 142 |
+
- **ComfyUI Integration**: [docs/COMFYUI_SETUP.md](docs/COMFYUI_SETUP.md)
|
| 143 |
+
- **API Reference**: [docs/API.md](docs/API.md)
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## 💡 Tips for Best Results
|
| 148 |
+
|
| 149 |
+
### Character Sheets
|
| 150 |
+
- Use clear, well-lit source images
|
| 151 |
+
- Front-facing photos work best
|
| 152 |
+
- Simple backgrounds preferred
|
| 153 |
+
- High resolution helps (but not required)
|
| 154 |
+
|
| 155 |
+
### Composition
|
| 156 |
+
- Generate subjects separately from backgrounds
|
| 157 |
+
- Use consistent lighting across images
|
| 158 |
+
- Be specific in your prompts
|
| 159 |
+
- Experiment with temperature settings
|
| 160 |
+
|
| 161 |
+
### General
|
| 162 |
+
- **Temperature 0.0-0.3**: Conservative, consistent
|
| 163 |
+
- **Temperature 0.4-0.6**: Balanced (recommended)
|
| 164 |
+
- **Temperature 0.7-1.0**: Creative, varied
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## 🤝 Contributing
|
| 169 |
+
|
| 170 |
+
Contributions are welcome! Please feel free to:
|
| 171 |
+
|
| 172 |
+
- Report bugs
|
| 173 |
+
- Suggest features
|
| 174 |
+
- Submit pull requests
|
| 175 |
+
- Improve documentation
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## 📝 License
|
| 180 |
+
|
| 181 |
+
GNU Affero General Public License v3.0 (AGPL-3.0)
|
| 182 |
+
|
| 183 |
+
**What this means:**
|
| 184 |
+
|
| 185 |
+
✓ **Free to use**: Personal, educational, and research use is completely free
|
| 186 |
+
✓ **Your content is yours**: Images and characters you generate belong to you
|
| 187 |
+
✓ **Modify freely**: You can modify and improve the software
|
| 188 |
+
✓ **Share improvements**: Modified versions must also be open source
|
| 189 |
+
|
| 190 |
+
✗ **No proprietary integration**: Cannot be integrated into closed-source commercial products
|
| 191 |
+
✗ **Network use = source sharing**: If you run this as a service, you must share your source code
|
| 192 |
+
|
| 193 |
+
**For commercial services or products**, any modifications or integrations must be released
|
| 194 |
+
under AGPL-3.0. This ensures the software remains free and open for everyone.
|
| 195 |
+
|
| 196 |
+
**For generated content**: Your images, characters, and creative outputs are yours to use
|
| 197 |
+
however you want - commercially or otherwise. The AGPL only applies to the software itself.
|
| 198 |
+
|
| 199 |
+
See [LICENSE](LICENSE) for full details and [NOTICE](NOTICE) for important information.
|
| 200 |
+
|
| 201 |
+
For commercial licensing inquiries or questions, please contact the authors.
|
| 202 |
+
## 🙏 Acknowledgments
|
| 203 |
+
|
| 204 |
+
- Google for the Gemini 2.5 Flash Image API
|
| 205 |
+
- Streamlit for the excellent UI framework
|
| 206 |
+
- The ComfyUI community
|
| 207 |
+
- All contributors and users
|
| 208 |
+
|
| 209 |
+
---
|
| 210 |
+
|
| 211 |
+
## 🔗 Links
|
| 212 |
+
|
| 213 |
+
- **Documentation**: [Full documentation](docs/)
|
| 214 |
+
- **Google Gemini API**: https://ai.google.dev/
|
| 215 |
+
- **Streamlit**: https://streamlit.app/
|
| 216 |
+
- **Report Issues**: [GitHub Issues](https://github.com/yourusername/character-forge/issues)
|
| 217 |
+
|
| 218 |
+
---
|
| 219 |
+
|
| 220 |
+
## ❓ FAQ
|
| 221 |
+
|
| 222 |
+
**Q: How much does it cost?**
|
| 223 |
+
A: With Gemini API: ~$0.03 per image. Free tier available for testing.
|
| 224 |
+
|
| 225 |
+
**Q: Do I need a GPU?**
|
| 226 |
+
A: Only for local ComfyUI backend. Gemini API runs in the cloud.
|
| 227 |
+
|
| 228 |
+
**Q: Can I use this commercially?**
|
| 229 |
+
A: Yes! Apache 2.0 license permits commercial use. Check individual backend terms.
|
| 230 |
+
|
| 231 |
+
**Q: Is my data private?**
|
| 232 |
+
A: With Gemini API, images are processed by Google. For complete privacy, use ComfyUI locally.
|
| 233 |
+
|
| 234 |
+
**Q: What image formats are supported?**
|
| 235 |
+
A: PNG, JPEG, WebP for input and output.
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
|
| 239 |
+
**Made with ❤️ by the Character Forge team**
|
|
|
READY_TO_DEPLOY.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ✅ CHARACTER FORGE - READY TO DEPLOY
|
| 2 |
+
|
| 3 |
+
## Status: **READY FOR HUGGINGFACE UPLOAD** 🚀
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Verification Complete
|
| 8 |
+
|
| 9 |
+
### ✅ Project Size: **2.5 MB**
|
| 10 |
+
Perfect for HuggingFace deployment!
|
| 11 |
+
|
| 12 |
+
### ✅ All Required Files Present:
|
| 13 |
+
- ✓ `Dockerfile` - HuggingFace configuration
|
| 14 |
+
- ✓ `LICENSE.txt` - GNU AGPL v3.0
|
| 15 |
+
- ✓ `NOTICE.txt` - User content ownership
|
| 16 |
+
- ✓ `README.md` - Documentation
|
| 17 |
+
- ✓ `requirements.txt` - Dependencies
|
| 18 |
+
- ✓ `.gitignore` - Protection against generated content
|
| 19 |
+
- ✓ `.streamlit/config.toml` - Streamlit settings
|
| 20 |
+
- ✓ `app.py` - HuggingFace entry point
|
| 21 |
+
- ✓ `character_forge_image/` - Main application
|
| 22 |
+
|
| 23 |
+
### ✅ All Generated Content Removed:
|
| 24 |
+
- ✓ No `outputs/` directory
|
| 25 |
+
- ✓ No test files or images
|
| 26 |
+
- ✓ No log files
|
| 27 |
+
- ✓ No cache directories
|
| 28 |
+
|
| 29 |
+
### ✅ License Headers Updated:
|
| 30 |
+
- ✓ GNU AGPL v3.0 throughout
|
| 31 |
+
- ✓ Copyright notices present
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 🚀 Deploy Now to HuggingFace
|
| 36 |
+
|
| 37 |
+
### Step 1: Create Your Space
|
| 38 |
+
Go to: **https://huggingface.co/new-space**
|
| 39 |
+
|
| 40 |
+
Fill in:
|
| 41 |
+
```
|
| 42 |
+
Owner: ghmk (or your username)
|
| 43 |
+
Space name: character_forge
|
| 44 |
+
License: agpl-3.0 ⚠️ IMPORTANT!
|
| 45 |
+
SDK: Docker
|
| 46 |
+
Template: Streamlit
|
| 47 |
+
Hardware: CPU Basic (Free)
|
| 48 |
+
Visibility: Public or Private
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
Click **"Create Space"**
|
| 52 |
+
|
| 53 |
+
### Step 2: Upload Files
|
| 54 |
+
|
| 55 |
+
**Option A: Git Upload (Recommended)**
|
| 56 |
+
```bash
|
| 57 |
+
cd D:/hu/character_forge
|
| 58 |
+
|
| 59 |
+
# Add HuggingFace remote
|
| 60 |
+
git remote add hf https://huggingface.co/spaces/ghmk/character_forge
|
| 61 |
+
|
| 62 |
+
# Push everything
|
| 63 |
+
git push hf main
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
**Option B: Web Upload**
|
| 67 |
+
1. Click "Files" tab in your new Space
|
| 68 |
+
2. Click "Add file" → "Upload files"
|
| 69 |
+
3. Drag and drop the **entire folder contents** from `D:\hu\character_forge`
|
| 70 |
+
4. Commit the upload
|
| 71 |
+
|
| 72 |
+
### Step 3: Add Your API Key Secret
|
| 73 |
+
|
| 74 |
+
**CRITICAL - App won't work without this!**
|
| 75 |
+
|
| 76 |
+
1. Go to your Space page
|
| 77 |
+
2. Click **"Settings"** tab
|
| 78 |
+
3. Click **"Repository secrets"**
|
| 79 |
+
4. Click **"New secret"**
|
| 80 |
+
5. Fill in:
|
| 81 |
+
```
|
| 82 |
+
Name: GEMINI_API_KEY
|
| 83 |
+
Value: [paste your actual Gemini API key]
|
| 84 |
+
```
|
| 85 |
+
6. Click **"Add"**
|
| 86 |
+
|
| 87 |
+
### Step 4: Wait for Build
|
| 88 |
+
|
| 89 |
+
HuggingFace will automatically:
|
| 90 |
+
- Detect the Dockerfile
|
| 91 |
+
- Build the container (3-5 minutes)
|
| 92 |
+
- Deploy the app
|
| 93 |
+
- Show you the logs
|
| 94 |
+
|
| 95 |
+
### Step 5: Test Your App! 🎉
|
| 96 |
+
|
| 97 |
+
Once deployed, visit:
|
| 98 |
+
```
|
| 99 |
+
https://huggingface.co/spaces/ghmk/character_forge
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Try:
|
| 103 |
+
1. Upload an image
|
| 104 |
+
2. Generate a character sheet
|
| 105 |
+
3. Check the output quality
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## 📊 What You're Deploying
|
| 110 |
+
|
| 111 |
+
### Application Features:
|
| 112 |
+
- **Character Forge**: Single image → complete character sheet
|
| 113 |
+
- **Composition Assistant**: Multi-image intelligent composition
|
| 114 |
+
- **Standard Interface**: Direct text/image-to-image generation
|
| 115 |
+
- **Library Management**: Save and reuse assets
|
| 116 |
+
|
| 117 |
+
### Technical Specs:
|
| 118 |
+
- **Framework**: Streamlit
|
| 119 |
+
- **Backend**: Gemini 2.5 Flash Image API
|
| 120 |
+
- **Hardware**: CPU only (no GPU needed!)
|
| 121 |
+
- **License**: GNU AGPL v3.0
|
| 122 |
+
- **Size**: 2.5 MB
|
| 123 |
+
|
| 124 |
+
### Cost:
|
| 125 |
+
- **Hosting**: FREE (HuggingFace CPU Basic)
|
| 126 |
+
- **API Usage**: FREE tier available (15 req/min)
|
| 127 |
+
- **Total**: $0 to start!
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## 🔒 License Info
|
| 132 |
+
|
| 133 |
+
Your deployment is licensed under **GNU AGPL v3.0**:
|
| 134 |
+
|
| 135 |
+
✓ **Free for everyone** to use personally
|
| 136 |
+
✓ **User content is theirs** - generated images belong to users
|
| 137 |
+
✓ **Must stay open source** - any modifications must be AGPL
|
| 138 |
+
✗ **No proprietary integration** - can't be closed-source
|
| 139 |
+
|
| 140 |
+
This is exactly what you wanted!
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## 🛠️ Troubleshooting
|
| 145 |
+
|
| 146 |
+
### "Invalid API Key" Error
|
| 147 |
+
- Check secret name is exactly: `GEMINI_API_KEY`
|
| 148 |
+
- Verify API key at: https://aistudio.google.com/app/apikey
|
| 149 |
+
- No extra spaces in the secret value
|
| 150 |
+
|
| 151 |
+
### "App Not Starting"
|
| 152 |
+
- Check build logs for errors
|
| 153 |
+
- Verify Dockerfile path is correct
|
| 154 |
+
- Make sure requirements.txt is present
|
| 155 |
+
|
| 156 |
+
### "File Not Found" Errors
|
| 157 |
+
- Check all files uploaded correctly
|
| 158 |
+
- Verify `character_forge_image/` folder structure intact
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## 📞 Support
|
| 163 |
+
|
| 164 |
+
- **Deployment Guide**: See `HUGGINGFACE_DEPLOYMENT.md`
|
| 165 |
+
- **Checklist**: See `DEPLOYMENT_CHECKLIST.md`
|
| 166 |
+
- **HuggingFace Docs**: https://huggingface.co/docs/hub/spaces
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## ✅ Final Checklist
|
| 171 |
+
|
| 172 |
+
Before you click upload, verify:
|
| 173 |
+
|
| 174 |
+
- [x] License is GNU AGPL v3.0
|
| 175 |
+
- [x] All generated content removed (2.5 MB clean)
|
| 176 |
+
- [x] .gitignore in place
|
| 177 |
+
- [x] Dockerfile configured for port 7860
|
| 178 |
+
- [x] LICENSE.txt present
|
| 179 |
+
- [x] NOTICE.txt explains user content ownership
|
| 180 |
+
- [x] README.md updated
|
| 181 |
+
- [x] requirements.txt present
|
| 182 |
+
- [x] You have GEMINI_API_KEY ready
|
| 183 |
+
|
| 184 |
+
**Everything is ready! Deploy now!** 🚀
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
**Location**: `D:\hu\character_forge`
|
| 189 |
+
**Status**: Clean, licensed, and ready to upload
|
| 190 |
+
**Next**: Go to https://huggingface.co/new-space and follow steps above!
|
RELEASE_NOTES.md
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Character Forge - Release Version
|
| 2 |
+
|
| 3 |
+
**Release Date**: November 2025
|
| 4 |
+
**Version**: 1.0.0
|
| 5 |
+
**License**: Apache 2.0
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 🎉 What's Included
|
| 10 |
+
|
| 11 |
+
This is the first official release of Character Forge, ready for:
|
| 12 |
+
- ✅ Local installation on Windows/Linux/Mac
|
| 13 |
+
- ✅ Deployment to HuggingFace Spaces
|
| 14 |
+
- ✅ Integration into your own projects
|
| 15 |
+
- ✅ Commercial use (Apache 2.0 licensed)
|
| 16 |
+
|
| 17 |
+
## 🚀 Key Features
|
| 18 |
+
|
| 19 |
+
### Character Forge
|
| 20 |
+
Transform a single image into a complete multi-angle character sheet:
|
| 21 |
+
- 2 facial views (front + side)
|
| 22 |
+
- 3 body views (front + side + rear)
|
| 23 |
+
- Automatic composition into single sheet
|
| 24 |
+
- ~2-3 minutes generation time
|
| 25 |
+
- Professional quality results
|
| 26 |
+
|
| 27 |
+
### Composition Assistant
|
| 28 |
+
Smart multi-image composition with AI-generated prompts:
|
| 29 |
+
- Upload 1-3 images
|
| 30 |
+
- Auto-detect image types
|
| 31 |
+
- Generate professional compositions
|
| 32 |
+
- Minimal manual work required
|
| 33 |
+
|
| 34 |
+
### Standard Interface
|
| 35 |
+
Direct text-to-image and image-to-image generation:
|
| 36 |
+
- 10 aspect ratios supported
|
| 37 |
+
- Temperature control (0.0-1.0)
|
| 38 |
+
- High-quality output
|
| 39 |
+
- Fast generation
|
| 40 |
+
|
| 41 |
+
### Library Management
|
| 42 |
+
Organize and reuse your assets:
|
| 43 |
+
- Save characters, backgrounds, styles
|
| 44 |
+
- Quick access for compositions
|
| 45 |
+
- Build your creative library
|
| 46 |
+
|
| 47 |
+
## 🔧 Backend Support
|
| 48 |
+
|
| 49 |
+
### Gemini API (Cloud) - Default
|
| 50 |
+
- No local installation needed
|
| 51 |
+
- High quality results
|
| 52 |
+
- ~$0.03 per image
|
| 53 |
+
- Free tier available (1,500 requests/day)
|
| 54 |
+
|
| 55 |
+
### ComfyUI (Local) - Optional
|
| 56 |
+
- Complete control
|
| 57 |
+
- No per-image costs
|
| 58 |
+
- GPU required
|
| 59 |
+
- Advanced workflows
|
| 60 |
+
|
| 61 |
+
## 📦 Installation
|
| 62 |
+
|
| 63 |
+
### Quick Start - Local
|
| 64 |
+
|
| 65 |
+
**Windows:**
|
| 66 |
+
```cmd
|
| 67 |
+
install.bat
|
| 68 |
+
set GEMINI_API_KEY=your-key-here
|
| 69 |
+
start.bat
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
**Linux/Mac:**
|
| 73 |
+
```bash
|
| 74 |
+
./install.sh
|
| 75 |
+
export GEMINI_API_KEY=your-key-here
|
| 76 |
+
./start.sh
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### HuggingFace Spaces
|
| 80 |
+
|
| 81 |
+
See [docs/HUGGINGFACE_DEPLOYMENT.md](docs/HUGGINGFACE_DEPLOYMENT.md)
|
| 82 |
+
|
| 83 |
+
## 📚 Documentation
|
| 84 |
+
|
| 85 |
+
Complete documentation included:
|
| 86 |
+
|
| 87 |
+
- **README.md** - Overview and quick start
|
| 88 |
+
- **docs/QUICK_START.md** - 5-minute getting started guide
|
| 89 |
+
- **docs/API_KEY_SETUP.md** - Complete API key setup instructions
|
| 90 |
+
- **docs/HUGGINGFACE_DEPLOYMENT.md** - HF Spaces deployment guide
|
| 91 |
+
- **LICENSE** - Apache 2.0 license
|
| 92 |
+
- **NOTICE** - Third-party licenses
|
| 93 |
+
|
| 94 |
+
## 🔒 Security & Privacy
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
### Your Privacy
|
| 98 |
+
- API keys are YOUR responsibility
|
| 99 |
+
- Use environment variables or HF Secrets
|
| 100 |
+
- Never commit keys to version control
|
| 101 |
+
- See docs/API_KEY_SETUP.md for best practices
|
| 102 |
+
|
| 103 |
+
## 🎯 Use Cases
|
| 104 |
+
|
| 105 |
+
### Game Development
|
| 106 |
+
- Character reference sheets for NPCs
|
| 107 |
+
- Environment backgrounds
|
| 108 |
+
- Texture variations
|
| 109 |
+
- Scene composition
|
| 110 |
+
|
| 111 |
+
### Animation
|
| 112 |
+
- Character sheets for consistency
|
| 113 |
+
- Background art generation
|
| 114 |
+
- Style references
|
| 115 |
+
- Storyboard composition
|
| 116 |
+
|
| 117 |
+
### Creative Projects
|
| 118 |
+
- Story illustration
|
| 119 |
+
- Concept art development
|
| 120 |
+
- Visual worldbuilding
|
| 121 |
+
- Character design iteration
|
| 122 |
+
|
| 123 |
+
## 💰 Cost Estimation
|
| 124 |
+
|
| 125 |
+
### Gemini API
|
| 126 |
+
- Single image: ~$0.03
|
| 127 |
+
- Character sheet: ~$0.15 (5 images)
|
| 128 |
+
- Composition: ~$0.03-0.06
|
| 129 |
+
- Free tier: 1,500 requests/day
|
| 130 |
+
|
| 131 |
+
### HuggingFace Spaces
|
| 132 |
+
- CPU Basic: Free
|
| 133 |
+
- CPU Upgrade: ~$0.03/hour (~$21/month 24/7)
|
| 134 |
+
- Persistent storage: Free up to 50GB
|
| 135 |
+
|
| 136 |
+
## 🛠️ Technical Stack
|
| 137 |
+
|
| 138 |
+
### Frontend
|
| 139 |
+
- **Streamlit**: Modern Python web framework
|
| 140 |
+
- **PIL/Pillow**: Image processing
|
| 141 |
+
- **NumPy**: Array operations
|
| 142 |
+
|
| 143 |
+
### Backend
|
| 144 |
+
- **Google Gemini API**: AI image generation
|
| 145 |
+
- **ComfyUI** (optional): Local generation
|
| 146 |
+
- **WebSocket**: ComfyUI communication
|
| 147 |
+
|
| 148 |
+
### Configuration
|
| 149 |
+
- **YAML**: Configuration files
|
| 150 |
+
- **TOML**: Streamlit configuration
|
| 151 |
+
- **Environment variables**: Secret management
|
| 152 |
+
|
| 153 |
+
## 📋 Requirements
|
| 154 |
+
|
| 155 |
+
### System Requirements
|
| 156 |
+
- **Python**: 3.10 or higher
|
| 157 |
+
- **RAM**: 2GB minimum, 4GB recommended
|
| 158 |
+
- **Storage**: 500MB for app, more for outputs
|
| 159 |
+
- **Network**: Internet connection (for Gemini API)
|
| 160 |
+
|
| 161 |
+
### Python Dependencies
|
| 162 |
+
See `requirements.txt` for complete list:
|
| 163 |
+
- streamlit >= 1.31.0
|
| 164 |
+
- google-genai >= 0.3.0
|
| 165 |
+
- Pillow >= 10.0.0
|
| 166 |
+
- numpy >= 1.24.0
|
| 167 |
+
- And more...
|
| 168 |
+
|
| 169 |
+
### Optional
|
| 170 |
+
- **GPU**: For local ComfyUI backend
|
| 171 |
+
- **Git**: For cloning and updates
|
| 172 |
+
|
| 173 |
+
## 🐛 Known Issues
|
| 174 |
+
|
| 175 |
+
### Current Limitations
|
| 176 |
+
- ComfyUI backend requires manual setup
|
| 177 |
+
- Maximum 3 images per composition
|
| 178 |
+
- 20MB max file size per image
|
| 179 |
+
- Rate limits on free tier (15/min, 1500/day)
|
| 180 |
+
|
| 181 |
+
### Planned Improvements
|
| 182 |
+
- More backend options
|
| 183 |
+
- Batch processing
|
| 184 |
+
- API endpoint for programmatic access
|
| 185 |
+
- Video generation support
|
| 186 |
+
- Improved caching
|
| 187 |
+
|
| 188 |
+
## 🤝 Contributing
|
| 189 |
+
|
| 190 |
+
We welcome contributions!
|
| 191 |
+
|
| 192 |
+
### How to Contribute
|
| 193 |
+
1. Fork the repository
|
| 194 |
+
2. Create a feature branch
|
| 195 |
+
3. Make your changes
|
| 196 |
+
4. Test thoroughly
|
| 197 |
+
5. Submit a pull request
|
| 198 |
+
|
| 199 |
+
### What We're Looking For
|
| 200 |
+
- Bug fixes
|
| 201 |
+
- Documentation improvements
|
| 202 |
+
- New features
|
| 203 |
+
- Performance optimizations
|
| 204 |
+
- UI/UX enhancements
|
| 205 |
+
|
| 206 |
+
### Code Style
|
| 207 |
+
- Follow PEP 8
|
| 208 |
+
- Add docstrings
|
| 209 |
+
- Include type hints
|
| 210 |
+
- Write tests where possible
|
| 211 |
+
|
| 212 |
+
## 📄 License
|
| 213 |
+
|
| 214 |
+
**Apache License 2.0**
|
| 215 |
+
|
| 216 |
+
- ✅ Commercial use allowed
|
| 217 |
+
- ✅ Modification allowed
|
| 218 |
+
- ✅ Distribution allowed
|
| 219 |
+
- ✅ Private use allowed
|
| 220 |
+
- ⚠️ Must include license and notice
|
| 221 |
+
- ⚠️ Changes must be documented
|
| 222 |
+
|
| 223 |
+
See [LICENSE](LICENSE) for full text.
|
| 224 |
+
|
| 225 |
+
## 🙏 Acknowledgments
|
| 226 |
+
|
| 227 |
+
This project builds on amazing open-source work:
|
| 228 |
+
|
| 229 |
+
- **Google Gemini API** - AI image generation
|
| 230 |
+
- **Streamlit** - Web framework
|
| 231 |
+
- **ComfyUI** - Local generation pipeline
|
| 232 |
+
- **Python community** - Countless libraries
|
| 233 |
+
|
| 234 |
+
Thank you to all contributors and users!
|
| 235 |
+
|
| 236 |
+
## 📞 Support
|
| 237 |
+
|
| 238 |
+
### Getting Help
|
| 239 |
+
- **Documentation**: Check `/docs` folder first
|
| 240 |
+
- **Issues**: Report bugs on GitHub
|
| 241 |
+
- **Discussions**: Ask questions on GitHub Discussions
|
| 242 |
+
- **API Help**: https://ai.google.dev/
|
| 243 |
+
|
| 244 |
+
### Reporting Bugs
|
| 245 |
+
Include:
|
| 246 |
+
- Description of the issue
|
| 247 |
+
- Steps to reproduce
|
| 248 |
+
- Expected vs actual behavior
|
| 249 |
+
- Screenshots if applicable
|
| 250 |
+
- System info (OS, Python version)
|
| 251 |
+
|
| 252 |
+
## 🗺️ Roadmap
|
| 253 |
+
|
| 254 |
+
### Version 1.1 (Planned)
|
| 255 |
+
- [ ] Batch processing
|
| 256 |
+
- [ ] More aspect ratios
|
| 257 |
+
- [ ] Improved caching
|
| 258 |
+
- [ ] Performance optimizations
|
| 259 |
+
|
| 260 |
+
### Version 1.2 (Planned)
|
| 261 |
+
- [ ] REST API endpoint
|
| 262 |
+
- [ ] Multiple backend support improvements
|
| 263 |
+
- [ ] Advanced composition features
|
| 264 |
+
- [ ] Better error handling
|
| 265 |
+
|
| 266 |
+
### Version 2.0 (Future)
|
| 267 |
+
- [ ] Video generation
|
| 268 |
+
- [ ] Animation support
|
| 269 |
+
- [ ] Advanced character persistence
|
| 270 |
+
- [ ] Web-based editor
|
| 271 |
+
|
| 272 |
+
## 🎓 Learning Resources
|
| 273 |
+
|
| 274 |
+
### For Beginners
|
| 275 |
+
- Start with docs/QUICK_START.md
|
| 276 |
+
- Follow the examples
|
| 277 |
+
- Experiment with different settings
|
| 278 |
+
- Join the community
|
| 279 |
+
|
| 280 |
+
### For Developers
|
| 281 |
+
- Check the code documentation
|
| 282 |
+
- Study the plugin system
|
| 283 |
+
- Review the architecture
|
| 284 |
+
- Contribute improvements
|
| 285 |
+
|
| 286 |
+
### For Artists
|
| 287 |
+
- Explore different prompts
|
| 288 |
+
- Experiment with temperature
|
| 289 |
+
- Build your style library
|
| 290 |
+
- Share your creations
|
| 291 |
+
|
| 292 |
+
## 📊 Project Stats
|
| 293 |
+
|
| 294 |
+
- **Lines of Code**: ~15,000+
|
| 295 |
+
- **Files**: 50+
|
| 296 |
+
- **Documentation**: 5 major guides
|
| 297 |
+
- **Dependencies**: 15+ packages
|
| 298 |
+
- **Supported Platforms**: Windows, Linux, Mac, HuggingFace
|
| 299 |
+
- **License**: Apache 2.0 (permissive)
|
| 300 |
+
|
| 301 |
+
## 🌟 Success Stories
|
| 302 |
+
|
| 303 |
+
We'd love to hear how you're using Character Forge!
|
| 304 |
+
|
| 305 |
+
Share your projects:
|
| 306 |
+
- Tag us on social media
|
| 307 |
+
- Post in GitHub Discussions
|
| 308 |
+
- Include in your project credits
|
| 309 |
+
|
| 310 |
+
## ⚡ Quick Tips
|
| 311 |
+
|
| 312 |
+
### For Best Results
|
| 313 |
+
1. Use clear, well-lit source images
|
| 314 |
+
2. Start with temperature 0.4
|
| 315 |
+
3. Be specific in prompts
|
| 316 |
+
4. Save good results to library
|
| 317 |
+
5. Experiment and iterate
|
| 318 |
+
|
| 319 |
+
### Performance
|
| 320 |
+
1. Close other applications
|
| 321 |
+
2. Use reasonable image sizes
|
| 322 |
+
3. Consider local backend for heavy use
|
| 323 |
+
4. Monitor API quotas
|
| 324 |
+
|
| 325 |
+
### Cost Optimization
|
| 326 |
+
1. Preview before generating
|
| 327 |
+
2. Use lower temperature (fewer retries)
|
| 328 |
+
3. Batch similar tasks
|
| 329 |
+
4. Reuse library assets
|
| 330 |
+
|
| 331 |
+
## 🎯 Next Steps
|
| 332 |
+
|
| 333 |
+
After installation:
|
| 334 |
+
|
| 335 |
+
1. ✅ Get your Gemini API key
|
| 336 |
+
2. ✅ Run the installation scripts
|
| 337 |
+
3. ✅ Generate your first image
|
| 338 |
+
4. ✅ Try the character sheet feature
|
| 339 |
+
5. ✅ Build your asset library
|
| 340 |
+
6. ✅ Integrate into your workflow
|
| 341 |
+
7. ✅ Share your creations!
|
| 342 |
+
|
| 343 |
+
---
|
| 344 |
+
|
| 345 |
+
**Thank you for using Character Forge! Happy creating! 🎨**
|
| 346 |
+
|
| 347 |
+
*For the latest updates, visit our GitHub repository.*
|
app.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Character Forge - AI Image Generation Platform
|
| 3 |
+
==============================================
|
| 4 |
+
|
| 5 |
+
License: GNU AGPL v3.0
|
| 6 |
+
Copyright (C) 2025 Gregor Hubert, Max Koch "cronos3k"
|
| 7 |
+
|
| 8 |
+
This program is free software: you can redistribute it and/or modify
|
| 9 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 10 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 11 |
+
(at your option) any later version.
|
| 12 |
+
|
| 13 |
+
This is the main entry point for Character Forge on HuggingFace Spaces.
|
| 14 |
+
For local installation, see README.md
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import sys
|
| 18 |
+
import os
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
# Add character_forge_image to Python path so imports work correctly
|
| 22 |
+
app_dir = Path(__file__).parent / "character_forge_image"
|
| 23 |
+
sys.path.insert(0, str(app_dir))
|
| 24 |
+
|
| 25 |
+
# Change to the app directory so relative imports work
|
| 26 |
+
os.chdir(str(app_dir))
|
| 27 |
+
|
| 28 |
+
# Now import and run the main app
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
# Import here after path setup
|
| 31 |
+
import streamlit as st
|
| 32 |
+
from app import main
|
| 33 |
+
main()
|
apply_agpl_license.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to apply GNU AGPL v3 license to Character Forge project.
|
| 4 |
+
This script downloads the official AGPL v3 license text and updates project files.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import urllib.request
|
| 8 |
+
import os
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
# Official GNU AGPL v3 license URL
|
| 12 |
+
AGPL_V3_URL = "https://www.gnu.org/licenses/agpl-3.0.txt"
|
| 13 |
+
|
| 14 |
+
# Copyright holders
|
| 15 |
+
COPYRIGHT_HOLDERS = "Gregor Hubert, Max Koch \"cronos3k\" (GK -/a/- ghmk.de)"
|
| 16 |
+
CURRENT_YEAR = datetime.now().year
|
| 17 |
+
|
| 18 |
+
def download_license():
|
| 19 |
+
"""Download the official GNU AGPL v3 license text."""
|
| 20 |
+
print("Downloading GNU AGPL v3 license from gnu.org...")
|
| 21 |
+
try:
|
| 22 |
+
with urllib.request.urlopen(AGPL_V3_URL) as response:
|
| 23 |
+
license_text = response.read().decode('utf-8')
|
| 24 |
+
print("[OK] License downloaded successfully")
|
| 25 |
+
return license_text
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"[ERROR] Error downloading license: {e}")
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
def create_license_file(license_text):
|
| 31 |
+
"""Create the LICENSE file with copyright notice."""
|
| 32 |
+
print("\nCreating LICENSE file...")
|
| 33 |
+
|
| 34 |
+
# Add copyright notice at the top
|
| 35 |
+
full_license = f"""Character Forge
|
| 36 |
+
Copyright (C) {CURRENT_YEAR} {COPYRIGHT_HOLDERS}
|
| 37 |
+
|
| 38 |
+
This program is free software: you can redistribute it and/or modify
|
| 39 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 40 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 41 |
+
(at your option) any later version.
|
| 42 |
+
|
| 43 |
+
This program is distributed in the hope that it will be useful,
|
| 44 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 45 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 46 |
+
GNU Affero General Public License for more details.
|
| 47 |
+
|
| 48 |
+
You should have received a copy of the GNU Affero General Public License
|
| 49 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 50 |
+
|
| 51 |
+
================================================================================
|
| 52 |
+
|
| 53 |
+
{license_text}
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
with open("LICENSE", "w", encoding="utf-8") as f:
|
| 58 |
+
f.write(full_license)
|
| 59 |
+
print("[OK] LICENSE file created")
|
| 60 |
+
return True
|
| 61 |
+
except Exception as e:
|
| 62 |
+
print(f"[ERROR] Error creating LICENSE file: {e}")
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
def create_notice_file():
|
| 66 |
+
"""Create updated NOTICE file for AGPL."""
|
| 67 |
+
print("\nCreating NOTICE file...")
|
| 68 |
+
|
| 69 |
+
notice_text = f"""Character Forge - AI Image Generation Platform
|
| 70 |
+
Copyright (C) {CURRENT_YEAR} {COPYRIGHT_HOLDERS}
|
| 71 |
+
|
| 72 |
+
This program is free software: you can redistribute it and/or modify
|
| 73 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 74 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 75 |
+
(at your option) any later version.
|
| 76 |
+
|
| 77 |
+
================================================================================
|
| 78 |
+
|
| 79 |
+
IMPORTANT: License for Generated Content
|
| 80 |
+
|
| 81 |
+
While this SOFTWARE is licensed under GNU AGPL v3, the OUTPUTS you create
|
| 82 |
+
with this software (images, character sheets, compositions) belong to YOU
|
| 83 |
+
and are NOT covered by the AGPL license.
|
| 84 |
+
|
| 85 |
+
You are free to:
|
| 86 |
+
- Use generated images for any purpose (personal or commercial)
|
| 87 |
+
- Sell your generated artwork
|
| 88 |
+
- Use characters in games, animations, or other projects
|
| 89 |
+
- Keep your generated content private or share it
|
| 90 |
+
|
| 91 |
+
The AGPL only applies to the software code itself, not your creative outputs.
|
| 92 |
+
|
| 93 |
+
================================================================================
|
| 94 |
+
|
| 95 |
+
COMMERCIAL USE RESTRICTIONS
|
| 96 |
+
|
| 97 |
+
The GNU AGPL v3 license means:
|
| 98 |
+
|
| 99 |
+
✓ FREE to use personally or for education
|
| 100 |
+
✓ FREE to modify and improve
|
| 101 |
+
✓ Your generated images/characters are YOURS to use commercially
|
| 102 |
+
|
| 103 |
+
✗ If you integrate this software into a commercial product or service,
|
| 104 |
+
you MUST release your entire codebase under AGPL v3
|
| 105 |
+
✗ You cannot create a proprietary/closed-source product using this code
|
| 106 |
+
✗ If you run this as a network service, you must provide source code
|
| 107 |
+
|
| 108 |
+
This prevents companies from taking this free software and building
|
| 109 |
+
commercial platforms without contributing back to the community.
|
| 110 |
+
|
| 111 |
+
================================================================================
|
| 112 |
+
|
| 113 |
+
THIRD-PARTY DEPENDENCIES
|
| 114 |
+
|
| 115 |
+
This project uses the following third-party software:
|
| 116 |
+
|
| 117 |
+
- Streamlit (Apache License 2.0)
|
| 118 |
+
Copyright (c) Streamlit Inc.
|
| 119 |
+
https://github.com/streamlit/streamlit
|
| 120 |
+
|
| 121 |
+
- google-generativeai (Apache License 2.0)
|
| 122 |
+
Copyright (c) Google LLC
|
| 123 |
+
https://github.com/google/generative-ai-python
|
| 124 |
+
|
| 125 |
+
- Pillow (PIL License)
|
| 126 |
+
Copyright (c) 1997-2011 by Secret Labs AB
|
| 127 |
+
Copyright (c) 1995-2011 by Fredrik Lundh
|
| 128 |
+
https://github.com/python-pillow/Pillow
|
| 129 |
+
|
| 130 |
+
- Gradio (Apache License 2.0)
|
| 131 |
+
Copyright (c) Gradio
|
| 132 |
+
https://github.com/gradio-app/gradio
|
| 133 |
+
|
| 134 |
+
Each dependency retains its original license. See individual packages
|
| 135 |
+
for their specific terms.
|
| 136 |
+
|
| 137 |
+
================================================================================
|
| 138 |
+
|
| 139 |
+
CONTACT
|
| 140 |
+
|
| 141 |
+
For questions about licensing or commercial use:
|
| 142 |
+
- Authors: {COPYRIGHT_HOLDERS}
|
| 143 |
+
- Project: https://github.com/yourusername/character-forge
|
| 144 |
+
|
| 145 |
+
For commercial licensing inquiries or exceptions to AGPL terms,
|
| 146 |
+
please contact the authors.
|
| 147 |
+
|
| 148 |
+
================================================================================
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
with open("NOTICE", "w", encoding="utf-8") as f:
|
| 153 |
+
f.write(notice_text)
|
| 154 |
+
print("[OK] NOTICE file created")
|
| 155 |
+
return True
|
| 156 |
+
except Exception as e:
|
| 157 |
+
print(f"[ERROR] Error creating NOTICE file: {e}")
|
| 158 |
+
return False
|
| 159 |
+
|
| 160 |
+
def update_readme():
|
| 161 |
+
"""Update README.md with new license information."""
|
| 162 |
+
print("\nUpdating README.md...")
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
with open("README.md", "r", encoding="utf-8") as f:
|
| 166 |
+
readme = f.read()
|
| 167 |
+
|
| 168 |
+
# Replace Apache license badge with AGPL badge
|
| 169 |
+
readme = readme.replace(
|
| 170 |
+
"[](https://opensource.org/licenses/Apache-2.0)",
|
| 171 |
+
"[](https://www.gnu.org/licenses/agpl-3.0)"
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# Update license section
|
| 175 |
+
license_section = """## 📝 License
|
| 176 |
+
|
| 177 |
+
GNU Affero General Public License v3.0 (AGPL-3.0)
|
| 178 |
+
|
| 179 |
+
**What this means:**
|
| 180 |
+
|
| 181 |
+
✓ **Free to use**: Personal, educational, and research use is completely free
|
| 182 |
+
✓ **Your content is yours**: Images and characters you generate belong to you
|
| 183 |
+
✓ **Modify freely**: You can modify and improve the software
|
| 184 |
+
✓ **Share improvements**: Modified versions must also be open source
|
| 185 |
+
|
| 186 |
+
✗ **No proprietary integration**: Cannot be integrated into closed-source commercial products
|
| 187 |
+
✗ **Network use = source sharing**: If you run this as a service, you must share your source code
|
| 188 |
+
|
| 189 |
+
**For commercial services or products**, any modifications or integrations must be released
|
| 190 |
+
under AGPL-3.0. This ensures the software remains free and open for everyone.
|
| 191 |
+
|
| 192 |
+
**For generated content**: Your images, characters, and creative outputs are yours to use
|
| 193 |
+
however you want - commercially or otherwise. The AGPL only applies to the software itself.
|
| 194 |
+
|
| 195 |
+
See [LICENSE](LICENSE) for full details and [NOTICE](NOTICE) for important information.
|
| 196 |
+
|
| 197 |
+
For commercial licensing inquiries or questions, please contact the authors."""
|
| 198 |
+
|
| 199 |
+
# Replace the license section
|
| 200 |
+
if "## 📝 License" in readme:
|
| 201 |
+
start = readme.find("## 📝 License")
|
| 202 |
+
end = readme.find("\n## ", start + 1)
|
| 203 |
+
if end == -1:
|
| 204 |
+
end = readme.find("\n---", start + 1)
|
| 205 |
+
|
| 206 |
+
readme = readme[:start] + license_section + readme[end:]
|
| 207 |
+
|
| 208 |
+
with open("README.md", "w", encoding="utf-8") as f:
|
| 209 |
+
f.write(readme)
|
| 210 |
+
|
| 211 |
+
print("[OK] README.md updated")
|
| 212 |
+
return True
|
| 213 |
+
except Exception as e:
|
| 214 |
+
print(f"[ERROR] Error updating README: {e}")
|
| 215 |
+
return False
|
| 216 |
+
|
| 217 |
+
def main():
|
| 218 |
+
"""Main function to apply AGPL license."""
|
| 219 |
+
print("=" * 70)
|
| 220 |
+
print("Character Forge - Applying GNU AGPL v3 License")
|
| 221 |
+
print("=" * 70)
|
| 222 |
+
|
| 223 |
+
# Download license
|
| 224 |
+
license_text = download_license()
|
| 225 |
+
if not license_text:
|
| 226 |
+
print("\n[ERROR] Failed to download license. Please check your internet connection.")
|
| 227 |
+
return False
|
| 228 |
+
|
| 229 |
+
# Create files
|
| 230 |
+
success = True
|
| 231 |
+
success &= create_license_file(license_text)
|
| 232 |
+
success &= create_notice_file()
|
| 233 |
+
success &= update_readme()
|
| 234 |
+
|
| 235 |
+
if success:
|
| 236 |
+
print("\n" + "=" * 70)
|
| 237 |
+
print("[SUCCESS] LICENSE SUCCESSFULLY APPLIED!")
|
| 238 |
+
print("=" * 70)
|
| 239 |
+
print("\nFiles updated:")
|
| 240 |
+
print(" - LICENSE (GNU AGPL v3)")
|
| 241 |
+
print(" - NOTICE (usage terms and clarifications)")
|
| 242 |
+
print(" - README.md (license badge and section)")
|
| 243 |
+
print("\nYour project is now licensed under GNU AGPL v3.")
|
| 244 |
+
print("Generated content (images/characters) belongs to users.")
|
| 245 |
+
print("Software code must remain open source if redistributed.")
|
| 246 |
+
return True
|
| 247 |
+
else:
|
| 248 |
+
print("\n[ERROR] Some files failed to update. Please check errors above.")
|
| 249 |
+
return False
|
| 250 |
+
|
| 251 |
+
if __name__ == "__main__":
|
| 252 |
+
main()
|
character_forge_image/api_server.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Character Forge API Server
|
| 3 |
+
===========================
|
| 4 |
+
|
| 5 |
+
REST API for automated character generation pipeline.
|
| 6 |
+
|
| 7 |
+
Workflow:
|
| 8 |
+
1. External tool → POST /api/v1/character/generate with description
|
| 9 |
+
2. Generate initial portrait with Nano Banana (Gemini)
|
| 10 |
+
3. Run Character Forge pipeline (6 stages)
|
| 11 |
+
4. Return all outputs (intermediates + composites)
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python api_server.py
|
| 15 |
+
|
| 16 |
+
Endpoints:
|
| 17 |
+
POST /api/v1/character/generate - Generate character from description
|
| 18 |
+
GET /api/v1/health - Health check
|
| 19 |
+
GET /api/v1/backends - Backend status
|
| 20 |
+
|
| 21 |
+
License: Apache 2.0
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
import sys
|
| 26 |
+
import json
|
| 27 |
+
import base64
|
| 28 |
+
import asyncio
|
| 29 |
+
import time
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import Optional, Dict, Any, List
|
| 32 |
+
from datetime import datetime
|
| 33 |
+
from io import BytesIO
|
| 34 |
+
from threading import Lock
|
| 35 |
+
|
| 36 |
+
# Add parent directory to path for imports
|
| 37 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 38 |
+
|
| 39 |
+
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
| 40 |
+
from fastapi.responses import JSONResponse
|
| 41 |
+
from pydantic import BaseModel, Field
|
| 42 |
+
from PIL import Image
|
| 43 |
+
import uvicorn
|
| 44 |
+
|
| 45 |
+
# Import Character Forge components
|
| 46 |
+
from services.character_forge_service import CharacterForgeService
|
| 47 |
+
from core import BackendRouter
|
| 48 |
+
from models.generation_request import GenerationRequest
|
| 49 |
+
from utils.logging_utils import get_logger
|
| 50 |
+
from config.settings import Settings
|
| 51 |
+
|
| 52 |
+
logger = get_logger(__name__)
|
| 53 |
+
|
| 54 |
+
# =============================================================================
|
| 55 |
+
# RATE LIMITING & SEQUENTIAL PROCESSING
|
| 56 |
+
# =============================================================================
|
| 57 |
+
|
| 58 |
+
# Global lock to ensure ONLY ONE character generates at a time
|
| 59 |
+
generation_lock = Lock()
|
| 60 |
+
|
| 61 |
+
# Rate limiting configuration
|
| 62 |
+
RATE_LIMIT_CONFIG = {
|
| 63 |
+
"gemini": {
|
| 64 |
+
"delay_between_requests": 3.0, # Minimum 3 seconds between API calls
|
| 65 |
+
"delay_after_stage": 2.0, # Wait 2 seconds after each stage completes
|
| 66 |
+
"delay_after_safety_block": 30.0, # Wait 30 seconds after safety filter trigger
|
| 67 |
+
"max_requests_per_minute": 15 # Conservative limit
|
| 68 |
+
},
|
| 69 |
+
"comfyui": {
|
| 70 |
+
"delay_between_requests": 1.0,
|
| 71 |
+
"delay_after_stage": 0.5,
|
| 72 |
+
"delay_after_safety_block": 5.0,
|
| 73 |
+
"max_requests_per_minute": 60
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
# Track last request time for rate limiting
|
| 78 |
+
last_request_time = {"gemini": 0, "comfyui": 0}
|
| 79 |
+
|
| 80 |
+
def enforce_rate_limit(backend: str, delay_type: str = "delay_between_requests"):
|
| 81 |
+
"""
|
| 82 |
+
Enforce rate limiting to avoid API bans/blacklisting.
|
| 83 |
+
|
| 84 |
+
CRITICAL: This prevents parallel processing and enforces delays
|
| 85 |
+
between requests to avoid hitting Google's rate limits.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
backend: Backend name ("gemini" or "comfyui")
|
| 89 |
+
delay_type: Type of delay to enforce
|
| 90 |
+
"""
|
| 91 |
+
global last_request_time
|
| 92 |
+
|
| 93 |
+
config = RATE_LIMIT_CONFIG.get(backend, RATE_LIMIT_CONFIG["gemini"])
|
| 94 |
+
required_delay = config.get(delay_type, 3.0)
|
| 95 |
+
|
| 96 |
+
# Calculate time since last request
|
| 97 |
+
time_since_last = time.time() - last_request_time.get(backend, 0)
|
| 98 |
+
|
| 99 |
+
# If not enough time has passed, wait
|
| 100 |
+
if time_since_last < required_delay:
|
| 101 |
+
wait_time = required_delay - time_since_last
|
| 102 |
+
logger.info(f"[RATE LIMIT] Waiting {wait_time:.1f}s before next {backend} API call...")
|
| 103 |
+
time.sleep(wait_time)
|
| 104 |
+
|
| 105 |
+
# Update last request time
|
| 106 |
+
last_request_time[backend] = time.time()
|
| 107 |
+
|
| 108 |
+
# =============================================================================
|
| 109 |
+
# API MODELS
|
| 110 |
+
# =============================================================================
|
| 111 |
+
|
| 112 |
+
class CharacterGenerationRequest(BaseModel):
|
| 113 |
+
"""Request model for character generation."""
|
| 114 |
+
|
| 115 |
+
character_id: str = Field(..., description="Unique identifier for the character")
|
| 116 |
+
description: str = Field(..., description="Text description of the character")
|
| 117 |
+
character_name: Optional[str] = Field(None, description="Character name (defaults to character_id)")
|
| 118 |
+
gender_term: Optional[str] = Field("character", description="Gender term: 'character', 'man', or 'woman'")
|
| 119 |
+
costume_description: Optional[str] = Field(None, description="Costume/clothing description")
|
| 120 |
+
backend: Optional[str] = Field(Settings.BACKEND_GEMINI, description="Backend to use for generation")
|
| 121 |
+
return_intermediates: bool = Field(True, description="Return intermediate stage images")
|
| 122 |
+
output_format: str = Field("base64", description="Output format: 'base64' or 'paths'")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class StageOutput(BaseModel):
|
| 126 |
+
"""Output model for a single generation stage."""
|
| 127 |
+
|
| 128 |
+
stage_name: str
|
| 129 |
+
status: str
|
| 130 |
+
image: Optional[str] = None # base64 encoded or path
|
| 131 |
+
prompt: Optional[str] = None
|
| 132 |
+
aspect_ratio: Optional[str] = None
|
| 133 |
+
temperature: Optional[float] = None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class CharacterGenerationResponse(BaseModel):
|
| 137 |
+
"""Response model for character generation."""
|
| 138 |
+
|
| 139 |
+
character_id: str
|
| 140 |
+
character_name: str
|
| 141 |
+
status: str # "completed", "failed", "processing"
|
| 142 |
+
message: str
|
| 143 |
+
timestamp: str
|
| 144 |
+
backend: str
|
| 145 |
+
|
| 146 |
+
# Generated files
|
| 147 |
+
initial_portrait: Optional[StageOutput] = None
|
| 148 |
+
stages: Optional[Dict[str, StageOutput]] = None
|
| 149 |
+
character_sheet: Optional[StageOutput] = None
|
| 150 |
+
|
| 151 |
+
# File paths (if output_format == "paths")
|
| 152 |
+
saved_to: Optional[str] = None
|
| 153 |
+
|
| 154 |
+
# Error info
|
| 155 |
+
error: Optional[str] = None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# =============================================================================
|
| 159 |
+
# API SERVER
|
| 160 |
+
# =============================================================================
|
| 161 |
+
|
| 162 |
+
app = FastAPI(
|
| 163 |
+
title="Character Forge API",
|
| 164 |
+
description="Automated character turnaround sheet generation pipeline",
|
| 165 |
+
version="1.0.0"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Initialize services
|
| 169 |
+
character_service = CharacterForgeService(api_key=os.environ.get("GEMINI_API_KEY"))
|
| 170 |
+
backend_router = BackendRouter(api_key=os.environ.get("GEMINI_API_KEY"))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# =============================================================================
|
| 174 |
+
# UTILITY FUNCTIONS
|
| 175 |
+
# =============================================================================
|
| 176 |
+
|
| 177 |
+
def image_to_base64(image: Image.Image) -> str:
|
| 178 |
+
"""Convert PIL Image to base64 string."""
|
| 179 |
+
buffered = BytesIO()
|
| 180 |
+
image.save(buffered, format="PNG")
|
| 181 |
+
img_str = base64.b64encode(buffered.getvalue()).decode()
|
| 182 |
+
return f"data:image/png;base64,{img_str}"
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def generate_initial_portrait(description: str, backend: str, max_retries: int = 3) -> tuple[Optional[Image.Image], str]:
|
| 186 |
+
"""
|
| 187 |
+
Generate initial frontal portrait using Nano Banana (Gemini).
|
| 188 |
+
|
| 189 |
+
CRITICAL: Includes rate limiting and retry logic for safety filters.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
description: Character description
|
| 193 |
+
backend: Backend to use
|
| 194 |
+
max_retries: Maximum retry attempts
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
Tuple of (image, status_message)
|
| 198 |
+
"""
|
| 199 |
+
logger.info(f"Generating initial portrait with {backend}...")
|
| 200 |
+
logger.info(f"Description: {description}")
|
| 201 |
+
|
| 202 |
+
# Create portrait-focused prompt
|
| 203 |
+
base_prompt = f"Generate a high-quality frontal portrait photograph focusing on the upper shoulders and face. {description}. Professional studio lighting, neutral grey background. The face should fill the vertical space. Photorealistic, detailed facial features."
|
| 204 |
+
|
| 205 |
+
prompt = base_prompt
|
| 206 |
+
|
| 207 |
+
for attempt in range(max_retries):
|
| 208 |
+
try:
|
| 209 |
+
# CRITICAL: Enforce rate limiting BEFORE making request
|
| 210 |
+
enforce_rate_limit(backend, "delay_between_requests")
|
| 211 |
+
|
| 212 |
+
logger.info(f"Initial portrait attempt {attempt + 1}/{max_retries}")
|
| 213 |
+
|
| 214 |
+
# Generate using backend router
|
| 215 |
+
request = GenerationRequest(
|
| 216 |
+
prompt=prompt,
|
| 217 |
+
backend=backend,
|
| 218 |
+
aspect_ratio="3:4", # Portrait format
|
| 219 |
+
temperature=0.4,
|
| 220 |
+
input_images=[]
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
result = backend_router.generate(request)
|
| 224 |
+
|
| 225 |
+
if result.success:
|
| 226 |
+
logger.info(f"Initial portrait generated successfully: {result.image.size}")
|
| 227 |
+
|
| 228 |
+
# CRITICAL: Wait after successful generation
|
| 229 |
+
enforce_rate_limit(backend, "delay_after_stage")
|
| 230 |
+
|
| 231 |
+
return result.image, "Success"
|
| 232 |
+
|
| 233 |
+
# Check for safety filter blocks
|
| 234 |
+
error_msg_upper = result.message.upper()
|
| 235 |
+
if any(keyword in error_msg_upper for keyword in [
|
| 236 |
+
'SAFETY', 'BLOCKED', 'PROHIBITED', 'CENSORED',
|
| 237 |
+
'POLICY', 'NSFW', 'INAPPROPRIATE', 'IMAGE_OTHER'
|
| 238 |
+
]):
|
| 239 |
+
logger.warning(f"⚠️ Safety filter triggered on attempt {attempt + 1}: {result.message}")
|
| 240 |
+
|
| 241 |
+
# CRITICAL: Long delay after safety block
|
| 242 |
+
enforce_rate_limit(backend, "delay_after_safety_block")
|
| 243 |
+
|
| 244 |
+
# Modify prompt to add clothing if not already present
|
| 245 |
+
if "wearing" not in prompt.lower() and "clothed" not in prompt.lower():
|
| 246 |
+
prompt = base_prompt + ", wearing appropriate casual clothing (shirt and pants)"
|
| 247 |
+
logger.info(f"Modified prompt to avoid safety filters: added clothing description")
|
| 248 |
+
|
| 249 |
+
# Continue to next retry
|
| 250 |
+
continue
|
| 251 |
+
|
| 252 |
+
# Other error - retry with delay
|
| 253 |
+
logger.warning(f"Attempt {attempt + 1} failed: {result.message}")
|
| 254 |
+
if attempt < max_retries - 1:
|
| 255 |
+
enforce_rate_limit(backend, "delay_after_safety_block")
|
| 256 |
+
|
| 257 |
+
except Exception as e:
|
| 258 |
+
logger.error(f"Attempt {attempt + 1} exception: {e}")
|
| 259 |
+
if attempt < max_retries - 1:
|
| 260 |
+
enforce_rate_limit(backend, "delay_after_safety_block")
|
| 261 |
+
|
| 262 |
+
return None, f"All {max_retries} attempts failed"
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def save_character_outputs(
|
| 266 |
+
character_id: str,
|
| 267 |
+
character_name: str,
|
| 268 |
+
initial_portrait: Image.Image,
|
| 269 |
+
metadata: Dict[str, Any]
|
| 270 |
+
) -> Path:
|
| 271 |
+
"""
|
| 272 |
+
Save all character outputs to organized directory structure.
|
| 273 |
+
|
| 274 |
+
Args:
|
| 275 |
+
character_id: Character ID
|
| 276 |
+
character_name: Character name
|
| 277 |
+
initial_portrait: Initial portrait image
|
| 278 |
+
metadata: Generation metadata with all stages
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
Path to output directory
|
| 282 |
+
"""
|
| 283 |
+
# Create output directory
|
| 284 |
+
output_dir = Settings.CHARACTER_SHEETS_DIR / character_id
|
| 285 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 286 |
+
|
| 287 |
+
# Save initial portrait
|
| 288 |
+
initial_path = output_dir / f"{character_id}_00_initial_portrait.png"
|
| 289 |
+
initial_portrait.save(initial_path, format="PNG")
|
| 290 |
+
logger.info(f"Saved initial portrait: {initial_path}")
|
| 291 |
+
|
| 292 |
+
# Save all stage outputs
|
| 293 |
+
stages = metadata.get("stages", {})
|
| 294 |
+
stage_num = 1
|
| 295 |
+
|
| 296 |
+
for stage_name, stage_data in stages.items():
|
| 297 |
+
if isinstance(stage_data, dict) and "image" in stage_data:
|
| 298 |
+
image = stage_data["image"]
|
| 299 |
+
if isinstance(image, Image.Image):
|
| 300 |
+
stage_path = output_dir / f"{character_id}_{stage_num:02d}_{stage_name}.png"
|
| 301 |
+
image.save(stage_path, format="PNG")
|
| 302 |
+
logger.info(f"Saved stage {stage_num}: {stage_path}")
|
| 303 |
+
stage_num += 1
|
| 304 |
+
|
| 305 |
+
# Save metadata
|
| 306 |
+
metadata_clean = {
|
| 307 |
+
"character_id": character_id,
|
| 308 |
+
"character_name": character_name,
|
| 309 |
+
"timestamp": metadata.get("timestamp"),
|
| 310 |
+
"backend": metadata.get("backend"),
|
| 311 |
+
"initial_image_type": metadata.get("initial_image_type"),
|
| 312 |
+
"costume_description": metadata.get("costume_description"),
|
| 313 |
+
"stages": {
|
| 314 |
+
name: {
|
| 315 |
+
"status": data.get("status"),
|
| 316 |
+
"prompt": data.get("prompt"),
|
| 317 |
+
"aspect_ratio": data.get("aspect_ratio"),
|
| 318 |
+
"temperature": data.get("temperature")
|
| 319 |
+
}
|
| 320 |
+
for name, data in stages.items()
|
| 321 |
+
if isinstance(data, dict)
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
metadata_path = output_dir / f"{character_id}_metadata.json"
|
| 326 |
+
with open(metadata_path, 'w') as f:
|
| 327 |
+
json.dump(metadata_clean, f, indent=2)
|
| 328 |
+
logger.info(f"Saved metadata: {metadata_path}")
|
| 329 |
+
|
| 330 |
+
return output_dir
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# =============================================================================
|
| 334 |
+
# API ENDPOINTS
|
| 335 |
+
# =============================================================================
|
| 336 |
+
|
| 337 |
+
@app.get("/")
|
| 338 |
+
async def root():
|
| 339 |
+
"""API root endpoint."""
|
| 340 |
+
return {
|
| 341 |
+
"name": "Character Forge API",
|
| 342 |
+
"version": "1.0.0",
|
| 343 |
+
"status": "operational",
|
| 344 |
+
"endpoints": {
|
| 345 |
+
"generate": "/api/v1/character/generate",
|
| 346 |
+
"health": "/api/v1/health",
|
| 347 |
+
"backends": "/api/v1/backends"
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
@app.get("/api/v1/health")
|
| 353 |
+
async def health_check():
|
| 354 |
+
"""Health check endpoint."""
|
| 355 |
+
return {
|
| 356 |
+
"status": "healthy",
|
| 357 |
+
"timestamp": datetime.now().isoformat(),
|
| 358 |
+
"service": "character-forge-api"
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@app.get("/api/v1/backends")
|
| 363 |
+
async def get_backends():
|
| 364 |
+
"""Get status of all available backends."""
|
| 365 |
+
status = character_service.get_all_backend_status()
|
| 366 |
+
return status
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@app.post("/api/v1/character/generate")
|
| 370 |
+
async def generate_character(
|
| 371 |
+
request: CharacterGenerationRequest,
|
| 372 |
+
background_tasks: BackgroundTasks
|
| 373 |
+
) -> CharacterGenerationResponse:
|
| 374 |
+
"""
|
| 375 |
+
Generate complete character turnaround sheet from description.
|
| 376 |
+
|
| 377 |
+
CRITICAL: This endpoint is STRICTLY SEQUENTIAL. Only ONE character
|
| 378 |
+
can be generated at a time to avoid Google API rate limits and bans.
|
| 379 |
+
|
| 380 |
+
The entire pipeline runs to completion before responding to ensure:
|
| 381 |
+
1. No parallel requests to Google API
|
| 382 |
+
2. Proper delays between API calls
|
| 383 |
+
3. All files saved before response
|
| 384 |
+
4. Rate limits respected
|
| 385 |
+
|
| 386 |
+
Pipeline:
|
| 387 |
+
1. Generate initial frontal portrait (Nano Banana)
|
| 388 |
+
2. Run Character Forge 6-stage pipeline (SEQUENTIAL)
|
| 389 |
+
3. Save all outputs to disk
|
| 390 |
+
4. Return response with file paths
|
| 391 |
+
|
| 392 |
+
Args:
|
| 393 |
+
request: Character generation request
|
| 394 |
+
|
| 395 |
+
Returns:
|
| 396 |
+
Character generation response with all outputs
|
| 397 |
+
"""
|
| 398 |
+
|
| 399 |
+
# CRITICAL: Acquire lock to ensure ONLY ONE generation at a time
|
| 400 |
+
# This prevents parallel processing which could trigger rate limits/bans
|
| 401 |
+
acquired = generation_lock.acquire(blocking=True, timeout=3600) # 1 hour max wait
|
| 402 |
+
|
| 403 |
+
if not acquired:
|
| 404 |
+
raise HTTPException(
|
| 405 |
+
status_code=503,
|
| 406 |
+
detail="Server busy - another character is being generated. Please retry in a few minutes."
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
try:
|
| 410 |
+
character_id = request.character_id
|
| 411 |
+
character_name = request.character_name or character_id
|
| 412 |
+
|
| 413 |
+
logger.info("="*80)
|
| 414 |
+
logger.info(f"API: SEQUENTIAL generation started for '{character_id}'")
|
| 415 |
+
logger.info(f"Description: {request.description}")
|
| 416 |
+
logger.info(f"Backend: {request.backend}")
|
| 417 |
+
logger.info(f"Lock acquired - no other generations can run")
|
| 418 |
+
logger.info("="*80)
|
| 419 |
+
|
| 420 |
+
# Stage 1: Generate initial portrait with Nano Banana
|
| 421 |
+
logger.info("[Stage 0/6] Generating initial portrait with Nano Banana...")
|
| 422 |
+
|
| 423 |
+
initial_portrait, status = generate_initial_portrait(
|
| 424 |
+
description=request.description,
|
| 425 |
+
backend=request.backend
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
if initial_portrait is None:
|
| 429 |
+
raise HTTPException(
|
| 430 |
+
status_code=500,
|
| 431 |
+
detail=f"Initial portrait generation failed: {status}"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
# Stage 2: Run Character Forge pipeline
|
| 435 |
+
# CRITICAL: This runs SEQUENTIALLY with built-in delays between stages
|
| 436 |
+
logger.info("[Stages 1-6] Running Character Forge pipeline SEQUENTIALLY...")
|
| 437 |
+
logger.info("Each stage waits for previous to complete + rate limit delay")
|
| 438 |
+
|
| 439 |
+
character_sheet, message, metadata = character_service.generate_character_sheet(
|
| 440 |
+
initial_image=initial_portrait,
|
| 441 |
+
initial_image_type="Face Only", # We generated a face portrait
|
| 442 |
+
character_name=character_name,
|
| 443 |
+
gender_term=request.gender_term,
|
| 444 |
+
costume_description=request.costume_description or "",
|
| 445 |
+
costume_image=None,
|
| 446 |
+
face_image=None,
|
| 447 |
+
body_image=None,
|
| 448 |
+
backend=request.backend,
|
| 449 |
+
progress_callback=None,
|
| 450 |
+
output_dir=None # We'll save manually
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
if character_sheet is None:
|
| 454 |
+
raise HTTPException(
|
| 455 |
+
status_code=500,
|
| 456 |
+
detail=f"Character forge pipeline failed: {message}"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
# CRITICAL: Wait before saving to ensure last API call is fully complete
|
| 460 |
+
logger.info("Pipeline complete - waiting before file save to ensure API cooldown...")
|
| 461 |
+
enforce_rate_limit(request.backend, "delay_after_stage")
|
| 462 |
+
|
| 463 |
+
# Save all outputs to disk
|
| 464 |
+
# CRITICAL: Files MUST be saved before returning response
|
| 465 |
+
logger.info("Saving outputs to disk...")
|
| 466 |
+
output_dir = save_character_outputs(
|
| 467 |
+
character_id=character_id,
|
| 468 |
+
character_name=character_name,
|
| 469 |
+
initial_portrait=initial_portrait,
|
| 470 |
+
metadata=metadata
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
logger.info(f"All files saved to: {output_dir}")
|
| 474 |
+
|
| 475 |
+
# CRITICAL: Final delay before releasing lock
|
| 476 |
+
# This ensures complete cooldown before next generation can start
|
| 477 |
+
logger.info("Files saved - final cooldown before releasing lock...")
|
| 478 |
+
enforce_rate_limit(request.backend, "delay_after_stage")
|
| 479 |
+
|
| 480 |
+
# Build response
|
| 481 |
+
response_data = {
|
| 482 |
+
"character_id": character_id,
|
| 483 |
+
"character_name": character_name,
|
| 484 |
+
"status": "completed",
|
| 485 |
+
"message": f"Character generated successfully! Saved to {output_dir}",
|
| 486 |
+
"timestamp": datetime.now().isoformat(),
|
| 487 |
+
"backend": request.backend,
|
| 488 |
+
"saved_to": str(output_dir)
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
# Add stage outputs if requested
|
| 492 |
+
if request.return_intermediates:
|
| 493 |
+
stages_output = {}
|
| 494 |
+
|
| 495 |
+
for stage_name, stage_data in metadata.get("stages", {}).items():
|
| 496 |
+
if isinstance(stage_data, dict):
|
| 497 |
+
stage_output = StageOutput(
|
| 498 |
+
stage_name=stage_name,
|
| 499 |
+
status=stage_data.get("status", "unknown"),
|
| 500 |
+
prompt=stage_data.get("prompt"),
|
| 501 |
+
aspect_ratio=stage_data.get("aspect_ratio"),
|
| 502 |
+
temperature=stage_data.get("temperature")
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# Add image if format is base64
|
| 506 |
+
if request.output_format == "base64" and "image" in stage_data:
|
| 507 |
+
image = stage_data["image"]
|
| 508 |
+
if isinstance(image, Image.Image):
|
| 509 |
+
stage_output.image = image_to_base64(image)
|
| 510 |
+
|
| 511 |
+
stages_output[stage_name] = stage_output
|
| 512 |
+
|
| 513 |
+
response_data["stages"] = stages_output
|
| 514 |
+
|
| 515 |
+
# Add initial portrait
|
| 516 |
+
response_data["initial_portrait"] = StageOutput(
|
| 517 |
+
stage_name="initial_portrait",
|
| 518 |
+
status="generated",
|
| 519 |
+
image=image_to_base64(initial_portrait) if request.output_format == "base64" else None,
|
| 520 |
+
prompt=request.description,
|
| 521 |
+
aspect_ratio="3:4",
|
| 522 |
+
temperature=0.4
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
# Add character sheet
|
| 526 |
+
response_data["character_sheet"] = StageOutput(
|
| 527 |
+
stage_name="character_sheet",
|
| 528 |
+
status="composited",
|
| 529 |
+
image=image_to_base64(character_sheet) if request.output_format == "base64" else None,
|
| 530 |
+
aspect_ratio="composite"
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
logger.info(f"API: Character generation completed successfully for '{character_id}'")
|
| 534 |
+
logger.info("="*80)
|
| 535 |
+
logger.info("SEQUENTIAL generation complete - releasing lock")
|
| 536 |
+
logger.info("="*80)
|
| 537 |
+
|
| 538 |
+
return CharacterGenerationResponse(**response_data)
|
| 539 |
+
|
| 540 |
+
except HTTPException:
|
| 541 |
+
raise
|
| 542 |
+
except Exception as e:
|
| 543 |
+
logger.exception(f"API: Character generation failed: {e}")
|
| 544 |
+
return CharacterGenerationResponse(
|
| 545 |
+
character_id=request.character_id,
|
| 546 |
+
character_name=request.character_name or request.character_id,
|
| 547 |
+
status="failed",
|
| 548 |
+
message="Generation failed",
|
| 549 |
+
timestamp=datetime.now().isoformat(),
|
| 550 |
+
backend=request.backend,
|
| 551 |
+
error=str(e)
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
finally:
|
| 555 |
+
# CRITICAL: ALWAYS release the lock, even if generation fails
|
| 556 |
+
# This ensures the server doesn't get stuck
|
| 557 |
+
generation_lock.release()
|
| 558 |
+
logger.info("Lock released - next generation can proceed")
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
# =============================================================================
|
| 562 |
+
# MAIN
|
| 563 |
+
# =============================================================================
|
| 564 |
+
|
| 565 |
+
def main():
|
| 566 |
+
"""Run API server."""
|
| 567 |
+
logger.info("="*80)
|
| 568 |
+
logger.info("CHARACTER FORGE API SERVER")
|
| 569 |
+
logger.info("="*80)
|
| 570 |
+
logger.info(f"Starting server on http://0.0.0.0:8000")
|
| 571 |
+
logger.info(f"Swagger docs: http://localhost:8000/docs")
|
| 572 |
+
logger.info(f"ReDoc: http://localhost:8000/redoc")
|
| 573 |
+
logger.info("="*80)
|
| 574 |
+
|
| 575 |
+
uvicorn.run(
|
| 576 |
+
app,
|
| 577 |
+
host="0.0.0.0",
|
| 578 |
+
port=8000,
|
| 579 |
+
log_level="info"
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
if __name__ == "__main__":
|
| 584 |
+
main()
|
character_forge_image/app.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Character Forge - Main Application Entry Point
|
| 3 |
+
===============================================
|
| 4 |
+
|
| 5 |
+
License: GNU AGPL v3.0
|
| 6 |
+
Copyright (C) 2025 Gregor Hubert, Max Koch "cronos3k"
|
| 7 |
+
|
| 8 |
+
This program is free software: you can redistribute it and/or modify
|
| 9 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 10 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 11 |
+
(at your option) any later version.
|
| 12 |
+
|
| 13 |
+
Main entry point for the Character Forge Streamlit application.
|
| 14 |
+
Run with: streamlit run app.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import streamlit as st
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
# Import configuration
|
| 21 |
+
from config.settings import Settings
|
| 22 |
+
|
| 23 |
+
# Import components
|
| 24 |
+
from ui.components.backend_selector import render_backend_selector
|
| 25 |
+
from ui.components.status_display import render_status_display
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def initialize_session_state():
|
| 29 |
+
"""
|
| 30 |
+
Initialize Streamlit session state with default values.
|
| 31 |
+
|
| 32 |
+
Session state is Streamlit's way of persisting data across reruns.
|
| 33 |
+
This function sets up all the global state our app needs.
|
| 34 |
+
"""
|
| 35 |
+
# Backend selection
|
| 36 |
+
if 'backend' not in st.session_state:
|
| 37 |
+
st.session_state.backend = "Gemini API (Cloud)"
|
| 38 |
+
|
| 39 |
+
# API keys
|
| 40 |
+
if 'gemini_api_key' not in st.session_state:
|
| 41 |
+
st.session_state.gemini_api_key = Settings.get_gemini_api_key()
|
| 42 |
+
|
| 43 |
+
# Output directory
|
| 44 |
+
if 'output_dir' not in st.session_state:
|
| 45 |
+
st.session_state.output_dir = Settings.OUTPUT_DIR
|
| 46 |
+
|
| 47 |
+
# Generation history
|
| 48 |
+
if 'history' not in st.session_state:
|
| 49 |
+
st.session_state.history = []
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def render_header():
|
| 53 |
+
"""Render the application header and global controls."""
|
| 54 |
+
st.set_page_config(
|
| 55 |
+
page_title="Character Forge - AI Image Generation",
|
| 56 |
+
page_icon="🔥",
|
| 57 |
+
layout="wide",
|
| 58 |
+
initial_sidebar_state="expanded"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
st.title("🔥 Character Forge - AI Image Generation")
|
| 62 |
+
st.markdown(
|
| 63 |
+
"""
|
| 64 |
+
**Professional character sheets and multi-image composition powered by AI**
|
| 65 |
+
|
| 66 |
+
*Supports Gemini API, OmniGen2, and ComfyUI backends*
|
| 67 |
+
"""
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
st.divider()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def render_global_backend_selector():
|
| 74 |
+
"""
|
| 75 |
+
Render the global backend selector that applies to all pages.
|
| 76 |
+
|
| 77 |
+
This is one of the key improvements over Gradio - no parameter drilling!
|
| 78 |
+
The backend selection is stored in session_state and accessible everywhere.
|
| 79 |
+
"""
|
| 80 |
+
st.subheader("🔧 Generation Backend")
|
| 81 |
+
|
| 82 |
+
col1, col2 = st.columns([2, 1])
|
| 83 |
+
|
| 84 |
+
with col1:
|
| 85 |
+
# Render the backend selector component
|
| 86 |
+
backend = render_backend_selector()
|
| 87 |
+
st.session_state.backend = backend
|
| 88 |
+
|
| 89 |
+
with col2:
|
| 90 |
+
# Render status display
|
| 91 |
+
if st.button("🔄 Refresh Status"):
|
| 92 |
+
st.rerun()
|
| 93 |
+
|
| 94 |
+
st.divider()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def render_sidebar():
|
| 98 |
+
"""Render the sidebar with navigation and settings."""
|
| 99 |
+
with st.sidebar:
|
| 100 |
+
st.image("https://via.placeholder.com/150x150.png?text=🍌", width=150)
|
| 101 |
+
|
| 102 |
+
st.markdown("## Navigation")
|
| 103 |
+
st.page_link("pages/01_🔥_Character_Forge.py", label="🔥 Character Forge")
|
| 104 |
+
st.page_link("pages/02_🎬_Composition_Assistant.py", label="🎬 Composition Assistant")
|
| 105 |
+
st.page_link("pages/03_📸_Standard_Interface.py", label="📸 Standard Interface")
|
| 106 |
+
|
| 107 |
+
st.divider()
|
| 108 |
+
|
| 109 |
+
st.markdown("## Settings")
|
| 110 |
+
|
| 111 |
+
# API Key input (for Gemini)
|
| 112 |
+
api_key = st.text_input(
|
| 113 |
+
"Gemini API Key",
|
| 114 |
+
value=st.session_state.gemini_api_key,
|
| 115 |
+
type="password",
|
| 116 |
+
help="Enter your Google Gemini API key. Required for Gemini backend."
|
| 117 |
+
)
|
| 118 |
+
if api_key != st.session_state.gemini_api_key:
|
| 119 |
+
st.session_state.gemini_api_key = api_key
|
| 120 |
+
|
| 121 |
+
# Output directory
|
| 122 |
+
st.text_input(
|
| 123 |
+
"Output Directory",
|
| 124 |
+
value=str(st.session_state.output_dir),
|
| 125 |
+
disabled=True,
|
| 126 |
+
help="All generated images are saved here"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
st.divider()
|
| 130 |
+
|
| 131 |
+
st.markdown("## About")
|
| 132 |
+
st.markdown(
|
| 133 |
+
"""
|
| 134 |
+
**Character Forge** v1.0.0
|
| 135 |
+
|
| 136 |
+
Multi-backend AI image generation with specialized tools for:
|
| 137 |
+
- Character sheet creation
|
| 138 |
+
- Multi-image composition
|
| 139 |
+
- Text/image-to-image generation
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
**License:**
|
| 143 |
+
Apache 2.0
|
| 144 |
+
|
| 145 |
+
**Get Started:**
|
| 146 |
+
- [Quick Start Guide](https://github.com/yourusername/character-forge)
|
| 147 |
+
- [Documentation](https://github.com/yourusername/character-forge/tree/main/docs)
|
| 148 |
+
"""
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def main():
|
| 153 |
+
"""Main application entry point."""
|
| 154 |
+
# Initialize session state
|
| 155 |
+
initialize_session_state()
|
| 156 |
+
|
| 157 |
+
# Render header
|
| 158 |
+
render_header()
|
| 159 |
+
|
| 160 |
+
# Render global backend selector
|
| 161 |
+
render_global_backend_selector()
|
| 162 |
+
|
| 163 |
+
# Render sidebar
|
| 164 |
+
render_sidebar()
|
| 165 |
+
|
| 166 |
+
# Main content area
|
| 167 |
+
st.info(
|
| 168 |
+
"""
|
| 169 |
+
👈 **Select a tool from the sidebar to get started:**
|
| 170 |
+
|
| 171 |
+
- **🔥 Character Forge**: Create multi-angle character sheets automatically
|
| 172 |
+
- **🎬 Composition Assistant**: Smart multi-image composition with auto-prompts
|
| 173 |
+
- **📸 Standard Interface**: Direct text-to-image and image-to-image generation
|
| 174 |
+
|
| 175 |
+
The backend selector above applies to all tools.
|
| 176 |
+
"""
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# Show recent generations
|
| 180 |
+
if st.session_state.history:
|
| 181 |
+
st.subheader("📸 Recent Generations")
|
| 182 |
+
cols = st.columns(4)
|
| 183 |
+
for idx, item in enumerate(st.session_state.history[-4:]):
|
| 184 |
+
with cols[idx]:
|
| 185 |
+
st.image(item['image'], caption=item['name'], use_container_width=True)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
if __name__ == "__main__":
|
| 189 |
+
main()
|
character_forge_image/config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Configuration package for Nano Banana Streamlit."""
|
character_forge_image/config/settings.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# settings.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Centralized configuration management for the entire Nano Banana Streamlit application. Single source of truth for all constants, paths, and environment-dependent settings.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Define all project paths (output directories, log files)
|
| 8 |
+
- Manage API keys and credentials
|
| 9 |
+
- Configure backend URLs and timeouts
|
| 10 |
+
- Define generation parameters (aspect ratios, temperatures)
|
| 11 |
+
- Provide character forge-specific settings
|
| 12 |
+
- Configure logging parameters
|
| 13 |
+
- Define UI constants
|
| 14 |
+
- Provide helper methods for common configuration tasks
|
| 15 |
+
|
| 16 |
+
## Dependencies
|
| 17 |
+
|
| 18 |
+
### Imports
|
| 19 |
+
- `os` - Environment variable access
|
| 20 |
+
- `pathlib.Path` - Path manipulation
|
| 21 |
+
- `typing.Optional` - Type hints
|
| 22 |
+
|
| 23 |
+
### Used By
|
| 24 |
+
- `app.py` - Gets API keys and output directory
|
| 25 |
+
- All services - Import constants for generation
|
| 26 |
+
- All UI components - Import display constants
|
| 27 |
+
- `core/backend_router.py` - Backend URLs and timeouts
|
| 28 |
+
- Logging setup - Log configuration
|
| 29 |
+
|
| 30 |
+
## Public Interface
|
| 31 |
+
|
| 32 |
+
### Class: `Settings`
|
| 33 |
+
|
| 34 |
+
Static class (no instantiation needed) providing configuration through class methods and properties.
|
| 35 |
+
|
| 36 |
+
#### **Project Paths**
|
| 37 |
+
```python
|
| 38 |
+
Settings.PROJECT_ROOT # Path to project root
|
| 39 |
+
Settings.OUTPUT_DIR # Main output directory
|
| 40 |
+
Settings.CHARACTER_SHEETS_DIR # Character sheet outputs
|
| 41 |
+
Settings.WARDROBE_CHANGES_DIR # Wardrobe change outputs
|
| 42 |
+
Settings.COMPOSITIONS_DIR # Composition outputs
|
| 43 |
+
Settings.STANDARD_DIR # Standard generation outputs
|
| 44 |
+
Settings.LOG_FILE # Log file path
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
#### **API Configuration**
|
| 48 |
+
```python
|
| 49 |
+
Settings.get_gemini_api_key() -> Optional[str]
|
| 50 |
+
# Returns Gemini API key from GEMINI_API_KEY env var
|
| 51 |
+
|
| 52 |
+
Settings.OMNIGEN2_BASE_URL # OmniGen2 server URL
|
| 53 |
+
Settings.BACKEND_TIMEOUT # Request timeout in seconds
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
#### **Generation Parameters**
|
| 57 |
+
```python
|
| 58 |
+
Settings.ASPECT_RATIOS # Dict of display name -> ratio
|
| 59 |
+
Settings.DEFAULT_ASPECT_RATIO # Default selection
|
| 60 |
+
Settings.DEFAULT_TEMPERATURE # Default temp (0.4)
|
| 61 |
+
Settings.MIN_TEMPERATURE # Min temp (0.0)
|
| 62 |
+
Settings.MAX_TEMPERATURE # Max temp (1.0)
|
| 63 |
+
Settings.TEMPERATURE_STEP # Slider step (0.05)
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
#### **Character Forge Settings**
|
| 67 |
+
```python
|
| 68 |
+
Settings.PORTRAIT_ASPECT_RATIO # "3:4" for portraits
|
| 69 |
+
Settings.BODY_ASPECT_RATIO # "9:16" for body shots
|
| 70 |
+
Settings.PORTRAIT_TEMPERATURE # 0.35 (lower for consistency)
|
| 71 |
+
Settings.BODY_TEMPERATURE # 0.5 (variety)
|
| 72 |
+
Settings.CHARACTER_SHEET_SPACING # 20px between rows
|
| 73 |
+
Settings.CHARACTER_SHEET_BACKGROUND # "#2C2C2C" dark gray
|
| 74 |
+
Settings.MAX_RETRIES # 3 attempts
|
| 75 |
+
Settings.RETRY_BASE_DELAY # 2s (exponential backoff)
|
| 76 |
+
Settings.RATE_LIMIT_DELAY_MIN # 2.0s
|
| 77 |
+
Settings.RATE_LIMIT_DELAY_MAX # 3.0s
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
#### **Logging Configuration**
|
| 81 |
+
```python
|
| 82 |
+
Settings.LOG_LEVEL # "INFO"
|
| 83 |
+
Settings.LOG_FORMAT # Log message format string
|
| 84 |
+
Settings.LOG_DATE_FORMAT # Date format string
|
| 85 |
+
Settings.LOG_MAX_BYTES # 10MB per file
|
| 86 |
+
Settings.LOG_BACKUP_COUNT # 5 backup files
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
#### **UI Configuration**
|
| 90 |
+
```python
|
| 91 |
+
Settings.MAX_IMAGE_UPLOAD_SIZE # 20MB
|
| 92 |
+
Settings.PREVIEW_IMAGE_WIDTH # 512px
|
| 93 |
+
Settings.MAX_HISTORY_ITEMS # 20 recent generations
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
#### **Composition Assistant Settings**
|
| 97 |
+
```python
|
| 98 |
+
Settings.IMAGE_TYPES # List of image type options
|
| 99 |
+
Settings.SHOT_TYPES # List of shot type options
|
| 100 |
+
Settings.CAMERA_ANGLES # List of camera angle options
|
| 101 |
+
Settings.LIGHTING_OPTIONS # List of lighting options
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
#### **Backend Types**
|
| 105 |
+
```python
|
| 106 |
+
Settings.BACKEND_GEMINI # "Gemini API (Cloud)"
|
| 107 |
+
Settings.BACKEND_OMNIGEN2 # "OmniGen2 (Local)"
|
| 108 |
+
Settings.AVAILABLE_BACKENDS # List of both
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
#### **Helper Methods**
|
| 112 |
+
```python
|
| 113 |
+
Settings.get_aspect_ratio_value(display_name: str) -> str
|
| 114 |
+
# "16:9 (1344x768)" → "16:9"
|
| 115 |
+
|
| 116 |
+
Settings.is_gemini_configured() -> bool
|
| 117 |
+
# Check if Gemini API key is set
|
| 118 |
+
|
| 119 |
+
Settings.validate_temperature(temperature: float) -> float
|
| 120 |
+
# Clamp temperature to valid range
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
## Usage Examples
|
| 124 |
+
|
| 125 |
+
### Get API Key
|
| 126 |
+
```python
|
| 127 |
+
from config.settings import Settings
|
| 128 |
+
|
| 129 |
+
api_key = Settings.get_gemini_api_key()
|
| 130 |
+
if api_key:
|
| 131 |
+
# Use API key
|
| 132 |
+
pass
|
| 133 |
+
else:
|
| 134 |
+
# Prompt user for API key
|
| 135 |
+
pass
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
### Save Generation Output
|
| 139 |
+
```python
|
| 140 |
+
from config.settings import Settings
|
| 141 |
+
from datetime import datetime
|
| 142 |
+
|
| 143 |
+
# Create filename
|
| 144 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 145 |
+
filename = f"character_{timestamp}.png"
|
| 146 |
+
|
| 147 |
+
# Save to appropriate directory
|
| 148 |
+
output_path = Settings.CHARACTER_SHEETS_DIR / filename
|
| 149 |
+
image.save(output_path)
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### Validate Temperature
|
| 153 |
+
```python
|
| 154 |
+
from config.settings import Settings
|
| 155 |
+
|
| 156 |
+
user_temp = 1.5 # Invalid - too high
|
| 157 |
+
valid_temp = Settings.validate_temperature(user_temp) # Returns 1.0
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
## Environment Variables
|
| 161 |
+
|
| 162 |
+
### Required
|
| 163 |
+
- **None** - App runs with defaults
|
| 164 |
+
|
| 165 |
+
### Optional
|
| 166 |
+
- `GEMINI_API_KEY` - Required only for Gemini backend
|
| 167 |
+
- Format: String API key from Google AI Studio
|
| 168 |
+
- How to get: https://aistudio.google.com/apikey
|
| 169 |
+
|
| 170 |
+
## Known Limitations
|
| 171 |
+
- API key only loaded from environment, not from config file
|
| 172 |
+
- No runtime configuration reloading (requires restart)
|
| 173 |
+
- No user-specific configuration (multi-user scenarios)
|
| 174 |
+
- Hardcoded OmniGen2 URL (not configurable without code change)
|
| 175 |
+
|
| 176 |
+
## Future Improvements
|
| 177 |
+
- Support config file (.env, .toml, .yaml)
|
| 178 |
+
- Add user profiles with different defaults
|
| 179 |
+
- Make all URLs/ports configurable
|
| 180 |
+
- Add validation for all settings on startup
|
| 181 |
+
- Add settings UI page for runtime changes
|
| 182 |
+
- Support multiple OmniGen2 instances (load balancing)
|
| 183 |
+
|
| 184 |
+
## Testing
|
| 185 |
+
- Verify all paths are created on import
|
| 186 |
+
- Test get_gemini_api_key() with and without env var
|
| 187 |
+
- Test validate_temperature() with various inputs
|
| 188 |
+
- Test get_aspect_ratio_value() with all display names
|
| 189 |
+
- Verify all constants are accessible
|
| 190 |
+
|
| 191 |
+
## Related Files
|
| 192 |
+
- `app.py` - Main app imports this first
|
| 193 |
+
- All services - Use constants from here
|
| 194 |
+
- All UI pages - Use UI constants from here
|
| 195 |
+
- `core/backend_router.py` - Uses backend URLs
|
| 196 |
+
- `utils/logging_utils.py` - Uses logging configuration
|
| 197 |
+
|
| 198 |
+
## Security Considerations
|
| 199 |
+
- API keys read from environment (not hardcoded)
|
| 200 |
+
- No API keys written to logs
|
| 201 |
+
- Output directory permissions should be restricted in production
|
| 202 |
+
|
| 203 |
+
## Change History
|
| 204 |
+
- 2025-10-23: Initial creation for Streamlit migration
|
| 205 |
+
- All constants from Gradio version migrated
|
| 206 |
+
- Added comprehensive documentation
|
| 207 |
+
- Added helper methods for common tasks
|
character_forge_image/config/settings.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application Settings and Configuration
|
| 3 |
+
=======================================
|
| 4 |
+
|
| 5 |
+
Centralized configuration management for Nano Banana Streamlit.
|
| 6 |
+
All environment variables, paths, and constants defined here.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings:
|
| 15 |
+
"""
|
| 16 |
+
Application-wide settings and configuration.
|
| 17 |
+
|
| 18 |
+
This class uses class methods and properties to provide
|
| 19 |
+
a simple interface for accessing configuration values.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
# =========================================================================
|
| 23 |
+
# PROJECT PATHS
|
| 24 |
+
# =========================================================================
|
| 25 |
+
|
| 26 |
+
# Root directory of the project
|
| 27 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 28 |
+
|
| 29 |
+
# Output directory for generated images
|
| 30 |
+
OUTPUT_DIR = PROJECT_ROOT / "outputs"
|
| 31 |
+
|
| 32 |
+
# Ensure output directory exists
|
| 33 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 34 |
+
|
| 35 |
+
# Subdirectories for different generation types
|
| 36 |
+
CHARACTER_SHEETS_DIR = OUTPUT_DIR / "character_sheets"
|
| 37 |
+
WARDROBE_CHANGES_DIR = OUTPUT_DIR / "wardrobe_changes"
|
| 38 |
+
COMPOSITIONS_DIR = OUTPUT_DIR / "compositions"
|
| 39 |
+
STANDARD_DIR = OUTPUT_DIR / "standard"
|
| 40 |
+
|
| 41 |
+
# Create all subdirectories
|
| 42 |
+
for directory in [CHARACTER_SHEETS_DIR, WARDROBE_CHANGES_DIR,
|
| 43 |
+
COMPOSITIONS_DIR, STANDARD_DIR]:
|
| 44 |
+
directory.mkdir(exist_ok=True)
|
| 45 |
+
|
| 46 |
+
# Log file
|
| 47 |
+
LOG_FILE = OUTPUT_DIR / "generation.log"
|
| 48 |
+
|
| 49 |
+
# =========================================================================
|
| 50 |
+
# API KEYS AND CREDENTIALS
|
| 51 |
+
# =========================================================================
|
| 52 |
+
|
| 53 |
+
@classmethod
|
| 54 |
+
def get_gemini_api_key(cls) -> Optional[str]:
|
| 55 |
+
"""
|
| 56 |
+
Get Gemini API key from environment variable.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
API key string if set, None otherwise
|
| 60 |
+
"""
|
| 61 |
+
return os.environ.get("GEMINI_API_KEY")
|
| 62 |
+
|
| 63 |
+
# =========================================================================
|
| 64 |
+
# BACKEND CONFIGURATION
|
| 65 |
+
# =========================================================================
|
| 66 |
+
|
| 67 |
+
# OmniGen2 server URL
|
| 68 |
+
OMNIGEN2_BASE_URL = "http://127.0.0.1:9002"
|
| 69 |
+
|
| 70 |
+
# ComfyUI server URL
|
| 71 |
+
COMFYUI_BASE_URL = "http://127.0.0.1:8188"
|
| 72 |
+
|
| 73 |
+
# Backend timeout (seconds)
|
| 74 |
+
# Set to None for local backends (ComfyUI) - no timeout needed, monitor logs instead
|
| 75 |
+
# For network/API backends (Gemini), keep a reasonable timeout
|
| 76 |
+
BACKEND_TIMEOUT = None # No timeout for local models (was 600s / 10 min)
|
| 77 |
+
|
| 78 |
+
# =========================================================================
|
| 79 |
+
# GENERATION PARAMETERS
|
| 80 |
+
# =========================================================================
|
| 81 |
+
|
| 82 |
+
# Available aspect ratios
|
| 83 |
+
ASPECT_RATIOS = {
|
| 84 |
+
"1:1 (1024x1024)": "1:1",
|
| 85 |
+
"16:9 (1344x768)": "16:9",
|
| 86 |
+
"9:16 (768x1344)": "9:16",
|
| 87 |
+
"3:2 (1248x832)": "3:2",
|
| 88 |
+
"2:3 (832x1248)": "2:3",
|
| 89 |
+
"3:4 (864x1184)": "3:4", # Character portraits (Gemini actual output)
|
| 90 |
+
"4:3 (1344x1008)": "4:3",
|
| 91 |
+
"4:5 (1024x1280)": "4:5",
|
| 92 |
+
"5:4 (1280x1024)": "5:4",
|
| 93 |
+
"21:9 (1536x640)": "21:9",
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# Default generation parameters
|
| 97 |
+
DEFAULT_ASPECT_RATIO = "16:9 (1344x768)"
|
| 98 |
+
DEFAULT_TEMPERATURE = 0.4
|
| 99 |
+
MIN_TEMPERATURE = 0.0
|
| 100 |
+
MAX_TEMPERATURE = 1.0
|
| 101 |
+
TEMPERATURE_STEP = 0.05
|
| 102 |
+
|
| 103 |
+
# =========================================================================
|
| 104 |
+
# CHARACTER FORGE SETTINGS
|
| 105 |
+
# =========================================================================
|
| 106 |
+
|
| 107 |
+
# Aspect ratios for character sheet views
|
| 108 |
+
PORTRAIT_ASPECT_RATIO = "3:4" # For face portraits (864x1184)
|
| 109 |
+
BODY_ASPECT_RATIO = "9:16" # For full body shots (768x1344)
|
| 110 |
+
|
| 111 |
+
# Generation temperatures for each stage
|
| 112 |
+
PORTRAIT_TEMPERATURE = 0.35 # Lower for consistency
|
| 113 |
+
BODY_TEMPERATURE = 0.5 # Slightly higher for variety
|
| 114 |
+
|
| 115 |
+
# Default negative prompts for ComfyUI qwen workflow
|
| 116 |
+
# These help steer generation away from common errors
|
| 117 |
+
DEFAULT_NEGATIVE_PROMPTS = {
|
| 118 |
+
"stage_0a": "blurry, low quality, distorted, deformed, disfigured, bad anatomy, extra limbs, missing limbs, multiple people",
|
| 119 |
+
"stage_0b": "different person, wrong face, altered features, different hair color, different eye color, low quality, blurry",
|
| 120 |
+
"stage_1": "side view, profile, back view, different person, different face, altered facial features, different clothing, wrong outfit, blurry, low quality",
|
| 121 |
+
"stage_2": "front view, facing camera, back view, three-quarter view, different person, different face, altered features, different clothing, wrong outfit, blurry, low quality",
|
| 122 |
+
"stage_3": "front view, facing camera, back view, rear view, different person, different face, different body, altered proportions, different clothing, costume change, nude, undressed, blurry, low quality, cut off, cropped, incomplete body",
|
| 123 |
+
"stage_4": "front view, facing camera, side view, profile view, face visible, different person, different body, different clothing, costume change, nude, undressed, blurry, low quality, cut off, cropped, incomplete body"
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
# Composition settings
|
| 127 |
+
CHARACTER_SHEET_SPACING = 20 # Pixels between rows
|
| 128 |
+
CHARACTER_SHEET_BACKGROUND = "#2C2C2C" # Dark gray
|
| 129 |
+
|
| 130 |
+
# Retry logic
|
| 131 |
+
MAX_RETRIES = 3
|
| 132 |
+
RETRY_BASE_DELAY = 2 # Seconds (exponential backoff)
|
| 133 |
+
RATE_LIMIT_DELAY_MIN = 2.0 # Seconds
|
| 134 |
+
RATE_LIMIT_DELAY_MAX = 3.0 # Seconds
|
| 135 |
+
|
| 136 |
+
# =========================================================================
|
| 137 |
+
# LOGGING CONFIGURATION
|
| 138 |
+
# =========================================================================
|
| 139 |
+
|
| 140 |
+
LOG_LEVEL = "INFO"
|
| 141 |
+
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 142 |
+
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
| 143 |
+
|
| 144 |
+
# Rotating file handler settings
|
| 145 |
+
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
| 146 |
+
LOG_BACKUP_COUNT = 5 # Keep 5 backup files
|
| 147 |
+
|
| 148 |
+
# =========================================================================
|
| 149 |
+
# UI CONFIGURATION
|
| 150 |
+
# =========================================================================
|
| 151 |
+
|
| 152 |
+
# Maximum image upload size (MB)
|
| 153 |
+
MAX_IMAGE_UPLOAD_SIZE = 20 # MB
|
| 154 |
+
|
| 155 |
+
# Image display size
|
| 156 |
+
PREVIEW_IMAGE_WIDTH = 512 # Pixels
|
| 157 |
+
|
| 158 |
+
# History display
|
| 159 |
+
MAX_HISTORY_ITEMS = 20
|
| 160 |
+
|
| 161 |
+
# =========================================================================
|
| 162 |
+
# COMPOSITION ASSISTANT SETTINGS
|
| 163 |
+
# =========================================================================
|
| 164 |
+
|
| 165 |
+
# Image type options
|
| 166 |
+
IMAGE_TYPES = [
|
| 167 |
+
"Subject/Character",
|
| 168 |
+
"Background/Environment",
|
| 169 |
+
"Style Reference",
|
| 170 |
+
"Product",
|
| 171 |
+
"Texture",
|
| 172 |
+
"Not Used"
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
# Shot type options
|
| 176 |
+
SHOT_TYPES = [
|
| 177 |
+
"close-up shot",
|
| 178 |
+
"medium shot",
|
| 179 |
+
"full body shot",
|
| 180 |
+
"wide shot",
|
| 181 |
+
"extreme close-up",
|
| 182 |
+
"establishing shot"
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
# Camera angle options
|
| 186 |
+
CAMERA_ANGLES = [
|
| 187 |
+
"eye-level perspective",
|
| 188 |
+
"low-angle perspective",
|
| 189 |
+
"high-angle perspective",
|
| 190 |
+
"bird's-eye view",
|
| 191 |
+
"Dutch angle (tilted)",
|
| 192 |
+
"over-the-shoulder"
|
| 193 |
+
]
|
| 194 |
+
|
| 195 |
+
# Lighting options
|
| 196 |
+
LIGHTING_OPTIONS = [
|
| 197 |
+
"Auto (match images)",
|
| 198 |
+
"natural daylight",
|
| 199 |
+
"soft studio lighting",
|
| 200 |
+
"dramatic side lighting",
|
| 201 |
+
"golden hour",
|
| 202 |
+
"blue hour",
|
| 203 |
+
"moody low-key",
|
| 204 |
+
"high-key bright",
|
| 205 |
+
"rim lighting"
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
# =========================================================================
|
| 209 |
+
# BACKEND TYPE ENUMERATION
|
| 210 |
+
# =========================================================================
|
| 211 |
+
|
| 212 |
+
BACKEND_GEMINI = "Gemini API (Cloud)"
|
| 213 |
+
BACKEND_OMNIGEN2 = "OmniGen2 (Local)"
|
| 214 |
+
BACKEND_COMFYUI = "ComfyUI (Local)"
|
| 215 |
+
BACKEND_FLUX_KREA = "FLUX Krea (Local)" # For initial portrait generation
|
| 216 |
+
BACKEND_FLUX_KONTEXT = "FLUX Kontext (Local)" # For perspective transformations
|
| 217 |
+
|
| 218 |
+
AVAILABLE_BACKENDS = [
|
| 219 |
+
BACKEND_GEMINI,
|
| 220 |
+
BACKEND_OMNIGEN2,
|
| 221 |
+
BACKEND_COMFYUI,
|
| 222 |
+
BACKEND_FLUX_KREA,
|
| 223 |
+
BACKEND_FLUX_KONTEXT
|
| 224 |
+
]
|
| 225 |
+
|
| 226 |
+
# =========================================================================
|
| 227 |
+
# HELPER METHODS
|
| 228 |
+
# =========================================================================
|
| 229 |
+
|
| 230 |
+
@classmethod
|
| 231 |
+
def get_aspect_ratio_value(cls, display_name: str) -> str:
|
| 232 |
+
"""
|
| 233 |
+
Convert display name to aspect ratio value.
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
display_name: Display name like "16:9 (1344x768)"
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
Aspect ratio value like "16:9"
|
| 240 |
+
"""
|
| 241 |
+
return cls.ASPECT_RATIOS.get(display_name, "1:1")
|
| 242 |
+
|
| 243 |
+
@classmethod
|
| 244 |
+
def is_gemini_configured(cls) -> bool:
|
| 245 |
+
"""Check if Gemini API is configured (API key set)."""
|
| 246 |
+
return cls.get_gemini_api_key() is not None
|
| 247 |
+
|
| 248 |
+
@classmethod
|
| 249 |
+
def validate_temperature(cls, temperature: float) -> float:
|
| 250 |
+
"""
|
| 251 |
+
Validate and clamp temperature to valid range.
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
temperature: Temperature value to validate
|
| 255 |
+
|
| 256 |
+
Returns:
|
| 257 |
+
Validated temperature within [MIN_TEMPERATURE, MAX_TEMPERATURE]
|
| 258 |
+
"""
|
| 259 |
+
return max(cls.MIN_TEMPERATURE, min(cls.MAX_TEMPERATURE, temperature))
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# Make settings instance available for import
|
| 263 |
+
settings = Settings()
|
character_forge_image/configs/training_dataset_config.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
character_forge_image/core/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core backend package for Nano Banana Streamlit."""
|
| 2 |
+
|
| 3 |
+
from core.backend_router import BackendRouter
|
| 4 |
+
|
| 5 |
+
__all__ = ['BackendRouter']
|
character_forge_image/core/backend_router.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend_router.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Unified routing interface for all image generation backends. Provides abstraction layer that allows the application to work with multiple backends (Gemini, OmniGen2) through a single consistent interface.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Route generation requests to appropriate backend
|
| 8 |
+
- Lazy initialization of backend clients (only create when needed)
|
| 9 |
+
- Normalize responses from different backends into GenerationResult
|
| 10 |
+
- Track generation time for all requests
|
| 11 |
+
- Health checking for all backends
|
| 12 |
+
- Centralize backend availability status
|
| 13 |
+
|
| 14 |
+
## Dependencies
|
| 15 |
+
- `config.settings.Settings` - Backend configuration (URLs, timeouts)
|
| 16 |
+
- `models.generation_request.GenerationRequest` - Request dataclass
|
| 17 |
+
- `models.generation_result.GenerationResult` - Result dataclass
|
| 18 |
+
- `core.gemini_client.GeminiClient` - Gemini API wrapper (lazy import)
|
| 19 |
+
- `core.omnigen2_client.OmniGen2Client` - OmniGen2 server wrapper (lazy import)
|
| 20 |
+
- `utils.logging_utils` - Logging
|
| 21 |
+
- `time` - Generation time tracking
|
| 22 |
+
|
| 23 |
+
## Public Interface
|
| 24 |
+
|
| 25 |
+
### `BackendRouter` class
|
| 26 |
+
|
| 27 |
+
**Constructor:**
|
| 28 |
+
```python
|
| 29 |
+
def __init__(self, api_key: Optional[str] = None)
|
| 30 |
+
```
|
| 31 |
+
- `api_key`: Optional Gemini API key (defaults to Settings.get_gemini_api_key())
|
| 32 |
+
- Initializes router with lazy client creation (clients created on first use)
|
| 33 |
+
|
| 34 |
+
**Key Methods:**
|
| 35 |
+
|
| 36 |
+
#### `generate(request: GenerationRequest) -> GenerationResult`
|
| 37 |
+
Main entry point for image generation.
|
| 38 |
+
|
| 39 |
+
**Parameters:**
|
| 40 |
+
- `request`: GenerationRequest object with prompt, backend, aspect_ratio, temperature, etc.
|
| 41 |
+
|
| 42 |
+
**Returns:**
|
| 43 |
+
- `GenerationResult` object with success status, image, message, generation_time
|
| 44 |
+
|
| 45 |
+
**Behavior:**
|
| 46 |
+
- Routes to appropriate backend based on `request.backend`
|
| 47 |
+
- Tracks total generation time
|
| 48 |
+
- Catches and normalizes exceptions
|
| 49 |
+
- Logs request details and results
|
| 50 |
+
|
| 51 |
+
**Usage:**
|
| 52 |
+
```python
|
| 53 |
+
router = BackendRouter(api_key="your-api-key")
|
| 54 |
+
request = GenerationRequest(
|
| 55 |
+
prompt="A magical forest",
|
| 56 |
+
backend="Gemini API (Cloud)",
|
| 57 |
+
aspect_ratio="16:9",
|
| 58 |
+
temperature=0.7
|
| 59 |
+
)
|
| 60 |
+
result = router.generate(request)
|
| 61 |
+
if result.success:
|
| 62 |
+
result.image.show()
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
#### `check_backend_health(backend: str) -> tuple[bool, str]`
|
| 66 |
+
Check if a specific backend is available.
|
| 67 |
+
|
| 68 |
+
**Parameters:**
|
| 69 |
+
- `backend`: Backend name ("Gemini API (Cloud)" or "OmniGen2 (Local)")
|
| 70 |
+
|
| 71 |
+
**Returns:**
|
| 72 |
+
- Tuple of (is_healthy: bool, status_message: str)
|
| 73 |
+
|
| 74 |
+
**Examples:**
|
| 75 |
+
```python
|
| 76 |
+
is_healthy, message = router.check_backend_health("Gemini API (Cloud)")
|
| 77 |
+
# Returns: (True, "Ready (API key set)") or (False, "API key not configured")
|
| 78 |
+
|
| 79 |
+
is_healthy, message = router.check_backend_health("OmniGen2 (Local)")
|
| 80 |
+
# Returns: (True, "Ready (Server running at http://127.0.0.1:8000)")
|
| 81 |
+
# or: (False, "Server not running")
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
#### `get_all_backend_status() -> dict`
|
| 85 |
+
Get health status of all configured backends.
|
| 86 |
+
|
| 87 |
+
**Returns:**
|
| 88 |
+
- Dictionary mapping backend name to status dict with "healthy" and "message" keys
|
| 89 |
+
|
| 90 |
+
**Usage:**
|
| 91 |
+
```python
|
| 92 |
+
status = router.get_all_backend_status()
|
| 93 |
+
# Returns:
|
| 94 |
+
# {
|
| 95 |
+
# "Gemini API (Cloud)": {"healthy": True, "message": "Ready (API key set)"},
|
| 96 |
+
# "OmniGen2 (Local)": {"healthy": False, "message": "Server not running"}
|
| 97 |
+
# }
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## Private Methods
|
| 101 |
+
|
| 102 |
+
### `_generate_with_gemini(request: GenerationRequest) -> GenerationResult`
|
| 103 |
+
Internal method to generate using Gemini API.
|
| 104 |
+
- Lazy initialization of GeminiClient
|
| 105 |
+
- Delegates to client.generate()
|
| 106 |
+
|
| 107 |
+
### `_generate_with_omnigen2(request: GenerationRequest) -> GenerationResult`
|
| 108 |
+
Internal method to generate using OmniGen2.
|
| 109 |
+
- Lazy initialization of OmniGen2Client
|
| 110 |
+
- Delegates to client.generate()
|
| 111 |
+
|
| 112 |
+
## Design Patterns
|
| 113 |
+
|
| 114 |
+
### Lazy Initialization
|
| 115 |
+
Clients are only created when first needed:
|
| 116 |
+
```python
|
| 117 |
+
if self._gemini_client is None:
|
| 118 |
+
from core.gemini_client import GeminiClient
|
| 119 |
+
self._gemini_client = GeminiClient(api_key=self.api_key)
|
| 120 |
+
```
|
| 121 |
+
This avoids importing/initializing unused backends.
|
| 122 |
+
|
| 123 |
+
### Strategy Pattern
|
| 124 |
+
Router implements strategy pattern - delegates to different backend implementations while maintaining consistent interface.
|
| 125 |
+
|
| 126 |
+
### Facade Pattern
|
| 127 |
+
Simplifies complex backend initialization and communication behind simple `generate()` method.
|
| 128 |
+
|
| 129 |
+
## Error Handling
|
| 130 |
+
- Unknown backends return error result
|
| 131 |
+
- Generation exceptions caught and converted to error results
|
| 132 |
+
- All errors logged with stack traces
|
| 133 |
+
- Health check exceptions caught and reported as unhealthy
|
| 134 |
+
|
| 135 |
+
## Usage in Application
|
| 136 |
+
|
| 137 |
+
### From Services:
|
| 138 |
+
```python
|
| 139 |
+
from core import BackendRouter
|
| 140 |
+
from models import GenerationRequest
|
| 141 |
+
|
| 142 |
+
router = BackendRouter()
|
| 143 |
+
request = GenerationRequest(
|
| 144 |
+
prompt=user_prompt,
|
| 145 |
+
backend=st.session_state.backend,
|
| 146 |
+
aspect_ratio=st.session_state.aspect_ratio,
|
| 147 |
+
temperature=st.session_state.temperature
|
| 148 |
+
)
|
| 149 |
+
result = router.generate(request)
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### Health Checks in UI:
|
| 153 |
+
```python
|
| 154 |
+
router = BackendRouter()
|
| 155 |
+
status = router.get_all_backend_status()
|
| 156 |
+
for backend, info in status.items():
|
| 157 |
+
if info['healthy']:
|
| 158 |
+
st.success(f"{backend}: {info['message']}")
|
| 159 |
+
else:
|
| 160 |
+
st.error(f"{backend}: {info['message']}")
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
## Related Files
|
| 164 |
+
- `core/gemini_client.py` - Gemini backend implementation
|
| 165 |
+
- `core/omnigen2_client.py` - OmniGen2 backend implementation
|
| 166 |
+
- `models/generation_request.py` - Request structure
|
| 167 |
+
- `models/generation_result.py` - Result structure
|
| 168 |
+
- `config/settings.py` - Backend configuration
|
| 169 |
+
- `services/generation_service.py` - Uses router for generation
|
character_forge_image/core/backend_router.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Backend Router
|
| 3 |
+
==============
|
| 4 |
+
|
| 5 |
+
Unified interface for all image generation backends.
|
| 6 |
+
Routes requests to appropriate backend and normalizes responses.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from config.settings import Settings
|
| 11 |
+
from models.generation_request import GenerationRequest
|
| 12 |
+
from models.generation_result import GenerationResult
|
| 13 |
+
from utils.logging_utils import get_logger
|
| 14 |
+
import time
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
logger = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class BackendRouter:
|
| 21 |
+
"""
|
| 22 |
+
Routes generation requests to appropriate backend.
|
| 23 |
+
|
| 24 |
+
This is the main abstraction layer that allows the application
|
| 25 |
+
to work with multiple backends through a unified interface.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 29 |
+
"""
|
| 30 |
+
Initialize backend router.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
api_key: API key for Gemini backend (optional)
|
| 34 |
+
"""
|
| 35 |
+
self.api_key = api_key or Settings.get_gemini_api_key()
|
| 36 |
+
self._gemini_client = None
|
| 37 |
+
self._omnigen2_client = None
|
| 38 |
+
self._comfyui_client = None
|
| 39 |
+
self._flux_client = None
|
| 40 |
+
|
| 41 |
+
logger.info("BackendRouter initialized")
|
| 42 |
+
|
| 43 |
+
def generate(self, request: GenerationRequest) -> GenerationResult:
|
| 44 |
+
"""
|
| 45 |
+
Generate image using specified backend.
|
| 46 |
+
|
| 47 |
+
Routes request to appropriate backend and normalizes response.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
request: GenerationRequest object
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
GenerationResult object
|
| 54 |
+
"""
|
| 55 |
+
start_time = time.time()
|
| 56 |
+
|
| 57 |
+
logger.info(f"Routing generation request to {request.backend}")
|
| 58 |
+
logger.debug(f"Request: {request}")
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
# Route to appropriate backend
|
| 62 |
+
if request.backend == Settings.BACKEND_GEMINI:
|
| 63 |
+
result = self._generate_with_gemini(request)
|
| 64 |
+
elif request.backend == Settings.BACKEND_OMNIGEN2:
|
| 65 |
+
result = self._generate_with_omnigen2(request)
|
| 66 |
+
elif request.backend == Settings.BACKEND_COMFYUI:
|
| 67 |
+
result = self._generate_with_comfyui(request)
|
| 68 |
+
elif request.backend == Settings.BACKEND_FLUX_KREA:
|
| 69 |
+
result = self._generate_with_flux_krea(request)
|
| 70 |
+
elif request.backend == Settings.BACKEND_FLUX_KONTEXT:
|
| 71 |
+
result = self._generate_with_flux_kontext(request)
|
| 72 |
+
else:
|
| 73 |
+
return GenerationResult.error_result(
|
| 74 |
+
message=f"Unknown backend: {request.backend}"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Add generation time if not already set
|
| 78 |
+
if result.generation_time is None:
|
| 79 |
+
result.generation_time = time.time() - start_time
|
| 80 |
+
|
| 81 |
+
logger.info(f"Generation completed: {result.message}")
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(f"Generation failed: {e}", exc_info=True)
|
| 86 |
+
return GenerationResult.error_result(
|
| 87 |
+
message=f"Generation error: {str(e)}"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def _generate_with_gemini(self, request: GenerationRequest) -> GenerationResult:
|
| 91 |
+
"""
|
| 92 |
+
Generate using Gemini API.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
request: GenerationRequest
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
GenerationResult
|
| 99 |
+
"""
|
| 100 |
+
# Lazy import and initialization
|
| 101 |
+
if self._gemini_client is None:
|
| 102 |
+
from core.gemini_client import GeminiClient
|
| 103 |
+
self._gemini_client = GeminiClient(api_key=self.api_key)
|
| 104 |
+
|
| 105 |
+
return self._gemini_client.generate(request)
|
| 106 |
+
|
| 107 |
+
def _generate_with_omnigen2(self, request: GenerationRequest) -> GenerationResult:
|
| 108 |
+
"""
|
| 109 |
+
Generate using OmniGen2 local server.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
request: GenerationRequest
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
GenerationResult
|
| 116 |
+
"""
|
| 117 |
+
# Lazy import and initialization
|
| 118 |
+
if self._omnigen2_client is None:
|
| 119 |
+
from core.omnigen2_client import OmniGen2Client
|
| 120 |
+
self._omnigen2_client = OmniGen2Client()
|
| 121 |
+
|
| 122 |
+
return self._omnigen2_client.generate(request)
|
| 123 |
+
|
| 124 |
+
def _generate_with_comfyui(self, request: GenerationRequest) -> GenerationResult:
|
| 125 |
+
"""
|
| 126 |
+
Generate using ComfyUI local server with qwen_image_edit_2509.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
request: GenerationRequest
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
GenerationResult
|
| 133 |
+
"""
|
| 134 |
+
import json
|
| 135 |
+
import random
|
| 136 |
+
from pathlib import Path
|
| 137 |
+
|
| 138 |
+
# Lazy import and initialization
|
| 139 |
+
if self._comfyui_client is None:
|
| 140 |
+
from core.comfyui_client import ComfyUIClient
|
| 141 |
+
self._comfyui_client = ComfyUIClient()
|
| 142 |
+
|
| 143 |
+
# Verify input images exist
|
| 144 |
+
if not request.input_images or len(request.input_images) == 0:
|
| 145 |
+
return GenerationResult.error_result(
|
| 146 |
+
message="ComfyUI/qwen_image_edit_2509 requires at least one input image"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
# Load workflow template
|
| 151 |
+
workflow_path = Path(__file__).parent.parent.parent / 'tools' / 'comfyui' / 'workflows' / 'qwen_image_edit.json'
|
| 152 |
+
if not workflow_path.exists():
|
| 153 |
+
return GenerationResult.error_result(
|
| 154 |
+
message=f"Workflow template not found at {workflow_path}"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
with open(workflow_path) as f:
|
| 158 |
+
workflow_template = json.load(f)
|
| 159 |
+
|
| 160 |
+
# Upload input images (up to 3 for better consistency)
|
| 161 |
+
uploaded_filenames = []
|
| 162 |
+
num_images = min(len(request.input_images), 3)
|
| 163 |
+
for i in range(num_images):
|
| 164 |
+
filename = self._comfyui_client.upload_image(request.input_images[i])
|
| 165 |
+
uploaded_filenames.append(filename)
|
| 166 |
+
logger.info(f"Uploaded input image {i+1}/{num_images}: {filename}")
|
| 167 |
+
|
| 168 |
+
# Parse dimensions from aspect ratio
|
| 169 |
+
# Match exact dimensions from Settings.ASPECT_RATIOS
|
| 170 |
+
width, height = 1024, 1024 # defaults
|
| 171 |
+
if request.aspect_ratio:
|
| 172 |
+
import re
|
| 173 |
+
# First, try to extract dimensions from format "16:9 (1344x768)"
|
| 174 |
+
match = re.search(r'(\d+)x(\d+)', request.aspect_ratio)
|
| 175 |
+
if match:
|
| 176 |
+
width = int(match.group(1))
|
| 177 |
+
height = int(match.group(2))
|
| 178 |
+
else:
|
| 179 |
+
# If just a ratio like "3:4" or "9:16", look it up in ASPECT_RATIOS
|
| 180 |
+
ratio_value = request.aspect_ratio
|
| 181 |
+
# Find the full string with dimensions in Settings.ASPECT_RATIOS
|
| 182 |
+
dimension_map = {
|
| 183 |
+
"1:1": (1024, 1024),
|
| 184 |
+
"16:9": (1344, 768),
|
| 185 |
+
"9:16": (768, 1344),
|
| 186 |
+
"3:2": (1248, 832),
|
| 187 |
+
"2:3": (832, 1248),
|
| 188 |
+
"3:4": (864, 1184), # Character portraits - MUST MATCH Gemini's actual output
|
| 189 |
+
"4:3": (1344, 1008),
|
| 190 |
+
"4:5": (1024, 1280),
|
| 191 |
+
"5:4": (1280, 1024),
|
| 192 |
+
"21:9": (1536, 640),
|
| 193 |
+
}
|
| 194 |
+
if ratio_value in dimension_map:
|
| 195 |
+
width, height = dimension_map[ratio_value]
|
| 196 |
+
logger.info(f"Mapped aspect ratio {ratio_value} to exact dimensions: {width}x{height}")
|
| 197 |
+
else:
|
| 198 |
+
logger.warning(f"Unknown aspect ratio {ratio_value}, using default 1024x1024")
|
| 199 |
+
|
| 200 |
+
# Update workflow parameters with multiple images
|
| 201 |
+
workflow = self._update_qwen_workflow(
|
| 202 |
+
workflow_template,
|
| 203 |
+
prompt=request.prompt,
|
| 204 |
+
negative_prompt=request.negative_prompt or "",
|
| 205 |
+
uploaded_filenames=uploaded_filenames,
|
| 206 |
+
seed=request.seed if request.seed else random.randint(1, 2**32 - 1),
|
| 207 |
+
width=width,
|
| 208 |
+
height=height
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Execute workflow
|
| 212 |
+
start_time = time.time()
|
| 213 |
+
images = self._comfyui_client.execute_workflow(workflow)
|
| 214 |
+
generation_time = time.time() - start_time
|
| 215 |
+
|
| 216 |
+
if not images or len(images) == 0:
|
| 217 |
+
return GenerationResult.error_result(
|
| 218 |
+
message="No images generated by ComfyUI"
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
# Return first generated image
|
| 222 |
+
return GenerationResult.success_result(
|
| 223 |
+
image=images[0],
|
| 224 |
+
message=f"Generated with ComfyUI/qwen using {num_images} reference image{'s' if num_images > 1 else ''} in {generation_time:.1f}s",
|
| 225 |
+
generation_time=generation_time
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"ComfyUI generation failed: {e}", exc_info=True)
|
| 230 |
+
return GenerationResult.error_result(
|
| 231 |
+
message=f"ComfyUI generation error: {str(e)}"
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
def _generate_with_flux_krea(self, request: GenerationRequest) -> GenerationResult:
|
| 235 |
+
"""
|
| 236 |
+
Generate using FLUX.1-Krea (text-to-image) via ComfyUI.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
request: GenerationRequest
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
GenerationResult
|
| 243 |
+
"""
|
| 244 |
+
# Lazy import and initialization
|
| 245 |
+
if self._flux_client is None:
|
| 246 |
+
from core.flux_client import FLUXClient
|
| 247 |
+
self._flux_client = FLUXClient()
|
| 248 |
+
|
| 249 |
+
return self._flux_client.generate_krea(request)
|
| 250 |
+
|
| 251 |
+
def _generate_with_flux_kontext(self, request: GenerationRequest) -> GenerationResult:
|
| 252 |
+
"""
|
| 253 |
+
Generate using FLUX.1-Kontext (image-to-image) via ComfyUI.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
request: GenerationRequest
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
GenerationResult
|
| 260 |
+
"""
|
| 261 |
+
# Lazy import and initialization
|
| 262 |
+
if self._flux_client is None:
|
| 263 |
+
from core.flux_client import FLUXClient
|
| 264 |
+
self._flux_client = FLUXClient()
|
| 265 |
+
|
| 266 |
+
return self._flux_client.generate_kontext(request)
|
| 267 |
+
|
| 268 |
+
def _update_qwen_workflow(
|
| 269 |
+
self,
|
| 270 |
+
workflow: dict,
|
| 271 |
+
prompt: str = None,
|
| 272 |
+
negative_prompt: str = None,
|
| 273 |
+
uploaded_filenames: list = None,
|
| 274 |
+
seed: int = None,
|
| 275 |
+
width: int = None,
|
| 276 |
+
height: int = None
|
| 277 |
+
) -> dict:
|
| 278 |
+
"""
|
| 279 |
+
Update workflow parameters for qwen_image_edit workflow with multiple images.
|
| 280 |
+
|
| 281 |
+
Node IDs for qwen_image_edit.json:
|
| 282 |
+
- 111: Positive prompt (TextEncodeQwenImageEditPlus)
|
| 283 |
+
- 110: Negative prompt (TextEncodeQwenImageEditPlus)
|
| 284 |
+
- 78: Load Image 1
|
| 285 |
+
- 79: Load Image 2 (created dynamically)
|
| 286 |
+
- 80: Load Image 3 (created dynamically)
|
| 287 |
+
- 3: KSampler (seed)
|
| 288 |
+
- 112: EmptySD3LatentImage (width, height)
|
| 289 |
+
"""
|
| 290 |
+
import json
|
| 291 |
+
import random
|
| 292 |
+
|
| 293 |
+
# Clone workflow to avoid modifying original
|
| 294 |
+
wf = json.loads(json.dumps(workflow))
|
| 295 |
+
|
| 296 |
+
# Update prompts with quality enhancements
|
| 297 |
+
if prompt is not None:
|
| 298 |
+
# Add "photorealistic" for quality enhancement
|
| 299 |
+
enhanced_prompt = f"{prompt}, photorealistic"
|
| 300 |
+
wf["111"]["inputs"]["prompt"] = enhanced_prompt
|
| 301 |
+
if negative_prompt is not None:
|
| 302 |
+
# Add extra negative terms for quality enhancement
|
| 303 |
+
enhanced_negative = f"{negative_prompt}, depth of field, motion blur, out of focus, blur, blurry"
|
| 304 |
+
wf["110"]["inputs"]["prompt"] = enhanced_negative
|
| 305 |
+
|
| 306 |
+
# Update input images
|
| 307 |
+
if uploaded_filenames and len(uploaded_filenames) > 0:
|
| 308 |
+
# Image 1 (node 78 already exists)
|
| 309 |
+
wf["78"]["inputs"]["image"] = uploaded_filenames[0]
|
| 310 |
+
|
| 311 |
+
# Image 2 (create LoadImage node 79 if provided)
|
| 312 |
+
if len(uploaded_filenames) >= 2:
|
| 313 |
+
wf["79"] = {
|
| 314 |
+
"inputs": {
|
| 315 |
+
"image": uploaded_filenames[1],
|
| 316 |
+
"upload": "image"
|
| 317 |
+
},
|
| 318 |
+
"class_type": "LoadImage",
|
| 319 |
+
"_meta": {"title": "Load Image 2"}
|
| 320 |
+
}
|
| 321 |
+
# Connect to encoding nodes
|
| 322 |
+
wf["111"]["inputs"]["image2"] = ["79", 0]
|
| 323 |
+
wf["110"]["inputs"]["image2"] = ["79", 0]
|
| 324 |
+
|
| 325 |
+
# Image 3 (create LoadImage node 80 if provided)
|
| 326 |
+
if len(uploaded_filenames) >= 3:
|
| 327 |
+
wf["80"] = {
|
| 328 |
+
"inputs": {
|
| 329 |
+
"image": uploaded_filenames[2],
|
| 330 |
+
"upload": "image"
|
| 331 |
+
},
|
| 332 |
+
"class_type": "LoadImage",
|
| 333 |
+
"_meta": {"title": "Load Image 3"}
|
| 334 |
+
}
|
| 335 |
+
# Connect to encoding nodes
|
| 336 |
+
wf["111"]["inputs"]["image3"] = ["80", 0]
|
| 337 |
+
wf["110"]["inputs"]["image3"] = ["80", 0]
|
| 338 |
+
|
| 339 |
+
# Update seed
|
| 340 |
+
if seed is not None:
|
| 341 |
+
wf["3"]["inputs"]["seed"] = seed
|
| 342 |
+
else:
|
| 343 |
+
wf["3"]["inputs"]["seed"] = random.randint(1, 2**32 - 1)
|
| 344 |
+
|
| 345 |
+
# Update dimensions ONLY in node 112 (EmptySD3LatentImage)
|
| 346 |
+
# This controls the EXACT output pixel dimensions - that's it!
|
| 347 |
+
if width is not None and height is not None:
|
| 348 |
+
logger.info(f"Setting EmptySD3LatentImage dimensions: {width}x{height}")
|
| 349 |
+
wf["112"]["inputs"]["width"] = width
|
| 350 |
+
wf["112"]["inputs"]["height"] = height
|
| 351 |
+
logger.info(f"Node 112 inputs after update: width={wf['112']['inputs']['width']}, height={wf['112']['inputs']['height']}")
|
| 352 |
+
|
| 353 |
+
return wf
|
| 354 |
+
|
| 355 |
+
def check_backend_health(self, backend: str) -> tuple[bool, str]:
|
| 356 |
+
"""
|
| 357 |
+
Check if a backend is available and healthy.
|
| 358 |
+
|
| 359 |
+
Args:
|
| 360 |
+
backend: Backend name to check
|
| 361 |
+
|
| 362 |
+
Returns:
|
| 363 |
+
Tuple of (is_healthy, status_message)
|
| 364 |
+
"""
|
| 365 |
+
try:
|
| 366 |
+
if backend == Settings.BACKEND_GEMINI:
|
| 367 |
+
if not self.api_key:
|
| 368 |
+
return False, "API key not configured"
|
| 369 |
+
# Gemini health check - just verify API key exists
|
| 370 |
+
return True, "Ready (API key set)"
|
| 371 |
+
|
| 372 |
+
elif backend == Settings.BACKEND_OMNIGEN2:
|
| 373 |
+
# Check OmniGen2 server
|
| 374 |
+
if self._omnigen2_client is None:
|
| 375 |
+
from core.omnigen2_client import OmniGen2Client
|
| 376 |
+
self._omnigen2_client = OmniGen2Client()
|
| 377 |
+
|
| 378 |
+
if self._omnigen2_client.is_healthy():
|
| 379 |
+
return True, f"Ready (Server running at {Settings.OMNIGEN2_BASE_URL})"
|
| 380 |
+
else:
|
| 381 |
+
return False, "Server not running"
|
| 382 |
+
|
| 383 |
+
elif backend == Settings.BACKEND_COMFYUI:
|
| 384 |
+
# Check ComfyUI server
|
| 385 |
+
if self._comfyui_client is None:
|
| 386 |
+
from core.comfyui_client import ComfyUIClient
|
| 387 |
+
self._comfyui_client = ComfyUIClient()
|
| 388 |
+
|
| 389 |
+
is_healthy, message = self._comfyui_client.health_check()
|
| 390 |
+
return is_healthy, message
|
| 391 |
+
|
| 392 |
+
elif backend == Settings.BACKEND_FLUX_KREA or backend == Settings.BACKEND_FLUX_KONTEXT:
|
| 393 |
+
# Check FLUX models availability
|
| 394 |
+
if self._flux_client is None:
|
| 395 |
+
from core.flux_client import FLUXClient
|
| 396 |
+
self._flux_client = FLUXClient()
|
| 397 |
+
|
| 398 |
+
is_healthy, message = self._flux_client.health_check()
|
| 399 |
+
return is_healthy, message
|
| 400 |
+
|
| 401 |
+
else:
|
| 402 |
+
return False, f"Unknown backend: {backend}"
|
| 403 |
+
|
| 404 |
+
except Exception as e:
|
| 405 |
+
logger.error(f"Health check failed for {backend}: {e}")
|
| 406 |
+
return False, f"Health check error: {str(e)}"
|
| 407 |
+
|
| 408 |
+
def get_all_backend_status(self) -> dict:
|
| 409 |
+
"""
|
| 410 |
+
Get health status of all backends.
|
| 411 |
+
|
| 412 |
+
Returns:
|
| 413 |
+
Dictionary mapping backend name to (is_healthy, message) tuple
|
| 414 |
+
"""
|
| 415 |
+
status = {}
|
| 416 |
+
for backend in Settings.AVAILABLE_BACKENDS:
|
| 417 |
+
is_healthy, message = self.check_backend_health(backend)
|
| 418 |
+
status[backend] = {
|
| 419 |
+
"healthy": is_healthy,
|
| 420 |
+
"message": message
|
| 421 |
+
}
|
| 422 |
+
return status
|
character_forge_image/core/comfyui_client.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ComfyUI API Client
|
| 3 |
+
==================
|
| 4 |
+
|
| 5 |
+
Client for communicating with ComfyUI server via REST API and WebSockets.
|
| 6 |
+
Handles workflow queuing, progress monitoring, and image retrieval.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import uuid
|
| 11 |
+
import time
|
| 12 |
+
import requests
|
| 13 |
+
import websocket
|
| 14 |
+
from typing import Dict, Any, Optional, List, Tuple
|
| 15 |
+
from PIL import Image
|
| 16 |
+
from io import BytesIO
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
from config.settings import Settings
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ComfyUIClient:
|
| 25 |
+
"""
|
| 26 |
+
Client for ComfyUI API.
|
| 27 |
+
|
| 28 |
+
Provides methods to:
|
| 29 |
+
- Check server health
|
| 30 |
+
- Queue workflows for execution
|
| 31 |
+
- Monitor execution progress via WebSocket
|
| 32 |
+
- Retrieve generated images
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, server_address: str = None):
|
| 36 |
+
"""
|
| 37 |
+
Initialize ComfyUI client.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
server_address: Server address (default: from settings)
|
| 41 |
+
"""
|
| 42 |
+
if server_address is None:
|
| 43 |
+
server_address = Settings.COMFYUI_BASE_URL.replace("http://", "")
|
| 44 |
+
|
| 45 |
+
self.server_address = server_address
|
| 46 |
+
self.client_id = str(uuid.uuid4())
|
| 47 |
+
self.timeout = Settings.BACKEND_TIMEOUT
|
| 48 |
+
|
| 49 |
+
logger.info(f"ComfyUI client initialized for {server_address}")
|
| 50 |
+
|
| 51 |
+
def health_check(self) -> Tuple[bool, str]:
|
| 52 |
+
"""
|
| 53 |
+
Check if ComfyUI server is running and accessible.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Tuple of (is_healthy: bool, message: str)
|
| 57 |
+
"""
|
| 58 |
+
try:
|
| 59 |
+
response = requests.get(
|
| 60 |
+
f"http://{self.server_address}/system_stats",
|
| 61 |
+
timeout=5
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
if response.status_code == 200:
|
| 65 |
+
stats = response.json()
|
| 66 |
+
return True, f"ComfyUI server online (RAM: {stats.get('system', {}).get('ram_used', 'N/A')} GB)"
|
| 67 |
+
else:
|
| 68 |
+
return False, f"Server responded with status {response.status_code}"
|
| 69 |
+
|
| 70 |
+
except requests.exceptions.ConnectionError:
|
| 71 |
+
return False, "Cannot connect to ComfyUI server (not running?)"
|
| 72 |
+
except requests.exceptions.Timeout:
|
| 73 |
+
return False, "Connection timeout"
|
| 74 |
+
except Exception as e:
|
| 75 |
+
return False, f"Health check failed: {str(e)}"
|
| 76 |
+
|
| 77 |
+
def queue_prompt(self, workflow: Dict[str, Any]) -> str:
|
| 78 |
+
"""
|
| 79 |
+
Queue a workflow for execution.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
workflow: ComfyUI workflow JSON (node graph)
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Prompt ID for tracking execution
|
| 86 |
+
|
| 87 |
+
Raises:
|
| 88 |
+
Exception: If queueing fails
|
| 89 |
+
"""
|
| 90 |
+
try:
|
| 91 |
+
prompt_request = {
|
| 92 |
+
"prompt": workflow,
|
| 93 |
+
"client_id": self.client_id
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
response = requests.post(
|
| 97 |
+
f"http://{self.server_address}/prompt",
|
| 98 |
+
json=prompt_request,
|
| 99 |
+
timeout=30
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
if response.status_code != 200:
|
| 103 |
+
raise Exception(f"Failed to queue prompt: HTTP {response.status_code}")
|
| 104 |
+
|
| 105 |
+
result = response.json()
|
| 106 |
+
prompt_id = result.get("prompt_id")
|
| 107 |
+
|
| 108 |
+
if not prompt_id:
|
| 109 |
+
raise Exception("No prompt_id in response")
|
| 110 |
+
|
| 111 |
+
logger.info(f"Workflow queued with ID: {prompt_id}")
|
| 112 |
+
return prompt_id
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.error(f"Failed to queue workflow: {e}")
|
| 116 |
+
raise
|
| 117 |
+
|
| 118 |
+
def get_history(self, prompt_id: str) -> Optional[Dict[str, Any]]:
|
| 119 |
+
"""
|
| 120 |
+
Get execution history for a prompt.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
prompt_id: ID of the prompt
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
History dict or None if not found
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
response = requests.get(
|
| 130 |
+
f"http://{self.server_address}/history/{prompt_id}",
|
| 131 |
+
timeout=10
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
if response.status_code == 200:
|
| 135 |
+
history = response.json()
|
| 136 |
+
return history.get(prompt_id)
|
| 137 |
+
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.error(f"Failed to get history: {e}")
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
def upload_image(self, image: Image.Image, filename: str = None) -> str:
|
| 145 |
+
"""
|
| 146 |
+
Upload image to ComfyUI input folder.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
image: PIL Image to upload
|
| 150 |
+
filename: Optional filename (auto-generated if None)
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
Filename for use in LoadImage node
|
| 154 |
+
|
| 155 |
+
Raises:
|
| 156 |
+
Exception: If upload fails
|
| 157 |
+
"""
|
| 158 |
+
try:
|
| 159 |
+
import io
|
| 160 |
+
import uuid
|
| 161 |
+
|
| 162 |
+
if filename is None:
|
| 163 |
+
filename = f"upload_{uuid.uuid4()}.png"
|
| 164 |
+
|
| 165 |
+
# Convert PIL Image to bytes
|
| 166 |
+
img_bytes = io.BytesIO()
|
| 167 |
+
image.save(img_bytes, format='PNG')
|
| 168 |
+
img_bytes.seek(0)
|
| 169 |
+
|
| 170 |
+
# Upload to ComfyUI
|
| 171 |
+
files = {
|
| 172 |
+
'image': (filename, img_bytes, 'image/png')
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
response = requests.post(
|
| 176 |
+
f"http://{self.server_address}/upload/image",
|
| 177 |
+
files=files,
|
| 178 |
+
timeout=30
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if response.status_code != 200:
|
| 182 |
+
raise Exception(f"Failed to upload image: HTTP {response.status_code}")
|
| 183 |
+
|
| 184 |
+
result = response.json()
|
| 185 |
+
uploaded_filename = result.get('name', filename)
|
| 186 |
+
logger.info(f"Uploaded image: {uploaded_filename}")
|
| 187 |
+
return uploaded_filename
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.error(f"Failed to upload image: {e}")
|
| 191 |
+
raise
|
| 192 |
+
|
| 193 |
+
def get_image(self, filename: str, subfolder: str = "", folder_type: str = "output") -> Image.Image:
|
| 194 |
+
"""
|
| 195 |
+
Download generated image from ComfyUI.
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
filename: Image filename
|
| 199 |
+
subfolder: Subfolder path
|
| 200 |
+
folder_type: Folder type (output, input, temp)
|
| 201 |
+
|
| 202 |
+
Returns:
|
| 203 |
+
PIL Image object
|
| 204 |
+
|
| 205 |
+
Raises:
|
| 206 |
+
Exception: If image retrieval fails
|
| 207 |
+
"""
|
| 208 |
+
try:
|
| 209 |
+
params = {
|
| 210 |
+
"filename": filename,
|
| 211 |
+
"subfolder": subfolder,
|
| 212 |
+
"type": folder_type
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
response = requests.get(
|
| 216 |
+
f"http://{self.server_address}/view",
|
| 217 |
+
params=params,
|
| 218 |
+
timeout=30
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
if response.status_code != 200:
|
| 222 |
+
raise Exception(f"Failed to get image: HTTP {response.status_code}")
|
| 223 |
+
|
| 224 |
+
image = Image.open(BytesIO(response.content))
|
| 225 |
+
logger.info(f"Retrieved image: {filename}")
|
| 226 |
+
return image
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"Failed to get image {filename}: {e}")
|
| 230 |
+
raise
|
| 231 |
+
|
| 232 |
+
def wait_for_completion(self, prompt_id: str, progress_callback=None) -> Dict[str, Any]:
|
| 233 |
+
"""
|
| 234 |
+
Wait for workflow execution to complete.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
prompt_id: ID of the prompt to monitor
|
| 238 |
+
progress_callback: Optional callback(node_id, progress_value, max_value)
|
| 239 |
+
|
| 240 |
+
Returns:
|
| 241 |
+
Execution history dict
|
| 242 |
+
|
| 243 |
+
Raises:
|
| 244 |
+
Exception: If execution fails or times out
|
| 245 |
+
"""
|
| 246 |
+
try:
|
| 247 |
+
ws_url = f"ws://{self.server_address}/ws?clientId={self.client_id}"
|
| 248 |
+
ws = websocket.create_connection(ws_url, timeout=10)
|
| 249 |
+
|
| 250 |
+
logger.info(f"WebSocket connected, monitoring prompt {prompt_id}")
|
| 251 |
+
|
| 252 |
+
start_time = time.time()
|
| 253 |
+
last_progress = {}
|
| 254 |
+
|
| 255 |
+
while True:
|
| 256 |
+
# Check timeout (only if timeout is set - None means no timeout for local models)
|
| 257 |
+
if self.timeout and time.time() - start_time > self.timeout:
|
| 258 |
+
ws.close()
|
| 259 |
+
raise TimeoutError(f"Execution timeout after {self.timeout}s")
|
| 260 |
+
|
| 261 |
+
# Receive message from WebSocket
|
| 262 |
+
try:
|
| 263 |
+
message = ws.recv()
|
| 264 |
+
if not message:
|
| 265 |
+
continue
|
| 266 |
+
|
| 267 |
+
data = json.loads(message)
|
| 268 |
+
msg_type = data.get("type")
|
| 269 |
+
|
| 270 |
+
# Progress update
|
| 271 |
+
if msg_type == "progress" and progress_callback:
|
| 272 |
+
progress_data = data.get("data", {})
|
| 273 |
+
node_id = progress_data.get("node")
|
| 274 |
+
value = progress_data.get("value", 0)
|
| 275 |
+
max_val = progress_data.get("max", 100)
|
| 276 |
+
|
| 277 |
+
# Only call callback if progress changed
|
| 278 |
+
key = f"{node_id}_{value}"
|
| 279 |
+
if key not in last_progress:
|
| 280 |
+
progress_callback(node_id, value, max_val)
|
| 281 |
+
last_progress[key] = True
|
| 282 |
+
|
| 283 |
+
# Execution complete
|
| 284 |
+
elif msg_type == "executing":
|
| 285 |
+
executing_data = data.get("data", {})
|
| 286 |
+
if executing_data.get("prompt_id") == prompt_id:
|
| 287 |
+
node = executing_data.get("node")
|
| 288 |
+
|
| 289 |
+
# null node means execution finished
|
| 290 |
+
if node is None:
|
| 291 |
+
logger.info(f"Execution complete for {prompt_id}")
|
| 292 |
+
ws.close()
|
| 293 |
+
|
| 294 |
+
# Get final history
|
| 295 |
+
history = self.get_history(prompt_id)
|
| 296 |
+
if not history:
|
| 297 |
+
raise Exception("No history found after completion")
|
| 298 |
+
|
| 299 |
+
return history
|
| 300 |
+
|
| 301 |
+
except websocket.WebSocketTimeoutException:
|
| 302 |
+
# Timeout on recv is okay, just continue
|
| 303 |
+
continue
|
| 304 |
+
|
| 305 |
+
except Exception as e:
|
| 306 |
+
logger.error(f"Error waiting for completion: {e}")
|
| 307 |
+
raise
|
| 308 |
+
|
| 309 |
+
def execute_workflow(
|
| 310 |
+
self,
|
| 311 |
+
workflow: Dict[str, Any],
|
| 312 |
+
progress_callback=None
|
| 313 |
+
) -> List[Image.Image]:
|
| 314 |
+
"""
|
| 315 |
+
Execute a workflow and return generated images.
|
| 316 |
+
|
| 317 |
+
Args:
|
| 318 |
+
workflow: ComfyUI workflow JSON
|
| 319 |
+
progress_callback: Optional progress callback
|
| 320 |
+
|
| 321 |
+
Returns:
|
| 322 |
+
List of generated PIL Images
|
| 323 |
+
|
| 324 |
+
Raises:
|
| 325 |
+
Exception: If execution fails
|
| 326 |
+
"""
|
| 327 |
+
# Queue the workflow
|
| 328 |
+
prompt_id = self.queue_prompt(workflow)
|
| 329 |
+
|
| 330 |
+
# Wait for completion
|
| 331 |
+
history = self.wait_for_completion(prompt_id, progress_callback)
|
| 332 |
+
|
| 333 |
+
# Extract output images
|
| 334 |
+
images = []
|
| 335 |
+
outputs = history.get("outputs", {})
|
| 336 |
+
|
| 337 |
+
for node_id, node_output in outputs.items():
|
| 338 |
+
if "images" in node_output:
|
| 339 |
+
for image_data in node_output["images"]:
|
| 340 |
+
filename = image_data.get("filename")
|
| 341 |
+
subfolder = image_data.get("subfolder", "")
|
| 342 |
+
|
| 343 |
+
if filename:
|
| 344 |
+
try:
|
| 345 |
+
image = self.get_image(filename, subfolder)
|
| 346 |
+
images.append(image)
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.warning(f"Failed to retrieve image {filename}: {e}")
|
| 349 |
+
|
| 350 |
+
if not images:
|
| 351 |
+
raise Exception("No images generated")
|
| 352 |
+
|
| 353 |
+
logger.info(f"Workflow execution complete, {len(images)} images generated")
|
| 354 |
+
return images
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# Convenience function for quick access
|
| 358 |
+
def create_client() -> ComfyUIClient:
|
| 359 |
+
"""Create and return a ComfyUI client instance."""
|
| 360 |
+
return ComfyUIClient()
|
character_forge_image/core/config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Configuration package for Nano Banana Streamlit."""
|
character_forge_image/core/config/settings.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# settings.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Centralized configuration management for the entire Nano Banana Streamlit application. Single source of truth for all constants, paths, and environment-dependent settings.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Define all project paths (output directories, log files)
|
| 8 |
+
- Manage API keys and credentials
|
| 9 |
+
- Configure backend URLs and timeouts
|
| 10 |
+
- Define generation parameters (aspect ratios, temperatures)
|
| 11 |
+
- Provide character forge-specific settings
|
| 12 |
+
- Configure logging parameters
|
| 13 |
+
- Define UI constants
|
| 14 |
+
- Provide helper methods for common configuration tasks
|
| 15 |
+
|
| 16 |
+
## Dependencies
|
| 17 |
+
|
| 18 |
+
### Imports
|
| 19 |
+
- `os` - Environment variable access
|
| 20 |
+
- `pathlib.Path` - Path manipulation
|
| 21 |
+
- `typing.Optional` - Type hints
|
| 22 |
+
|
| 23 |
+
### Used By
|
| 24 |
+
- `app.py` - Gets API keys and output directory
|
| 25 |
+
- All services - Import constants for generation
|
| 26 |
+
- All UI components - Import display constants
|
| 27 |
+
- `core/backend_router.py` - Backend URLs and timeouts
|
| 28 |
+
- Logging setup - Log configuration
|
| 29 |
+
|
| 30 |
+
## Public Interface
|
| 31 |
+
|
| 32 |
+
### Class: `Settings`
|
| 33 |
+
|
| 34 |
+
Static class (no instantiation needed) providing configuration through class methods and properties.
|
| 35 |
+
|
| 36 |
+
#### **Project Paths**
|
| 37 |
+
```python
|
| 38 |
+
Settings.PROJECT_ROOT # Path to project root
|
| 39 |
+
Settings.OUTPUT_DIR # Main output directory
|
| 40 |
+
Settings.CHARACTER_SHEETS_DIR # Character sheet outputs
|
| 41 |
+
Settings.WARDROBE_CHANGES_DIR # Wardrobe change outputs
|
| 42 |
+
Settings.COMPOSITIONS_DIR # Composition outputs
|
| 43 |
+
Settings.STANDARD_DIR # Standard generation outputs
|
| 44 |
+
Settings.LOG_FILE # Log file path
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
#### **API Configuration**
|
| 48 |
+
```python
|
| 49 |
+
Settings.get_gemini_api_key() -> Optional[str]
|
| 50 |
+
# Returns Gemini API key from GEMINI_API_KEY env var
|
| 51 |
+
|
| 52 |
+
Settings.OMNIGEN2_BASE_URL # OmniGen2 server URL
|
| 53 |
+
Settings.BACKEND_TIMEOUT # Request timeout in seconds
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
#### **Generation Parameters**
|
| 57 |
+
```python
|
| 58 |
+
Settings.ASPECT_RATIOS # Dict of display name -> ratio
|
| 59 |
+
Settings.DEFAULT_ASPECT_RATIO # Default selection
|
| 60 |
+
Settings.DEFAULT_TEMPERATURE # Default temp (0.4)
|
| 61 |
+
Settings.MIN_TEMPERATURE # Min temp (0.0)
|
| 62 |
+
Settings.MAX_TEMPERATURE # Max temp (1.0)
|
| 63 |
+
Settings.TEMPERATURE_STEP # Slider step (0.05)
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
#### **Character Forge Settings**
|
| 67 |
+
```python
|
| 68 |
+
Settings.PORTRAIT_ASPECT_RATIO # "3:4" for portraits
|
| 69 |
+
Settings.BODY_ASPECT_RATIO # "9:16" for body shots
|
| 70 |
+
Settings.PORTRAIT_TEMPERATURE # 0.35 (lower for consistency)
|
| 71 |
+
Settings.BODY_TEMPERATURE # 0.5 (variety)
|
| 72 |
+
Settings.CHARACTER_SHEET_SPACING # 20px between rows
|
| 73 |
+
Settings.CHARACTER_SHEET_BACKGROUND # "#2C2C2C" dark gray
|
| 74 |
+
Settings.MAX_RETRIES # 3 attempts
|
| 75 |
+
Settings.RETRY_BASE_DELAY # 2s (exponential backoff)
|
| 76 |
+
Settings.RATE_LIMIT_DELAY_MIN # 2.0s
|
| 77 |
+
Settings.RATE_LIMIT_DELAY_MAX # 3.0s
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
#### **Logging Configuration**
|
| 81 |
+
```python
|
| 82 |
+
Settings.LOG_LEVEL # "INFO"
|
| 83 |
+
Settings.LOG_FORMAT # Log message format string
|
| 84 |
+
Settings.LOG_DATE_FORMAT # Date format string
|
| 85 |
+
Settings.LOG_MAX_BYTES # 10MB per file
|
| 86 |
+
Settings.LOG_BACKUP_COUNT # 5 backup files
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
#### **UI Configuration**
|
| 90 |
+
```python
|
| 91 |
+
Settings.MAX_IMAGE_UPLOAD_SIZE # 20MB
|
| 92 |
+
Settings.PREVIEW_IMAGE_WIDTH # 512px
|
| 93 |
+
Settings.MAX_HISTORY_ITEMS # 20 recent generations
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
#### **Composition Assistant Settings**
|
| 97 |
+
```python
|
| 98 |
+
Settings.IMAGE_TYPES # List of image type options
|
| 99 |
+
Settings.SHOT_TYPES # List of shot type options
|
| 100 |
+
Settings.CAMERA_ANGLES # List of camera angle options
|
| 101 |
+
Settings.LIGHTING_OPTIONS # List of lighting options
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
#### **Backend Types**
|
| 105 |
+
```python
|
| 106 |
+
Settings.BACKEND_GEMINI # "Gemini API (Cloud)"
|
| 107 |
+
Settings.BACKEND_OMNIGEN2 # "OmniGen2 (Local)"
|
| 108 |
+
Settings.AVAILABLE_BACKENDS # List of both
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
#### **Helper Methods**
|
| 112 |
+
```python
|
| 113 |
+
Settings.get_aspect_ratio_value(display_name: str) -> str
|
| 114 |
+
# "16:9 (1344x768)" → "16:9"
|
| 115 |
+
|
| 116 |
+
Settings.is_gemini_configured() -> bool
|
| 117 |
+
# Check if Gemini API key is set
|
| 118 |
+
|
| 119 |
+
Settings.validate_temperature(temperature: float) -> float
|
| 120 |
+
# Clamp temperature to valid range
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
## Usage Examples
|
| 124 |
+
|
| 125 |
+
### Get API Key
|
| 126 |
+
```python
|
| 127 |
+
from config.settings import Settings
|
| 128 |
+
|
| 129 |
+
api_key = Settings.get_gemini_api_key()
|
| 130 |
+
if api_key:
|
| 131 |
+
# Use API key
|
| 132 |
+
pass
|
| 133 |
+
else:
|
| 134 |
+
# Prompt user for API key
|
| 135 |
+
pass
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
### Save Generation Output
|
| 139 |
+
```python
|
| 140 |
+
from config.settings import Settings
|
| 141 |
+
from datetime import datetime
|
| 142 |
+
|
| 143 |
+
# Create filename
|
| 144 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 145 |
+
filename = f"character_{timestamp}.png"
|
| 146 |
+
|
| 147 |
+
# Save to appropriate directory
|
| 148 |
+
output_path = Settings.CHARACTER_SHEETS_DIR / filename
|
| 149 |
+
image.save(output_path)
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### Validate Temperature
|
| 153 |
+
```python
|
| 154 |
+
from config.settings import Settings
|
| 155 |
+
|
| 156 |
+
user_temp = 1.5 # Invalid - too high
|
| 157 |
+
valid_temp = Settings.validate_temperature(user_temp) # Returns 1.0
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
## Environment Variables
|
| 161 |
+
|
| 162 |
+
### Required
|
| 163 |
+
- **None** - App runs with defaults
|
| 164 |
+
|
| 165 |
+
### Optional
|
| 166 |
+
- `GEMINI_API_KEY` - Required only for Gemini backend
|
| 167 |
+
- Format: String API key from Google AI Studio
|
| 168 |
+
- How to get: https://aistudio.google.com/apikey
|
| 169 |
+
|
| 170 |
+
## Known Limitations
|
| 171 |
+
- API key only loaded from environment, not from config file
|
| 172 |
+
- No runtime configuration reloading (requires restart)
|
| 173 |
+
- No user-specific configuration (multi-user scenarios)
|
| 174 |
+
- Hardcoded OmniGen2 URL (not configurable without code change)
|
| 175 |
+
|
| 176 |
+
## Future Improvements
|
| 177 |
+
- Support config file (.env, .toml, .yaml)
|
| 178 |
+
- Add user profiles with different defaults
|
| 179 |
+
- Make all URLs/ports configurable
|
| 180 |
+
- Add validation for all settings on startup
|
| 181 |
+
- Add settings UI page for runtime changes
|
| 182 |
+
- Support multiple OmniGen2 instances (load balancing)
|
| 183 |
+
|
| 184 |
+
## Testing
|
| 185 |
+
- Verify all paths are created on import
|
| 186 |
+
- Test get_gemini_api_key() with and without env var
|
| 187 |
+
- Test validate_temperature() with various inputs
|
| 188 |
+
- Test get_aspect_ratio_value() with all display names
|
| 189 |
+
- Verify all constants are accessible
|
| 190 |
+
|
| 191 |
+
## Related Files
|
| 192 |
+
- `app.py` - Main app imports this first
|
| 193 |
+
- All services - Use constants from here
|
| 194 |
+
- All UI pages - Use UI constants from here
|
| 195 |
+
- `core/backend_router.py` - Uses backend URLs
|
| 196 |
+
- `utils/logging_utils.py` - Uses logging configuration
|
| 197 |
+
|
| 198 |
+
## Security Considerations
|
| 199 |
+
- API keys read from environment (not hardcoded)
|
| 200 |
+
- No API keys written to logs
|
| 201 |
+
- Output directory permissions should be restricted in production
|
| 202 |
+
|
| 203 |
+
## Change History
|
| 204 |
+
- 2025-10-23: Initial creation for Streamlit migration
|
| 205 |
+
- All constants from Gradio version migrated
|
| 206 |
+
- Added comprehensive documentation
|
| 207 |
+
- Added helper methods for common tasks
|
character_forge_image/core/config/settings.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application Settings and Configuration
|
| 3 |
+
=======================================
|
| 4 |
+
|
| 5 |
+
Centralized configuration management for Nano Banana Streamlit.
|
| 6 |
+
All environment variables, paths, and constants defined here.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings:
|
| 15 |
+
"""
|
| 16 |
+
Application-wide settings and configuration.
|
| 17 |
+
|
| 18 |
+
This class uses class methods and properties to provide
|
| 19 |
+
a simple interface for accessing configuration values.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
# =========================================================================
|
| 23 |
+
# PROJECT PATHS
|
| 24 |
+
# =========================================================================
|
| 25 |
+
|
| 26 |
+
# Root directory of the project
|
| 27 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 28 |
+
|
| 29 |
+
# Output directory for generated images
|
| 30 |
+
OUTPUT_DIR = PROJECT_ROOT / "outputs"
|
| 31 |
+
|
| 32 |
+
# Ensure output directory exists
|
| 33 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 34 |
+
|
| 35 |
+
# Subdirectories for different generation types
|
| 36 |
+
CHARACTER_SHEETS_DIR = OUTPUT_DIR / "character_sheets"
|
| 37 |
+
WARDROBE_CHANGES_DIR = OUTPUT_DIR / "wardrobe_changes"
|
| 38 |
+
COMPOSITIONS_DIR = OUTPUT_DIR / "compositions"
|
| 39 |
+
STANDARD_DIR = OUTPUT_DIR / "standard"
|
| 40 |
+
|
| 41 |
+
# Create all subdirectories
|
| 42 |
+
for directory in [CHARACTER_SHEETS_DIR, WARDROBE_CHANGES_DIR,
|
| 43 |
+
COMPOSITIONS_DIR, STANDARD_DIR]:
|
| 44 |
+
directory.mkdir(exist_ok=True)
|
| 45 |
+
|
| 46 |
+
# Log file
|
| 47 |
+
LOG_FILE = OUTPUT_DIR / "generation.log"
|
| 48 |
+
|
| 49 |
+
# =========================================================================
|
| 50 |
+
# API KEYS AND CREDENTIALS
|
| 51 |
+
# =========================================================================
|
| 52 |
+
|
| 53 |
+
@classmethod
|
| 54 |
+
def get_gemini_api_key(cls) -> Optional[str]:
|
| 55 |
+
"""
|
| 56 |
+
Get Gemini API key from environment variable or Streamlit secrets.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
API key string if set, None otherwise
|
| 60 |
+
"""
|
| 61 |
+
# Try environment variable first
|
| 62 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
| 63 |
+
|
| 64 |
+
# If not found, try Streamlit secrets (for HuggingFace Spaces)
|
| 65 |
+
if not api_key:
|
| 66 |
+
try:
|
| 67 |
+
import streamlit as st
|
| 68 |
+
api_key = st.secrets.get("GEMINI_API_KEY")
|
| 69 |
+
except:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
return api_key
|
| 73 |
+
|
| 74 |
+
# =========================================================================
|
| 75 |
+
# BACKEND CONFIGURATION
|
| 76 |
+
# =========================================================================
|
| 77 |
+
|
| 78 |
+
# OmniGen2 server URL
|
| 79 |
+
OMNIGEN2_BASE_URL = "http://127.0.0.1:9002"
|
| 80 |
+
|
| 81 |
+
# ComfyUI server URL
|
| 82 |
+
COMFYUI_BASE_URL = "http://127.0.0.1:8188"
|
| 83 |
+
|
| 84 |
+
# Backend timeout (seconds)
|
| 85 |
+
BACKEND_TIMEOUT = 300 # 5 minutes for generation
|
| 86 |
+
|
| 87 |
+
# =========================================================================
|
| 88 |
+
# GENERATION PARAMETERS
|
| 89 |
+
# =========================================================================
|
| 90 |
+
|
| 91 |
+
# Available aspect ratios
|
| 92 |
+
ASPECT_RATIOS = {
|
| 93 |
+
"1:1 (1024x1024)": "1:1",
|
| 94 |
+
"16:9 (1344x768)": "16:9",
|
| 95 |
+
"9:16 (768x1344)": "9:16",
|
| 96 |
+
"3:2 (1248x832)": "3:2",
|
| 97 |
+
"2:3 (832x1248)": "2:3",
|
| 98 |
+
"3:4 (1008x1344)": "3:4", # Character portraits
|
| 99 |
+
"4:3 (1344x1008)": "4:3",
|
| 100 |
+
"4:5 (1024x1280)": "4:5",
|
| 101 |
+
"5:4 (1280x1024)": "5:4",
|
| 102 |
+
"21:9 (1536x640)": "21:9",
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# Default generation parameters
|
| 106 |
+
DEFAULT_ASPECT_RATIO = "16:9 (1344x768)"
|
| 107 |
+
DEFAULT_TEMPERATURE = 0.4
|
| 108 |
+
MIN_TEMPERATURE = 0.0
|
| 109 |
+
MAX_TEMPERATURE = 1.0
|
| 110 |
+
TEMPERATURE_STEP = 0.05
|
| 111 |
+
|
| 112 |
+
# =========================================================================
|
| 113 |
+
# CHARACTER FORGE SETTINGS
|
| 114 |
+
# =========================================================================
|
| 115 |
+
|
| 116 |
+
# Aspect ratios for character sheet views
|
| 117 |
+
PORTRAIT_ASPECT_RATIO = "3:4" # For face portraits (1008x1344)
|
| 118 |
+
BODY_ASPECT_RATIO = "9:16" # For full body shots (768x1344)
|
| 119 |
+
|
| 120 |
+
# Generation temperatures for each stage
|
| 121 |
+
PORTRAIT_TEMPERATURE = 0.35 # Lower for consistency
|
| 122 |
+
BODY_TEMPERATURE = 0.5 # Slightly higher for variety
|
| 123 |
+
|
| 124 |
+
# Composition settings
|
| 125 |
+
CHARACTER_SHEET_SPACING = 20 # Pixels between rows
|
| 126 |
+
CHARACTER_SHEET_BACKGROUND = "#2C2C2C" # Dark gray
|
| 127 |
+
|
| 128 |
+
# Retry logic
|
| 129 |
+
MAX_RETRIES = 3
|
| 130 |
+
RETRY_BASE_DELAY = 2 # Seconds (exponential backoff)
|
| 131 |
+
RATE_LIMIT_DELAY_MIN = 2.0 # Seconds
|
| 132 |
+
RATE_LIMIT_DELAY_MAX = 3.0 # Seconds
|
| 133 |
+
|
| 134 |
+
# =========================================================================
|
| 135 |
+
# LOGGING CONFIGURATION
|
| 136 |
+
# =========================================================================
|
| 137 |
+
|
| 138 |
+
LOG_LEVEL = "INFO"
|
| 139 |
+
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 140 |
+
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
| 141 |
+
|
| 142 |
+
# Rotating file handler settings
|
| 143 |
+
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
| 144 |
+
LOG_BACKUP_COUNT = 5 # Keep 5 backup files
|
| 145 |
+
|
| 146 |
+
# =========================================================================
|
| 147 |
+
# UI CONFIGURATION
|
| 148 |
+
# =========================================================================
|
| 149 |
+
|
| 150 |
+
# Maximum image upload size (MB)
|
| 151 |
+
MAX_IMAGE_UPLOAD_SIZE = 20 # MB
|
| 152 |
+
|
| 153 |
+
# Image display size
|
| 154 |
+
PREVIEW_IMAGE_WIDTH = 512 # Pixels
|
| 155 |
+
|
| 156 |
+
# History display
|
| 157 |
+
MAX_HISTORY_ITEMS = 20
|
| 158 |
+
|
| 159 |
+
# =========================================================================
|
| 160 |
+
# COMPOSITION ASSISTANT SETTINGS
|
| 161 |
+
# =========================================================================
|
| 162 |
+
|
| 163 |
+
# Image type options
|
| 164 |
+
IMAGE_TYPES = [
|
| 165 |
+
"Subject/Character",
|
| 166 |
+
"Background/Environment",
|
| 167 |
+
"Style Reference",
|
| 168 |
+
"Product",
|
| 169 |
+
"Texture",
|
| 170 |
+
"Not Used"
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
# Shot type options
|
| 174 |
+
SHOT_TYPES = [
|
| 175 |
+
"close-up shot",
|
| 176 |
+
"medium shot",
|
| 177 |
+
"full body shot",
|
| 178 |
+
"wide shot",
|
| 179 |
+
"extreme close-up",
|
| 180 |
+
"establishing shot"
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
# Camera angle options
|
| 184 |
+
CAMERA_ANGLES = [
|
| 185 |
+
"eye-level perspective",
|
| 186 |
+
"low-angle perspective",
|
| 187 |
+
"high-angle perspective",
|
| 188 |
+
"bird's-eye view",
|
| 189 |
+
"Dutch angle (tilted)",
|
| 190 |
+
"over-the-shoulder"
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
# Lighting options
|
| 194 |
+
LIGHTING_OPTIONS = [
|
| 195 |
+
"Auto (match images)",
|
| 196 |
+
"natural daylight",
|
| 197 |
+
"soft studio lighting",
|
| 198 |
+
"dramatic side lighting",
|
| 199 |
+
"golden hour",
|
| 200 |
+
"blue hour",
|
| 201 |
+
"moody low-key",
|
| 202 |
+
"high-key bright",
|
| 203 |
+
"rim lighting"
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
# =========================================================================
|
| 207 |
+
# BACKEND TYPE ENUMERATION
|
| 208 |
+
# =========================================================================
|
| 209 |
+
|
| 210 |
+
BACKEND_GEMINI = "Gemini API (Cloud)"
|
| 211 |
+
BACKEND_OMNIGEN2 = "OmniGen2 (Local)"
|
| 212 |
+
BACKEND_COMFYUI = "ComfyUI (Local)"
|
| 213 |
+
|
| 214 |
+
AVAILABLE_BACKENDS = [BACKEND_GEMINI, BACKEND_OMNIGEN2, BACKEND_COMFYUI]
|
| 215 |
+
|
| 216 |
+
# =========================================================================
|
| 217 |
+
# HELPER METHODS
|
| 218 |
+
# =========================================================================
|
| 219 |
+
|
| 220 |
+
@classmethod
|
| 221 |
+
def get_aspect_ratio_value(cls, display_name: str) -> str:
|
| 222 |
+
"""
|
| 223 |
+
Convert display name to aspect ratio value.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
display_name: Display name like "16:9 (1344x768)"
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
Aspect ratio value like "16:9"
|
| 230 |
+
"""
|
| 231 |
+
return cls.ASPECT_RATIOS.get(display_name, "1:1")
|
| 232 |
+
|
| 233 |
+
@classmethod
|
| 234 |
+
def is_gemini_configured(cls) -> bool:
|
| 235 |
+
"""Check if Gemini API is configured (API key set)."""
|
| 236 |
+
return cls.get_gemini_api_key() is not None
|
| 237 |
+
|
| 238 |
+
@classmethod
|
| 239 |
+
def validate_temperature(cls, temperature: float) -> float:
|
| 240 |
+
"""
|
| 241 |
+
Validate and clamp temperature to valid range.
|
| 242 |
+
|
| 243 |
+
Args:
|
| 244 |
+
temperature: Temperature value to validate
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
Validated temperature within [MIN_TEMPERATURE, MAX_TEMPERATURE]
|
| 248 |
+
"""
|
| 249 |
+
return max(cls.MIN_TEMPERATURE, min(cls.MAX_TEMPERATURE, temperature))
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# Make settings instance available for import
|
| 253 |
+
settings = Settings()
|
character_forge_image/core/flux_client.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FLUX Client
|
| 3 |
+
===========
|
| 4 |
+
|
| 5 |
+
Client for FLUX.1-Krea and FLUX.1-Kontext models running in ComfyUI.
|
| 6 |
+
|
| 7 |
+
FLUX.1-Krea: Text-to-image generation for initial portraits
|
| 8 |
+
FLUX.1-Kontext: Image-to-image transformation for perspective changes
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from typing import Optional
|
| 12 |
+
import json
|
| 13 |
+
import random
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
from config.settings import Settings
|
| 18 |
+
from models.generation_request import GenerationRequest
|
| 19 |
+
from models.generation_result import GenerationResult
|
| 20 |
+
from core.comfyui_client import ComfyUIClient
|
| 21 |
+
from utils.logging_utils import get_logger
|
| 22 |
+
import time
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
logger = get_logger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FLUXClient:
|
| 29 |
+
"""
|
| 30 |
+
Client for FLUX models running in ComfyUI.
|
| 31 |
+
|
| 32 |
+
Uses ComfyUI as the backend to execute FLUX workflows.
|
| 33 |
+
Supports both Krea (T2I) and Kontext (I2I) models.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self):
|
| 37 |
+
"""Initialize FLUX client with ComfyUI connection."""
|
| 38 |
+
self.comfyui_client = ComfyUIClient()
|
| 39 |
+
# Path from character_forge_image/core/ up to character_forge/
|
| 40 |
+
self.workflow_dir = Path(__file__).parent.parent.parent / 'tools' / 'comfyui' / 'workflows'
|
| 41 |
+
logger.info(f"FLUXClient initialized (workflow dir: {self.workflow_dir})")
|
| 42 |
+
|
| 43 |
+
def generate_krea(self, request: GenerationRequest) -> GenerationResult:
|
| 44 |
+
"""
|
| 45 |
+
Generate image using FLUX.1-Krea (text-to-image).
|
| 46 |
+
|
| 47 |
+
Best for: Initial portrait generation from text prompts
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
request: GenerationRequest object
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
GenerationResult object
|
| 54 |
+
"""
|
| 55 |
+
try:
|
| 56 |
+
# Load Krea workflow template
|
| 57 |
+
workflow_path = self.workflow_dir / 'flux_krea_t2i.json'
|
| 58 |
+
if not workflow_path.exists():
|
| 59 |
+
return GenerationResult.error_result(
|
| 60 |
+
message=f"FLUX Krea workflow not found at {workflow_path}"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
with open(workflow_path) as f:
|
| 64 |
+
workflow_template = json.load(f)
|
| 65 |
+
|
| 66 |
+
# Parse dimensions from aspect ratio
|
| 67 |
+
width, height = self._parse_dimensions(request.aspect_ratio)
|
| 68 |
+
|
| 69 |
+
# Update workflow with request parameters
|
| 70 |
+
workflow = self._update_krea_workflow(
|
| 71 |
+
workflow_template,
|
| 72 |
+
prompt=request.prompt,
|
| 73 |
+
width=width,
|
| 74 |
+
height=height,
|
| 75 |
+
seed=request.seed if request.seed else random.randint(1, 2**32 - 1)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Execute workflow via ComfyUI
|
| 79 |
+
start_time = time.time()
|
| 80 |
+
images = self.comfyui_client.execute_workflow(workflow)
|
| 81 |
+
generation_time = time.time() - start_time
|
| 82 |
+
|
| 83 |
+
if not images or len(images) == 0:
|
| 84 |
+
return GenerationResult.error_result(
|
| 85 |
+
message="No images generated by FLUX Krea"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Return first generated image
|
| 89 |
+
return GenerationResult.success_result(
|
| 90 |
+
image=images[0],
|
| 91 |
+
message=f"Generated with FLUX Krea in {generation_time:.1f}s",
|
| 92 |
+
generation_time=generation_time
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"FLUX Krea generation failed: {e}", exc_info=True)
|
| 97 |
+
return GenerationResult.error_result(
|
| 98 |
+
message=f"FLUX Krea error: {str(e)}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
def generate_kontext(self, request: GenerationRequest) -> GenerationResult:
|
| 102 |
+
"""
|
| 103 |
+
Generate image using FLUX.1-Kontext (image-to-image).
|
| 104 |
+
|
| 105 |
+
Best for: Perspective transformations with reference images
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
request: GenerationRequest object (must include input_images)
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
GenerationResult object
|
| 112 |
+
"""
|
| 113 |
+
try:
|
| 114 |
+
# Verify input images exist
|
| 115 |
+
if not request.input_images or len(request.input_images) == 0:
|
| 116 |
+
return GenerationResult.error_result(
|
| 117 |
+
message="FLUX Kontext requires at least one input image"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Load Kontext workflow template
|
| 121 |
+
workflow_path = self.workflow_dir / 'flux_kontext_i2i.json'
|
| 122 |
+
if not workflow_path.exists():
|
| 123 |
+
return GenerationResult.error_result(
|
| 124 |
+
message=f"FLUX Kontext workflow not found at {workflow_path}"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
with open(workflow_path) as f:
|
| 128 |
+
workflow_template = json.load(f)
|
| 129 |
+
|
| 130 |
+
# Upload reference image to ComfyUI
|
| 131 |
+
reference_filename = self.comfyui_client.upload_image(request.input_images[0])
|
| 132 |
+
logger.info(f"Uploaded reference image: {reference_filename}")
|
| 133 |
+
|
| 134 |
+
# Update workflow with request parameters
|
| 135 |
+
workflow = self._update_kontext_workflow(
|
| 136 |
+
workflow_template,
|
| 137 |
+
prompt=request.prompt,
|
| 138 |
+
reference_filename=reference_filename,
|
| 139 |
+
seed=request.seed if request.seed else random.randint(1, 2**32 - 1),
|
| 140 |
+
denoise=0.75 # Strength of transformation
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Execute workflow via ComfyUI
|
| 144 |
+
start_time = time.time()
|
| 145 |
+
images = self.comfyui_client.execute_workflow(workflow)
|
| 146 |
+
generation_time = time.time() - start_time
|
| 147 |
+
|
| 148 |
+
if not images or len(images) == 0:
|
| 149 |
+
return GenerationResult.error_result(
|
| 150 |
+
message="No images generated by FLUX Kontext"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# Return first generated image
|
| 154 |
+
return GenerationResult.success_result(
|
| 155 |
+
image=images[0],
|
| 156 |
+
message=f"Generated with FLUX Kontext in {generation_time:.1f}s",
|
| 157 |
+
generation_time=generation_time
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(f"FLUX Kontext generation failed: {e}", exc_info=True)
|
| 162 |
+
return GenerationResult.error_result(
|
| 163 |
+
message=f"FLUX Kontext error: {str(e)}"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
def _update_krea_workflow(
|
| 167 |
+
self,
|
| 168 |
+
workflow: dict,
|
| 169 |
+
prompt: str = None,
|
| 170 |
+
width: int = None,
|
| 171 |
+
height: int = None,
|
| 172 |
+
seed: int = None
|
| 173 |
+
) -> dict:
|
| 174 |
+
"""
|
| 175 |
+
Update FLUX Krea workflow parameters.
|
| 176 |
+
|
| 177 |
+
Node IDs for flux_krea_t2i.json:
|
| 178 |
+
- 4: Positive prompt (CLIPTextEncode)
|
| 179 |
+
- 5: Empty latent (width, height)
|
| 180 |
+
- 6: KSampler (seed, steps, cfg)
|
| 181 |
+
"""
|
| 182 |
+
wf = json.loads(json.dumps(workflow)) # Deep copy
|
| 183 |
+
|
| 184 |
+
# Update prompt
|
| 185 |
+
if prompt is not None:
|
| 186 |
+
wf["4"]["inputs"]["text"] = prompt
|
| 187 |
+
|
| 188 |
+
# Update dimensions
|
| 189 |
+
if width is not None and height is not None:
|
| 190 |
+
wf["5"]["inputs"]["width"] = width
|
| 191 |
+
wf["5"]["inputs"]["height"] = height
|
| 192 |
+
|
| 193 |
+
# Update seed
|
| 194 |
+
if seed is not None:
|
| 195 |
+
wf["6"]["inputs"]["seed"] = seed
|
| 196 |
+
|
| 197 |
+
return wf
|
| 198 |
+
|
| 199 |
+
def _update_kontext_workflow(
|
| 200 |
+
self,
|
| 201 |
+
workflow: dict,
|
| 202 |
+
prompt: str = None,
|
| 203 |
+
reference_filename: str = None,
|
| 204 |
+
seed: int = None,
|
| 205 |
+
denoise: float = 0.75
|
| 206 |
+
) -> dict:
|
| 207 |
+
"""
|
| 208 |
+
Update FLUX Kontext workflow parameters.
|
| 209 |
+
|
| 210 |
+
Node IDs for flux_kontext_i2i.json:
|
| 211 |
+
- 4: Prompt (CLIPTextEncode)
|
| 212 |
+
- 5: Load Image (reference)
|
| 213 |
+
- 7: KSampler (seed, denoise)
|
| 214 |
+
"""
|
| 215 |
+
wf = json.loads(json.dumps(workflow)) # Deep copy
|
| 216 |
+
|
| 217 |
+
# Update prompt
|
| 218 |
+
if prompt is not None:
|
| 219 |
+
wf["4"]["inputs"]["text"] = prompt
|
| 220 |
+
|
| 221 |
+
# Update reference image
|
| 222 |
+
if reference_filename is not None:
|
| 223 |
+
wf["5"]["inputs"]["image"] = reference_filename
|
| 224 |
+
|
| 225 |
+
# Update seed and denoise
|
| 226 |
+
if seed is not None:
|
| 227 |
+
wf["7"]["inputs"]["seed"] = seed
|
| 228 |
+
wf["7"]["inputs"]["denoise"] = denoise
|
| 229 |
+
|
| 230 |
+
return wf
|
| 231 |
+
|
| 232 |
+
def _parse_dimensions(self, aspect_ratio: str) -> tuple[int, int]:
|
| 233 |
+
"""
|
| 234 |
+
Parse aspect ratio to exact dimensions.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
aspect_ratio: Aspect ratio string (e.g., "3:4" or "3:4 (864x1184)")
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
Tuple of (width, height)
|
| 241 |
+
"""
|
| 242 |
+
import re
|
| 243 |
+
|
| 244 |
+
# Default
|
| 245 |
+
width, height = 1024, 1024
|
| 246 |
+
|
| 247 |
+
if not aspect_ratio:
|
| 248 |
+
return width, height
|
| 249 |
+
|
| 250 |
+
# Try to extract dimensions from format "16:9 (1344x768)"
|
| 251 |
+
match = re.search(r'(\d+)x(\d+)', aspect_ratio)
|
| 252 |
+
if match:
|
| 253 |
+
width = int(match.group(1))
|
| 254 |
+
height = int(match.group(2))
|
| 255 |
+
else:
|
| 256 |
+
# Map aspect ratio to dimensions
|
| 257 |
+
dimension_map = {
|
| 258 |
+
"1:1": (1024, 1024),
|
| 259 |
+
"16:9": (1344, 768),
|
| 260 |
+
"9:16": (768, 1344),
|
| 261 |
+
"3:2": (1248, 832),
|
| 262 |
+
"2:3": (832, 1248),
|
| 263 |
+
"3:4": (864, 1184),
|
| 264 |
+
"4:3": (1344, 1008),
|
| 265 |
+
"4:5": (1024, 1280),
|
| 266 |
+
"5:4": (1280, 1024),
|
| 267 |
+
"21:9": (1536, 640),
|
| 268 |
+
}
|
| 269 |
+
if aspect_ratio in dimension_map:
|
| 270 |
+
width, height = dimension_map[aspect_ratio]
|
| 271 |
+
|
| 272 |
+
logger.info(f"Parsed aspect ratio {aspect_ratio} to dimensions: {width}x{height}")
|
| 273 |
+
return width, height
|
| 274 |
+
|
| 275 |
+
def health_check(self) -> tuple[bool, str]:
|
| 276 |
+
"""
|
| 277 |
+
Check if FLUX models are available in ComfyUI.
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
Tuple of (is_healthy, status_message)
|
| 281 |
+
"""
|
| 282 |
+
# Check if ComfyUI is running
|
| 283 |
+
comfyui_healthy, comfyui_msg = self.comfyui_client.health_check()
|
| 284 |
+
if not comfyui_healthy:
|
| 285 |
+
return False, f"ComfyUI not available: {comfyui_msg}"
|
| 286 |
+
|
| 287 |
+
# Check if workflow files exist
|
| 288 |
+
krea_workflow = self.workflow_dir / 'flux_krea_t2i.json'
|
| 289 |
+
kontext_workflow = self.workflow_dir / 'flux_kontext_i2i.json'
|
| 290 |
+
|
| 291 |
+
if not krea_workflow.exists():
|
| 292 |
+
return False, f"FLUX Krea workflow missing: {krea_workflow}"
|
| 293 |
+
if not kontext_workflow.exists():
|
| 294 |
+
return False, f"FLUX Kontext workflow missing: {kontext_workflow}"
|
| 295 |
+
|
| 296 |
+
return True, "Ready (ComfyUI running, workflows available)"
|
character_forge_image/core/gemini_client.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# gemini_client.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Client wrapper for Google Gemini 2.5 Flash Image API. Handles API authentication, request formatting, response parsing, and error handling for cloud-based image generation.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Initialize Gemini API client with authentication
|
| 8 |
+
- Build API request contents (images + prompt)
|
| 9 |
+
- Configure generation parameters (aspect_ratio, temperature)
|
| 10 |
+
- Parse API responses and extract generated images
|
| 11 |
+
- Handle safety filter blocks and API errors
|
| 12 |
+
- Decode base64 image data from responses
|
| 13 |
+
- Health checking (API key validation)
|
| 14 |
+
|
| 15 |
+
## Dependencies
|
| 16 |
+
- `google.genai` - Official Gemini API SDK
|
| 17 |
+
- `google.genai.types` - API types (GenerateContentConfig, ImageConfig)
|
| 18 |
+
- `config.settings.Settings` - Configuration and aspect ratios
|
| 19 |
+
- `models.generation_request.GenerationRequest` - Request dataclass
|
| 20 |
+
- `models.generation_result.GenerationResult` - Result dataclass
|
| 21 |
+
- `PIL.Image` - Image handling
|
| 22 |
+
- `base64` - Image data decoding
|
| 23 |
+
- `io.BytesIO` - Binary stream handling
|
| 24 |
+
- `utils.logging_utils` - Logging
|
| 25 |
+
|
| 26 |
+
## Source
|
| 27 |
+
Extracted from `character_forge.py` lines 896-955 (Gradio implementation).
|
| 28 |
+
|
| 29 |
+
## Public Interface
|
| 30 |
+
|
| 31 |
+
### `GeminiClient` class
|
| 32 |
+
|
| 33 |
+
**Constants:**
|
| 34 |
+
- `MODEL_NAME = "gemini-2.5-flash-image"` - Gemini model identifier
|
| 35 |
+
|
| 36 |
+
**Constructor:**
|
| 37 |
+
```python
|
| 38 |
+
def __init__(self, api_key: str)
|
| 39 |
+
```
|
| 40 |
+
- `api_key`: Google Gemini API key (required)
|
| 41 |
+
- Raises `ValueError` if api_key is empty
|
| 42 |
+
- Initializes Google genai.Client
|
| 43 |
+
|
| 44 |
+
**Key Methods:**
|
| 45 |
+
|
| 46 |
+
#### `generate(request: GenerationRequest) -> GenerationResult`
|
| 47 |
+
Generate image using Gemini API.
|
| 48 |
+
|
| 49 |
+
**Parameters:**
|
| 50 |
+
- `request`: GenerationRequest with prompt, aspect_ratio, temperature, optional input_images
|
| 51 |
+
|
| 52 |
+
**Returns:**
|
| 53 |
+
- `GenerationResult` with success/failure status, image, and message
|
| 54 |
+
|
| 55 |
+
**Behavior:**
|
| 56 |
+
- Text-to-image: Send prompt only
|
| 57 |
+
- Image-to-image: Send input images followed by prompt
|
| 58 |
+
- Configures aspect_ratio and temperature
|
| 59 |
+
- Parses response for generated image
|
| 60 |
+
- Handles safety blocks and API errors
|
| 61 |
+
|
| 62 |
+
**Usage:**
|
| 63 |
+
```python
|
| 64 |
+
client = GeminiClient(api_key="your-api-key")
|
| 65 |
+
request = GenerationRequest(
|
| 66 |
+
prompt="A fantasy castle on a mountain",
|
| 67 |
+
backend="Gemini API (Cloud)",
|
| 68 |
+
aspect_ratio="16:9 (1344x768)",
|
| 69 |
+
temperature=0.7
|
| 70 |
+
)
|
| 71 |
+
result = client.generate(request)
|
| 72 |
+
if result.success:
|
| 73 |
+
result.image.save("output.png")
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
#### `is_healthy() -> bool`
|
| 77 |
+
Check if client is properly configured.
|
| 78 |
+
|
| 79 |
+
**Returns:**
|
| 80 |
+
- `True` if API key is set and non-empty
|
| 81 |
+
|
| 82 |
+
**Usage:**
|
| 83 |
+
```python
|
| 84 |
+
if client.is_healthy():
|
| 85 |
+
result = client.generate(request)
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
## Private Methods
|
| 89 |
+
|
| 90 |
+
### `_build_contents(request: GenerationRequest) -> list`
|
| 91 |
+
Build contents list for API request.
|
| 92 |
+
|
| 93 |
+
**Format:**
|
| 94 |
+
- Text-to-image: `[prompt]`
|
| 95 |
+
- Image-to-image: `[image1, image2, ..., prompt]`
|
| 96 |
+
|
| 97 |
+
**Behavior:**
|
| 98 |
+
- Filters out None images from input
|
| 99 |
+
- Appends prompt as final element
|
| 100 |
+
- Gemini expects images before text
|
| 101 |
+
|
| 102 |
+
### `_build_config(request: GenerationRequest) -> types.GenerateContentConfig`
|
| 103 |
+
Build generation configuration.
|
| 104 |
+
|
| 105 |
+
**Parameters set:**
|
| 106 |
+
- `temperature`: Creativity level (0.0-1.0)
|
| 107 |
+
- `aspect_ratio`: Desired image dimensions
|
| 108 |
+
|
| 109 |
+
**Aspect Ratio Handling:**
|
| 110 |
+
Converts display format to API format:
|
| 111 |
+
- "16:9 (1344x768)" → "16:9"
|
| 112 |
+
- Uses Settings.get_aspect_ratio_value() for conversion
|
| 113 |
+
|
| 114 |
+
**Returns:**
|
| 115 |
+
```python
|
| 116 |
+
GenerateContentConfig(
|
| 117 |
+
temperature=0.7,
|
| 118 |
+
image_config=ImageConfig(aspect_ratio="16:9")
|
| 119 |
+
)
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### `_parse_response(response) -> GenerationResult`
|
| 123 |
+
Parse API response and extract image data.
|
| 124 |
+
|
| 125 |
+
**Validation Steps:**
|
| 126 |
+
1. Check response exists
|
| 127 |
+
2. Check for candidates in response
|
| 128 |
+
3. Check finish_reason for safety blocks
|
| 129 |
+
4. Check for content in candidate
|
| 130 |
+
5. Extract image from parts
|
| 131 |
+
|
| 132 |
+
**Safety Block Handling:**
|
| 133 |
+
If finish_reason contains "SAFETY" or "BLOCK":
|
| 134 |
+
```python
|
| 135 |
+
return GenerationResult.error_result(
|
| 136 |
+
f"Content blocked by safety filters: {finish_reason}"
|
| 137 |
+
)
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
**Image Extraction:**
|
| 141 |
+
- Searches content.parts for inline_data
|
| 142 |
+
- Decodes base64 image data
|
| 143 |
+
- Converts to PIL.Image
|
| 144 |
+
|
| 145 |
+
**Success Return:**
|
| 146 |
+
```python
|
| 147 |
+
return GenerationResult.success_result(
|
| 148 |
+
image=image,
|
| 149 |
+
message="Generated successfully"
|
| 150 |
+
)
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
## API Request Flow
|
| 154 |
+
|
| 155 |
+
```
|
| 156 |
+
1. generate(request)
|
| 157 |
+
↓
|
| 158 |
+
2. _build_contents(request) → [images..., prompt]
|
| 159 |
+
↓
|
| 160 |
+
3. _build_config(request) → GenerateContentConfig
|
| 161 |
+
↓
|
| 162 |
+
4. client.models.generate_content(MODEL_NAME, contents, config)
|
| 163 |
+
↓
|
| 164 |
+
5. _parse_response(response) → GenerationResult
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
## Error Handling
|
| 168 |
+
|
| 169 |
+
### API Errors
|
| 170 |
+
All exceptions caught and converted to error results:
|
| 171 |
+
```python
|
| 172 |
+
except Exception as e:
|
| 173 |
+
return GenerationResult.error_result(f"Gemini API error: {str(e)}")
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
### Safety Blocks
|
| 177 |
+
Detected via finish_reason:
|
| 178 |
+
- `SAFETY` - Content policy violation
|
| 179 |
+
- `BLOCK` - Blocked by filters
|
| 180 |
+
|
| 181 |
+
### Missing Data
|
| 182 |
+
- No response → "No response from API"
|
| 183 |
+
- No candidates → "No candidates in response"
|
| 184 |
+
- No content → "No content in response (finish_reason: {reason})"
|
| 185 |
+
- No image data → "No image data in response"
|
| 186 |
+
|
| 187 |
+
### Image Decoding Errors
|
| 188 |
+
```python
|
| 189 |
+
except Exception as e:
|
| 190 |
+
return GenerationResult.error_result(f"Image decoding error: {str(e)}")
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
## Usage Examples
|
| 194 |
+
|
| 195 |
+
### Text-to-Image
|
| 196 |
+
```python
|
| 197 |
+
client = GeminiClient(api_key="your-key")
|
| 198 |
+
request = GenerationRequest(
|
| 199 |
+
prompt="A serene mountain landscape",
|
| 200 |
+
backend="Gemini API (Cloud)",
|
| 201 |
+
aspect_ratio="16:9",
|
| 202 |
+
temperature=0.5
|
| 203 |
+
)
|
| 204 |
+
result = client.generate(request)
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
### Image-to-Image (Style Transfer)
|
| 208 |
+
```python
|
| 209 |
+
reference_image = Image.open("reference.jpg")
|
| 210 |
+
request = GenerationRequest(
|
| 211 |
+
prompt="Same scene but at sunset",
|
| 212 |
+
backend="Gemini API (Cloud)",
|
| 213 |
+
aspect_ratio="3:4",
|
| 214 |
+
temperature=0.7,
|
| 215 |
+
input_images=[reference_image]
|
| 216 |
+
)
|
| 217 |
+
result = client.generate(request)
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### Multi-Image Context
|
| 221 |
+
```python
|
| 222 |
+
character_sheet = Image.open("character.png")
|
| 223 |
+
background = Image.open("background.png")
|
| 224 |
+
request = GenerationRequest(
|
| 225 |
+
prompt="Place the character in this background scene",
|
| 226 |
+
backend="Gemini API (Cloud)",
|
| 227 |
+
aspect_ratio="16:9",
|
| 228 |
+
temperature=0.6,
|
| 229 |
+
input_images=[character_sheet, background]
|
| 230 |
+
)
|
| 231 |
+
result = client.generate(request)
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
## Configuration
|
| 235 |
+
|
| 236 |
+
### Environment Variable
|
| 237 |
+
API key loaded from environment:
|
| 238 |
+
```python
|
| 239 |
+
import os
|
| 240 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
| 241 |
+
client = GeminiClient(api_key=api_key)
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
### Aspect Ratios Supported
|
| 245 |
+
Via Settings.ASPECT_RATIOS:
|
| 246 |
+
- 1:1 (1024x1024) - Square
|
| 247 |
+
- 16:9 (1344x768) - Landscape
|
| 248 |
+
- 9:16 (768x1344) - Portrait
|
| 249 |
+
- 3:2, 2:3, 3:4, 4:3, 4:5, 5:4, 21:9
|
| 250 |
+
|
| 251 |
+
### Temperature Range
|
| 252 |
+
- Min: 0.0 (most deterministic)
|
| 253 |
+
- Max: 1.0 (most creative)
|
| 254 |
+
- Controlled via Settings.MIN_TEMPERATURE / MAX_TEMPERATURE
|
| 255 |
+
|
| 256 |
+
## Related Files
|
| 257 |
+
- `core/backend_router.py` - Routes requests to this client
|
| 258 |
+
- `core/omnigen2_client.py` - Alternative backend implementation
|
| 259 |
+
- `models/generation_request.py` - Request structure
|
| 260 |
+
- `models/generation_result.py` - Result structure
|
| 261 |
+
- `config/settings.py` - Configuration and aspect ratios
|
| 262 |
+
- `character_forge.py` (old) - Original implementation source
|
character_forge_image/core/gemini_client.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini API Client
|
| 3 |
+
=================
|
| 4 |
+
|
| 5 |
+
Client for Google Gemini 2.5 Flash Image API.
|
| 6 |
+
Handles API communication and response parsing.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import base64
|
| 10 |
+
from io import BytesIO
|
| 11 |
+
from typing import Optional
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
from google import genai
|
| 15 |
+
from google.genai import types
|
| 16 |
+
|
| 17 |
+
from config.settings import Settings
|
| 18 |
+
from models.generation_request import GenerationRequest
|
| 19 |
+
from models.generation_result import GenerationResult
|
| 20 |
+
from utils.logging_utils import get_logger
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
logger = get_logger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GeminiClient:
|
| 27 |
+
"""
|
| 28 |
+
Client for Gemini 2.5 Flash Image API.
|
| 29 |
+
|
| 30 |
+
Handles API authentication, request formatting, and response parsing.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
MODEL_NAME = "gemini-2.5-flash-image"
|
| 34 |
+
|
| 35 |
+
def __init__(self, api_key: str):
|
| 36 |
+
"""
|
| 37 |
+
Initialize Gemini client.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
api_key: Google Gemini API key
|
| 41 |
+
"""
|
| 42 |
+
if not api_key:
|
| 43 |
+
raise ValueError("API key is required for Gemini client")
|
| 44 |
+
|
| 45 |
+
self.api_key = api_key
|
| 46 |
+
self.client = genai.Client(api_key=api_key)
|
| 47 |
+
logger.info("GeminiClient initialized")
|
| 48 |
+
|
| 49 |
+
def generate(self, request: GenerationRequest) -> GenerationResult:
|
| 50 |
+
"""
|
| 51 |
+
Generate image using Gemini API.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
request: GenerationRequest object
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
GenerationResult object
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
logger.info(f"Generating with Gemini: {request.prompt[:100]}")
|
| 61 |
+
|
| 62 |
+
# Build contents list
|
| 63 |
+
contents = self._build_contents(request)
|
| 64 |
+
|
| 65 |
+
# Build config
|
| 66 |
+
config = self._build_config(request)
|
| 67 |
+
|
| 68 |
+
# Call API
|
| 69 |
+
response = self.client.models.generate_content(
|
| 70 |
+
model=self.MODEL_NAME,
|
| 71 |
+
contents=contents,
|
| 72 |
+
config=config
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Parse response
|
| 76 |
+
return self._parse_response(response)
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"Gemini generation failed: {e}", exc_info=True)
|
| 80 |
+
return GenerationResult.error_result(
|
| 81 |
+
message=f"Gemini API error: {str(e)}"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
def _build_contents(self, request: GenerationRequest) -> list:
|
| 85 |
+
"""
|
| 86 |
+
Build contents list for API request.
|
| 87 |
+
|
| 88 |
+
For text-to-image: [prompt]
|
| 89 |
+
For image-to-image: [image1, image2, ..., prompt]
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
request: GenerationRequest
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
Contents list
|
| 96 |
+
"""
|
| 97 |
+
contents = []
|
| 98 |
+
|
| 99 |
+
# Add input images if present
|
| 100 |
+
if request.has_input_images:
|
| 101 |
+
# Filter out None images
|
| 102 |
+
valid_images = [img for img in request.input_images if img is not None]
|
| 103 |
+
contents.extend(valid_images)
|
| 104 |
+
|
| 105 |
+
# Add prompt
|
| 106 |
+
contents.append(request.prompt)
|
| 107 |
+
|
| 108 |
+
return contents
|
| 109 |
+
|
| 110 |
+
def _build_config(self, request: GenerationRequest) -> types.GenerateContentConfig:
|
| 111 |
+
"""
|
| 112 |
+
Build generation config for API request.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
request: GenerationRequest
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
GenerateContentConfig
|
| 119 |
+
"""
|
| 120 |
+
# Get aspect ratio value (convert display name if needed)
|
| 121 |
+
aspect_ratio = Settings.get_aspect_ratio_value(request.aspect_ratio)
|
| 122 |
+
if aspect_ratio == "1:1":
|
| 123 |
+
# Gemini expects just the ratio for most, but check if already in correct format
|
| 124 |
+
aspect_ratio = request.aspect_ratio.split()[0] if ' ' in request.aspect_ratio else request.aspect_ratio
|
| 125 |
+
|
| 126 |
+
config = types.GenerateContentConfig(
|
| 127 |
+
temperature=request.temperature,
|
| 128 |
+
image_config=types.ImageConfig(
|
| 129 |
+
aspect_ratio=aspect_ratio
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return config
|
| 134 |
+
|
| 135 |
+
def _parse_response(self, response) -> GenerationResult:
|
| 136 |
+
"""
|
| 137 |
+
Parse API response and extract image.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
response: API response object
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
GenerationResult
|
| 144 |
+
"""
|
| 145 |
+
# Validate response structure
|
| 146 |
+
if response is None:
|
| 147 |
+
return GenerationResult.error_result("No response from API")
|
| 148 |
+
|
| 149 |
+
if not hasattr(response, 'candidates') or not response.candidates:
|
| 150 |
+
return GenerationResult.error_result("No candidates in response")
|
| 151 |
+
|
| 152 |
+
candidate = response.candidates[0]
|
| 153 |
+
|
| 154 |
+
# Log finish_reason (but don't block on STOP - it's normal for successful generation)
|
| 155 |
+
if hasattr(candidate, 'finish_reason'):
|
| 156 |
+
finish_reason = str(candidate.finish_reason)
|
| 157 |
+
logger.info(f"Finish reason: {finish_reason}")
|
| 158 |
+
|
| 159 |
+
# Only block on actual safety issues
|
| 160 |
+
if 'SAFETY' in finish_reason or 'PROHIBITED' in finish_reason:
|
| 161 |
+
return GenerationResult.error_result(
|
| 162 |
+
f"Content blocked by safety filters: {finish_reason}"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# IMAGE_OTHER often means temporary API issues - log but allow retry
|
| 166 |
+
if 'IMAGE_OTHER' in finish_reason:
|
| 167 |
+
logger.warning(f"IMAGE_OTHER finish reason - this often indicates temporary API issues or prompt complexity")
|
| 168 |
+
|
| 169 |
+
# Check for content
|
| 170 |
+
if not hasattr(candidate, 'content') or candidate.content is None:
|
| 171 |
+
finish_reason = getattr(candidate, 'finish_reason', 'UNKNOWN')
|
| 172 |
+
return GenerationResult.error_result(
|
| 173 |
+
f"No content in response (finish_reason: {finish_reason})"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Log content structure
|
| 177 |
+
logger.info(f"Response has content: {hasattr(candidate.content, 'parts')}")
|
| 178 |
+
if hasattr(candidate.content, 'parts'):
|
| 179 |
+
logger.info(f"Number of parts: {len(candidate.content.parts) if candidate.content.parts else 0}")
|
| 180 |
+
|
| 181 |
+
# Extract image
|
| 182 |
+
if hasattr(candidate.content, 'parts') and candidate.content.parts:
|
| 183 |
+
for i, part in enumerate(candidate.content.parts):
|
| 184 |
+
logger.info(f"Part {i}: has inline_data={hasattr(part, 'inline_data')}, has text={hasattr(part, 'text')}")
|
| 185 |
+
if hasattr(part, 'inline_data') and part.inline_data:
|
| 186 |
+
try:
|
| 187 |
+
# Get image data
|
| 188 |
+
image_data = part.inline_data.data
|
| 189 |
+
|
| 190 |
+
# Log what we're getting
|
| 191 |
+
mime_type = getattr(part.inline_data, 'mime_type', 'unknown')
|
| 192 |
+
data_type = type(image_data).__name__
|
| 193 |
+
logger.info(f"Got inline_data: mime_type={mime_type}, data_type={data_type}")
|
| 194 |
+
|
| 195 |
+
# Handle both bytes and base64 string formats (like old Gradio code)
|
| 196 |
+
if isinstance(image_data, str):
|
| 197 |
+
logger.info("Data is string, decoding base64...")
|
| 198 |
+
image_data = base64.b64decode(image_data)
|
| 199 |
+
else:
|
| 200 |
+
logger.info("Data is already bytes, using directly")
|
| 201 |
+
|
| 202 |
+
logger.info(f"Final image data: {len(image_data)} bytes")
|
| 203 |
+
|
| 204 |
+
# Convert to PIL Image
|
| 205 |
+
image_buffer = BytesIO(image_data)
|
| 206 |
+
image = Image.open(image_buffer)
|
| 207 |
+
|
| 208 |
+
# Force load to verify it's valid
|
| 209 |
+
image.load()
|
| 210 |
+
|
| 211 |
+
logger.info(f"✅ Image successfully generated with Gemini: {image.size}, {image.mode}")
|
| 212 |
+
return GenerationResult.success_result(
|
| 213 |
+
image=image,
|
| 214 |
+
message="Generated successfully"
|
| 215 |
+
)
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"Failed to decode image: {e}")
|
| 218 |
+
logger.error(f"Data type: {type(part.inline_data.data)}")
|
| 219 |
+
logger.error(f"Data length: {len(part.inline_data.data) if hasattr(part.inline_data.data, '__len__') else 'N/A'}")
|
| 220 |
+
# Try to log first few bytes
|
| 221 |
+
try:
|
| 222 |
+
decoded = base64.b64decode(part.inline_data.data)
|
| 223 |
+
logger.error(f"First 20 bytes: {decoded[:20]}")
|
| 224 |
+
except:
|
| 225 |
+
pass
|
| 226 |
+
return GenerationResult.error_result(
|
| 227 |
+
f"Image decoding error: {str(e)}"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
return GenerationResult.error_result("No image data in response")
|
| 231 |
+
|
| 232 |
+
def is_healthy(self) -> bool:
|
| 233 |
+
"""
|
| 234 |
+
Check if Gemini API is accessible.
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
True if API key is set
|
| 238 |
+
"""
|
| 239 |
+
return self.api_key is not None and len(self.api_key) > 0
|
character_forge_image/core/omnigen2_client.md
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# omnigen2_client.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Client wrapper for OmniGen2 local HTTP API server. Handles communication with locally-hosted OmniGen2 model, including text-to-image, image editing, and multi-image in-context generation.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Communicate with OmniGen2 HTTP API endpoints
|
| 8 |
+
- Convert Gemini-style aspect ratios to pixel dimensions
|
| 9 |
+
- Map temperature parameter to guidance_scale
|
| 10 |
+
- Route requests to appropriate endpoints based on input count
|
| 11 |
+
- Encode/decode images as base64 for HTTP transport
|
| 12 |
+
- Check server health and model load status
|
| 13 |
+
- Handle server connection errors and timeouts
|
| 14 |
+
|
| 15 |
+
## Dependencies
|
| 16 |
+
- `requests` - HTTP client for API communication
|
| 17 |
+
- `config.settings.Settings` - Server URL and timeout configuration
|
| 18 |
+
- `models.generation_request.GenerationRequest` - Request dataclass
|
| 19 |
+
- `models.generation_result.GenerationResult` - Result dataclass
|
| 20 |
+
- `PIL.Image` - Image handling
|
| 21 |
+
- `base64` - Image encoding/decoding
|
| 22 |
+
- `io.BytesIO` - Binary stream handling
|
| 23 |
+
- `typing.Tuple` - Type hints
|
| 24 |
+
- `utils.logging_utils` - Logging
|
| 25 |
+
|
| 26 |
+
## Source
|
| 27 |
+
Based on `omnigen2_backend.py` from OmniGen2 plugin integration.
|
| 28 |
+
|
| 29 |
+
## Public Interface
|
| 30 |
+
|
| 31 |
+
### `OmniGen2Client` class
|
| 32 |
+
|
| 33 |
+
**Constants:**
|
| 34 |
+
```python
|
| 35 |
+
ASPECT_RATIOS = {
|
| 36 |
+
"1:1": (1024, 1024),
|
| 37 |
+
"16:9": (1344, 768),
|
| 38 |
+
"9:16": (768, 1344),
|
| 39 |
+
"3:2": (1248, 832),
|
| 40 |
+
"2:3": (832, 1248),
|
| 41 |
+
"3:4": (1008, 1344),
|
| 42 |
+
"4:3": (1344, 1008),
|
| 43 |
+
"4:5": (1024, 1280),
|
| 44 |
+
"5:4": (1280, 1024),
|
| 45 |
+
"21:9": (1536, 640),
|
| 46 |
+
}
|
| 47 |
+
```
|
| 48 |
+
Maps Gemini-style aspect ratio strings to (width, height) tuples.
|
| 49 |
+
|
| 50 |
+
**Constructor:**
|
| 51 |
+
```python
|
| 52 |
+
def __init__(self, base_url: str = None)
|
| 53 |
+
```
|
| 54 |
+
- `base_url`: Optional server URL (defaults to Settings.OMNIGEN2_BASE_URL: "http://127.0.0.1:8000")
|
| 55 |
+
- Strips trailing slashes from URL
|
| 56 |
+
- Logs initialization
|
| 57 |
+
|
| 58 |
+
**Key Methods:**
|
| 59 |
+
|
| 60 |
+
#### `generate(request: GenerationRequest) -> GenerationResult`
|
| 61 |
+
Generate image using OmniGen2 local server.
|
| 62 |
+
|
| 63 |
+
**Parameters:**
|
| 64 |
+
- `request`: GenerationRequest with prompt, aspect_ratio, temperature, optional input_images
|
| 65 |
+
|
| 66 |
+
**Returns:**
|
| 67 |
+
- `GenerationResult` with success/failure status, image, and message
|
| 68 |
+
|
| 69 |
+
**Routing Logic:**
|
| 70 |
+
- 0 input images → Text-to-image (`/text-to-image` endpoint)
|
| 71 |
+
- 1 input image → Image editing (`/edit-image` endpoint)
|
| 72 |
+
- 2+ input images → In-context generation (`/in-context` endpoint)
|
| 73 |
+
|
| 74 |
+
**Parameter Conversion:**
|
| 75 |
+
- Aspect ratio: "16:9 (1344x768)" → (1344, 768)
|
| 76 |
+
- Temperature: 0.0-1.0 → guidance_scale 1.0-5.0
|
| 77 |
+
- Formula: `guidance_scale = 1.0 + (temperature * 4.0)`
|
| 78 |
+
|
| 79 |
+
**Usage:**
|
| 80 |
+
```python
|
| 81 |
+
client = OmniGen2Client()
|
| 82 |
+
request = GenerationRequest(
|
| 83 |
+
prompt="A futuristic city skyline",
|
| 84 |
+
backend="OmniGen2 (Local)",
|
| 85 |
+
aspect_ratio="16:9",
|
| 86 |
+
temperature=0.8
|
| 87 |
+
)
|
| 88 |
+
result = client.generate(request)
|
| 89 |
+
if result.success:
|
| 90 |
+
result.image.save("output.png")
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
#### `is_healthy() -> bool`
|
| 94 |
+
Check if OmniGen2 server is running and model is loaded.
|
| 95 |
+
|
| 96 |
+
**Returns:**
|
| 97 |
+
- `True` if server responds to `/health` endpoint with status="healthy" and model_loaded=True
|
| 98 |
+
- `False` if server unreachable or model not loaded
|
| 99 |
+
|
| 100 |
+
**Health Endpoint Response:**
|
| 101 |
+
```json
|
| 102 |
+
{
|
| 103 |
+
"status": "healthy",
|
| 104 |
+
"model_loaded": true
|
| 105 |
+
}
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**Usage:**
|
| 109 |
+
```python
|
| 110 |
+
if not client.is_healthy():
|
| 111 |
+
print("OmniGen2 server not running. Start with: omnigen2_plugin/server.bat start")
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
## Private Methods
|
| 115 |
+
|
| 116 |
+
### `_generate_text_to_image(prompt, width, height, guidance_scale) -> Image.Image`
|
| 117 |
+
Call `/text-to-image` endpoint for text-to-image generation.
|
| 118 |
+
|
| 119 |
+
**Request Payload:**
|
| 120 |
+
```json
|
| 121 |
+
{
|
| 122 |
+
"prompt": "A magical forest",
|
| 123 |
+
"width": 1344,
|
| 124 |
+
"height": 768,
|
| 125 |
+
"num_inference_steps": 50,
|
| 126 |
+
"guidance_scale": 4.2
|
| 127 |
+
}
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
**Returns:**
|
| 131 |
+
- PIL.Image decoded from base64 response
|
| 132 |
+
|
| 133 |
+
**Timeout:**
|
| 134 |
+
- Uses Settings.BACKEND_TIMEOUT (300 seconds / 5 minutes)
|
| 135 |
+
|
| 136 |
+
### `_edit_image(prompt, input_image, width, height, guidance_scale) -> Image.Image`
|
| 137 |
+
Call `/edit-image` endpoint for single image editing.
|
| 138 |
+
|
| 139 |
+
**Request Payload:**
|
| 140 |
+
```json
|
| 141 |
+
{
|
| 142 |
+
"prompt": "Make it nighttime",
|
| 143 |
+
"input_image": "base64_encoded_image_data...",
|
| 144 |
+
"width": 1024,
|
| 145 |
+
"height": 1024,
|
| 146 |
+
"num_inference_steps": 50,
|
| 147 |
+
"guidance_scale": 3.8
|
| 148 |
+
}
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
**Use Cases:**
|
| 152 |
+
- Style transfer
|
| 153 |
+
- Object modification
|
| 154 |
+
- Scene variation
|
| 155 |
+
|
| 156 |
+
### `_generate_in_context(prompt, input_images, width, height, guidance_scale) -> Image.Image`
|
| 157 |
+
Call `/in-context` endpoint for multi-image generation.
|
| 158 |
+
|
| 159 |
+
**Request Payload:**
|
| 160 |
+
```json
|
| 161 |
+
{
|
| 162 |
+
"prompt": "Combine these characters in one scene",
|
| 163 |
+
"input_images": ["base64_image1...", "base64_image2..."],
|
| 164 |
+
"width": 1344,
|
| 165 |
+
"height": 768,
|
| 166 |
+
"num_inference_steps": 50,
|
| 167 |
+
"guidance_scale": 3.4
|
| 168 |
+
}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
**Use Cases:**
|
| 172 |
+
- Character sheet composition
|
| 173 |
+
- Multi-character scenes
|
| 174 |
+
- Reference-based generation
|
| 175 |
+
|
| 176 |
+
### `_parse_aspect_ratio(aspect_ratio: str) -> Tuple[int, int]`
|
| 177 |
+
Convert aspect ratio string to (width, height) pixels.
|
| 178 |
+
|
| 179 |
+
**Input Formats Handled:**
|
| 180 |
+
- "16:9" → (1344, 768)
|
| 181 |
+
- "16:9 (1344x768)" → (1344, 768) (strips display format)
|
| 182 |
+
|
| 183 |
+
**Fallback:**
|
| 184 |
+
- Unknown ratios default to "1:1" (1024, 1024)
|
| 185 |
+
- Logs warning for unknown ratios
|
| 186 |
+
|
| 187 |
+
### `_image_to_base64(image: Image.Image) -> str`
|
| 188 |
+
Convert PIL Image to base64 string for HTTP transport.
|
| 189 |
+
|
| 190 |
+
**Process:**
|
| 191 |
+
1. Save image to BytesIO buffer as PNG
|
| 192 |
+
2. Encode buffer bytes as base64
|
| 193 |
+
3. Decode to UTF-8 string
|
| 194 |
+
|
| 195 |
+
**Returns:**
|
| 196 |
+
```python
|
| 197 |
+
"iVBORw0KGgoAAAANSUhEUgAA..." # Base64 string
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
### `_base64_to_image(b64_string: str) -> Image.Image`
|
| 201 |
+
Convert base64 string to PIL Image.
|
| 202 |
+
|
| 203 |
+
**Process:**
|
| 204 |
+
1. Decode base64 string to bytes
|
| 205 |
+
2. Wrap bytes in BytesIO
|
| 206 |
+
3. Open as PIL.Image
|
| 207 |
+
|
| 208 |
+
## API Endpoints
|
| 209 |
+
|
| 210 |
+
### `/text-to-image` (POST)
|
| 211 |
+
**Purpose:** Generate image from text prompt only
|
| 212 |
+
|
| 213 |
+
**Request:**
|
| 214 |
+
```json
|
| 215 |
+
{
|
| 216 |
+
"prompt": str,
|
| 217 |
+
"width": int,
|
| 218 |
+
"height": int,
|
| 219 |
+
"num_inference_steps": int, # Fixed at 50
|
| 220 |
+
"guidance_scale": float # Converted from temperature
|
| 221 |
+
}
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
**Response:**
|
| 225 |
+
```json
|
| 226 |
+
{
|
| 227 |
+
"image": "base64_encoded_image"
|
| 228 |
+
}
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
### `/edit-image` (POST)
|
| 232 |
+
**Purpose:** Edit/modify single input image
|
| 233 |
+
|
| 234 |
+
**Request:**
|
| 235 |
+
```json
|
| 236 |
+
{
|
| 237 |
+
"prompt": str,
|
| 238 |
+
"input_image": "base64_encoded_image",
|
| 239 |
+
"width": int,
|
| 240 |
+
"height": int,
|
| 241 |
+
"num_inference_steps": int,
|
| 242 |
+
"guidance_scale": float
|
| 243 |
+
}
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
**Response:**
|
| 247 |
+
```json
|
| 248 |
+
{
|
| 249 |
+
"image": "base64_encoded_image"
|
| 250 |
+
}
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
### `/in-context` (POST)
|
| 254 |
+
**Purpose:** Generate using multiple reference images
|
| 255 |
+
|
| 256 |
+
**Request:**
|
| 257 |
+
```json
|
| 258 |
+
{
|
| 259 |
+
"prompt": str,
|
| 260 |
+
"input_images": ["base64_1", "base64_2", ...],
|
| 261 |
+
"width": int,
|
| 262 |
+
"height": int,
|
| 263 |
+
"num_inference_steps": int,
|
| 264 |
+
"guidance_scale": float
|
| 265 |
+
}
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
**Response:**
|
| 269 |
+
```json
|
| 270 |
+
{
|
| 271 |
+
"image": "base64_encoded_image"
|
| 272 |
+
}
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
### `/health` (GET)
|
| 276 |
+
**Purpose:** Check server and model status
|
| 277 |
+
|
| 278 |
+
**Response:**
|
| 279 |
+
```json
|
| 280 |
+
{
|
| 281 |
+
"status": "healthy",
|
| 282 |
+
"model_loaded": true
|
| 283 |
+
}
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
## Parameter Conversion
|
| 287 |
+
|
| 288 |
+
### Temperature to Guidance Scale
|
| 289 |
+
Gemini uses temperature (0.0-1.0), OmniGen2 uses guidance_scale (1.0-5.0):
|
| 290 |
+
|
| 291 |
+
```python
|
| 292 |
+
guidance_scale = 1.0 + (temperature * 4.0)
|
| 293 |
+
|
| 294 |
+
# Examples:
|
| 295 |
+
# temperature=0.0 → guidance_scale=1.0 (most faithful to prompt)
|
| 296 |
+
# temperature=0.5 → guidance_scale=3.0 (balanced)
|
| 297 |
+
# temperature=1.0 → guidance_scale=5.0 (most creative)
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
### Aspect Ratio to Dimensions
|
| 301 |
+
Consistent pixel counts for similar aspect ratios:
|
| 302 |
+
|
| 303 |
+
| Ratio | Pixels | Use Case |
|
| 304 |
+
|-------|--------|----------|
|
| 305 |
+
| 1:1 | 1024x1024 | Square, icons |
|
| 306 |
+
| 16:9 | 1344x768 | Landscape, scenes |
|
| 307 |
+
| 9:16 | 768x1344 | Portrait, mobile |
|
| 308 |
+
| 3:4 | 1008x1344 | Character portraits |
|
| 309 |
+
| 4:3 | 1344x1008 | Classic landscape |
|
| 310 |
+
| 21:9 | 1536x640 | Ultra-wide |
|
| 311 |
+
|
| 312 |
+
## Error Handling
|
| 313 |
+
|
| 314 |
+
### Server Not Running
|
| 315 |
+
```python
|
| 316 |
+
if not self.is_healthy():
|
| 317 |
+
return GenerationResult.error_result(
|
| 318 |
+
"OmniGen2 server not running. Start it with: omnigen2_plugin/server.bat start"
|
| 319 |
+
)
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
### HTTP Errors
|
| 323 |
+
```python
|
| 324 |
+
response.raise_for_status() # Raises HTTPError for 4xx/5xx
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
### Timeouts
|
| 328 |
+
Set via Settings.BACKEND_TIMEOUT (300 seconds):
|
| 329 |
+
```python
|
| 330 |
+
requests.post(..., timeout=Settings.BACKEND_TIMEOUT)
|
| 331 |
+
```
|
| 332 |
+
|
| 333 |
+
### General Exceptions
|
| 334 |
+
All caught and converted to error results:
|
| 335 |
+
```python
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(f"OmniGen2 generation failed: {e}", exc_info=True)
|
| 338 |
+
return GenerationResult.error_result(f"OmniGen2 error: {str(e)}")
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
## Usage Examples
|
| 342 |
+
|
| 343 |
+
### Text-to-Image
|
| 344 |
+
```python
|
| 345 |
+
client = OmniGen2Client()
|
| 346 |
+
request = GenerationRequest(
|
| 347 |
+
prompt="A steampunk airship in the clouds",
|
| 348 |
+
backend="OmniGen2 (Local)",
|
| 349 |
+
aspect_ratio="16:9",
|
| 350 |
+
temperature=0.7
|
| 351 |
+
)
|
| 352 |
+
result = client.generate(request)
|
| 353 |
+
```
|
| 354 |
+
|
| 355 |
+
### Single Image Edit
|
| 356 |
+
```python
|
| 357 |
+
original = Image.open("portrait.png")
|
| 358 |
+
request = GenerationRequest(
|
| 359 |
+
prompt="Add fantasy armor to the character",
|
| 360 |
+
backend="OmniGen2 (Local)",
|
| 361 |
+
aspect_ratio="3:4",
|
| 362 |
+
temperature=0.6,
|
| 363 |
+
input_images=[original]
|
| 364 |
+
)
|
| 365 |
+
result = client.generate(request)
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
### Multi-Image Composition
|
| 369 |
+
```python
|
| 370 |
+
char1 = Image.open("character1.png")
|
| 371 |
+
char2 = Image.open("character2.png")
|
| 372 |
+
background = Image.open("scene.png")
|
| 373 |
+
request = GenerationRequest(
|
| 374 |
+
prompt="Place both characters in the background scene, interacting naturally",
|
| 375 |
+
backend="OmniGen2 (Local)",
|
| 376 |
+
aspect_ratio="16:9",
|
| 377 |
+
temperature=0.5,
|
| 378 |
+
input_images=[char1, char2, background]
|
| 379 |
+
)
|
| 380 |
+
result = client.generate(request)
|
| 381 |
+
```
|
| 382 |
+
|
| 383 |
+
## Server Setup
|
| 384 |
+
|
| 385 |
+
### Starting Server
|
| 386 |
+
```bash
|
| 387 |
+
# Windows
|
| 388 |
+
omnigen2_plugin\server.bat start
|
| 389 |
+
|
| 390 |
+
# Linux/Mac
|
| 391 |
+
./omnigen2_plugin/server.sh start
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
### Configuration
|
| 395 |
+
Server URL set in Settings:
|
| 396 |
+
```python
|
| 397 |
+
OMNIGEN2_BASE_URL = "http://127.0.0.1:8000"
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
### Requirements
|
| 401 |
+
- OmniGen2 model downloaded
|
| 402 |
+
- Sufficient GPU memory (12GB+ recommended)
|
| 403 |
+
- Python environment with OmniGen2 dependencies
|
| 404 |
+
|
| 405 |
+
## Related Files
|
| 406 |
+
- `core/backend_router.py` - Routes requests to this client
|
| 407 |
+
- `core/gemini_client.py` - Alternative cloud backend
|
| 408 |
+
- `models/generation_request.py` - Request structure
|
| 409 |
+
- `models/generation_result.py` - Result structure
|
| 410 |
+
- `config/settings.py` - Server URL and timeout configuration
|
| 411 |
+
- `omnigen2_plugin/server.py` - Local server implementation
|
| 412 |
+
- `omnigen2_backend.py` (old) - Original implementation source
|
character_forge_image/core/omnigen2_client.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OmniGen2 Local API Client
|
| 3 |
+
==========================
|
| 4 |
+
|
| 5 |
+
Client for OmniGen2 local API server.
|
| 6 |
+
Handles HTTP communication and response parsing.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import requests
|
| 10 |
+
import base64
|
| 11 |
+
from io import BytesIO
|
| 12 |
+
from typing import Tuple
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
from config.settings import Settings
|
| 16 |
+
from models.generation_request import GenerationRequest
|
| 17 |
+
from models.generation_result import GenerationResult
|
| 18 |
+
from utils.logging_utils import get_logger
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
logger = get_logger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class OmniGen2Client:
|
| 25 |
+
"""
|
| 26 |
+
Client for OmniGen2 local API server.
|
| 27 |
+
|
| 28 |
+
Communicates with the OmniGen2 HTTP API running on localhost.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
# Aspect ratio mapping: Gemini notation -> (width, height)
|
| 32 |
+
ASPECT_RATIOS = {
|
| 33 |
+
"1:1": (1024, 1024),
|
| 34 |
+
"16:9": (1344, 768),
|
| 35 |
+
"9:16": (768, 1344),
|
| 36 |
+
"3:2": (1248, 832),
|
| 37 |
+
"2:3": (832, 1248),
|
| 38 |
+
"3:4": (1008, 1344),
|
| 39 |
+
"4:3": (1344, 1008),
|
| 40 |
+
"4:5": (1024, 1280),
|
| 41 |
+
"5:4": (1280, 1024),
|
| 42 |
+
"21:9": (1536, 640),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def __init__(self, base_url: str = None):
|
| 46 |
+
"""
|
| 47 |
+
Initialize OmniGen2 client.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
base_url: Base URL of server (default: from Settings)
|
| 51 |
+
"""
|
| 52 |
+
self.base_url = (base_url or Settings.OMNIGEN2_BASE_URL).rstrip('/')
|
| 53 |
+
logger.info(f"OmniGen2Client initialized: {self.base_url}")
|
| 54 |
+
|
| 55 |
+
def generate(self, request: GenerationRequest) -> GenerationResult:
|
| 56 |
+
"""
|
| 57 |
+
Generate image using OmniGen2.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
request: GenerationRequest object
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
GenerationResult object
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
logger.info(f"Generating with OmniGen2: {request.prompt[:100]}")
|
| 67 |
+
|
| 68 |
+
# Check server health
|
| 69 |
+
if not self.is_healthy():
|
| 70 |
+
return GenerationResult.error_result(
|
| 71 |
+
"OmniGen2 server not running. Start it with: omnigen2_plugin/server.bat start"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Parse dimensions
|
| 75 |
+
width, height = self._parse_aspect_ratio(request.aspect_ratio)
|
| 76 |
+
|
| 77 |
+
# Map temperature to guidance_scale (Gemini 0.0-1.0 -> OmniGen2 1.0-5.0)
|
| 78 |
+
guidance_scale = 1.0 + (request.temperature * 4.0)
|
| 79 |
+
|
| 80 |
+
logger.info(f"OmniGen2 params: {width}x{height}, guidance={guidance_scale:.1f}")
|
| 81 |
+
|
| 82 |
+
# Route to appropriate endpoint
|
| 83 |
+
if not request.has_input_images:
|
| 84 |
+
# Text-to-image
|
| 85 |
+
image = self._generate_text_to_image(
|
| 86 |
+
prompt=request.prompt,
|
| 87 |
+
width=width,
|
| 88 |
+
height=height,
|
| 89 |
+
guidance_scale=guidance_scale
|
| 90 |
+
)
|
| 91 |
+
elif request.image_count == 1:
|
| 92 |
+
# Edit single image
|
| 93 |
+
image = self._edit_image(
|
| 94 |
+
prompt=request.prompt,
|
| 95 |
+
input_image=request.input_images[0],
|
| 96 |
+
width=width,
|
| 97 |
+
height=height,
|
| 98 |
+
guidance_scale=guidance_scale
|
| 99 |
+
)
|
| 100 |
+
else:
|
| 101 |
+
# Multi-image in-context
|
| 102 |
+
image = self._generate_in_context(
|
| 103 |
+
prompt=request.prompt,
|
| 104 |
+
input_images=request.input_images,
|
| 105 |
+
width=width,
|
| 106 |
+
height=height,
|
| 107 |
+
guidance_scale=guidance_scale
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if image:
|
| 111 |
+
logger.info("Image successfully generated with OmniGen2")
|
| 112 |
+
return GenerationResult.success_result(
|
| 113 |
+
image=image,
|
| 114 |
+
message="Generated successfully"
|
| 115 |
+
)
|
| 116 |
+
else:
|
| 117 |
+
return GenerationResult.error_result("No image returned from server")
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"OmniGen2 generation failed: {e}", exc_info=True)
|
| 121 |
+
return GenerationResult.error_result(
|
| 122 |
+
f"OmniGen2 error: {str(e)}"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def _generate_text_to_image(self, prompt: str, width: int, height: int, guidance_scale: float) -> Image.Image:
|
| 126 |
+
"""Text-to-image generation."""
|
| 127 |
+
response = requests.post(
|
| 128 |
+
f"{self.base_url}/api/v1/text-to-image",
|
| 129 |
+
json={
|
| 130 |
+
"prompt": prompt,
|
| 131 |
+
"width": width,
|
| 132 |
+
"height": height,
|
| 133 |
+
"num_inference_steps": 50,
|
| 134 |
+
"guidance_scale": guidance_scale
|
| 135 |
+
},
|
| 136 |
+
timeout=Settings.BACKEND_TIMEOUT
|
| 137 |
+
)
|
| 138 |
+
response.raise_for_status()
|
| 139 |
+
data = response.json()
|
| 140 |
+
return self._base64_to_image(data['image'])
|
| 141 |
+
|
| 142 |
+
def _edit_image(self, prompt: str, input_image: Image.Image, width: int, height: int, guidance_scale: float) -> Image.Image:
|
| 143 |
+
"""Edit single image."""
|
| 144 |
+
# Convert image to bytes for file upload
|
| 145 |
+
img_buffer = BytesIO()
|
| 146 |
+
input_image.save(img_buffer, format="PNG")
|
| 147 |
+
img_buffer.seek(0)
|
| 148 |
+
|
| 149 |
+
# Send as multipart/form-data with file upload
|
| 150 |
+
files = {
|
| 151 |
+
'file': ('image.png', img_buffer, 'image/png')
|
| 152 |
+
}
|
| 153 |
+
data = {
|
| 154 |
+
'instruction': prompt,
|
| 155 |
+
'num_inference_steps': 50,
|
| 156 |
+
'text_guidance_scale': guidance_scale
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
response = requests.post(
|
| 160 |
+
f"{self.base_url}/api/v1/edit-image",
|
| 161 |
+
files=files,
|
| 162 |
+
data=data,
|
| 163 |
+
timeout=Settings.BACKEND_TIMEOUT
|
| 164 |
+
)
|
| 165 |
+
response.raise_for_status()
|
| 166 |
+
result = response.json()
|
| 167 |
+
return self._base64_to_image(result['image'])
|
| 168 |
+
|
| 169 |
+
def _generate_in_context(self, prompt: str, input_images: list, width: int, height: int, guidance_scale: float) -> Image.Image:
|
| 170 |
+
"""Multi-image in-context generation."""
|
| 171 |
+
# Convert all images to file uploads
|
| 172 |
+
files = []
|
| 173 |
+
for i, img in enumerate(input_images):
|
| 174 |
+
img_buffer = BytesIO()
|
| 175 |
+
img.save(img_buffer, format="PNG")
|
| 176 |
+
img_buffer.seek(0)
|
| 177 |
+
files.append(('files', (f'image_{i}.png', img_buffer, 'image/png')))
|
| 178 |
+
|
| 179 |
+
# Form data
|
| 180 |
+
data = {
|
| 181 |
+
'prompt': prompt,
|
| 182 |
+
'width': width,
|
| 183 |
+
'height': height,
|
| 184 |
+
'num_inference_steps': 50,
|
| 185 |
+
'text_guidance_scale': guidance_scale
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
response = requests.post(
|
| 189 |
+
f"{self.base_url}/api/v1/in-context",
|
| 190 |
+
files=files,
|
| 191 |
+
data=data,
|
| 192 |
+
timeout=Settings.BACKEND_TIMEOUT
|
| 193 |
+
)
|
| 194 |
+
response.raise_for_status()
|
| 195 |
+
result = response.json()
|
| 196 |
+
return self._base64_to_image(result['image'])
|
| 197 |
+
|
| 198 |
+
def _parse_aspect_ratio(self, aspect_ratio: str) -> Tuple[int, int]:
|
| 199 |
+
"""Convert aspect ratio string to (width, height)."""
|
| 200 |
+
# Handle full format like "16:9 (1344x768)"
|
| 201 |
+
if "(" in aspect_ratio:
|
| 202 |
+
aspect_ratio = aspect_ratio.split("(")[0].strip()
|
| 203 |
+
|
| 204 |
+
if aspect_ratio in self.ASPECT_RATIOS:
|
| 205 |
+
return self.ASPECT_RATIOS[aspect_ratio]
|
| 206 |
+
|
| 207 |
+
logger.warning(f"Unknown aspect ratio '{aspect_ratio}', using 1:1")
|
| 208 |
+
return self.ASPECT_RATIOS["1:1"]
|
| 209 |
+
|
| 210 |
+
def _image_to_base64(self, image: Image.Image) -> str:
|
| 211 |
+
"""Convert PIL Image to base64 string (uncompressed for quality)."""
|
| 212 |
+
buffered = BytesIO()
|
| 213 |
+
image.save(buffered, format="PNG", compress_level=0)
|
| 214 |
+
return base64.b64encode(buffered.getvalue()).decode()
|
| 215 |
+
|
| 216 |
+
def _base64_to_image(self, b64_string: str) -> Image.Image:
|
| 217 |
+
"""Convert base64 string to PIL Image."""
|
| 218 |
+
image_data = base64.b64decode(b64_string)
|
| 219 |
+
return Image.open(BytesIO(image_data))
|
| 220 |
+
|
| 221 |
+
def is_healthy(self) -> bool:
|
| 222 |
+
"""
|
| 223 |
+
Check if OmniGen2 server is running and healthy.
|
| 224 |
+
|
| 225 |
+
Returns:
|
| 226 |
+
True if server is accessible (model loads on first request)
|
| 227 |
+
"""
|
| 228 |
+
try:
|
| 229 |
+
response = requests.get(f"{self.base_url}/health", timeout=5)
|
| 230 |
+
if response.ok:
|
| 231 |
+
data = response.json()
|
| 232 |
+
# Server is running, model will load on first request
|
| 233 |
+
return data.get('status') == 'healthy'
|
| 234 |
+
return False
|
| 235 |
+
except Exception:
|
| 236 |
+
return False
|
character_forge_image/models/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models package for Nano Banana Streamlit."""
|
| 2 |
+
|
| 3 |
+
from models.generation_request import GenerationRequest
|
| 4 |
+
from models.generation_result import GenerationResult
|
| 5 |
+
|
| 6 |
+
__all__ = ['GenerationRequest', 'GenerationResult']
|
character_forge_image/models/generation_request.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# generation_request.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Data model for image generation requests. Provides type-safe, validated structure for all generation parameters.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Encapsulate all generation request parameters
|
| 8 |
+
- Validate data on initialization
|
| 9 |
+
- Provide convenience properties (is_text_to_image, etc.)
|
| 10 |
+
- Serialize to dictionary for logging/metadata
|
| 11 |
+
|
| 12 |
+
## Dependencies
|
| 13 |
+
- `dataclasses` - Data class decorator
|
| 14 |
+
- `PIL.Image` - Image type
|
| 15 |
+
- Used by all services and backend clients
|
| 16 |
+
|
| 17 |
+
## Public Interface
|
| 18 |
+
|
| 19 |
+
### `GenerationRequest` dataclass
|
| 20 |
+
|
| 21 |
+
**Fields:**
|
| 22 |
+
- `prompt: str` - Text prompt (required)
|
| 23 |
+
- `backend: str` - Backend name (required)
|
| 24 |
+
- `aspect_ratio: str` - Aspect ratio (required)
|
| 25 |
+
- `temperature: float` - Temperature 0.0-1.0 (required)
|
| 26 |
+
- `input_images: List[Image]` - Input images (optional)
|
| 27 |
+
- `is_character_sheet: List[bool]` - Character sheet flags (optional)
|
| 28 |
+
- `metadata: dict` - Additional metadata (optional)
|
| 29 |
+
|
| 30 |
+
**Properties:**
|
| 31 |
+
- `has_input_images: bool` - True if has input images
|
| 32 |
+
- `image_count: int` - Number of input images
|
| 33 |
+
- `is_text_to_image: bool` - True if text-to-image mode
|
| 34 |
+
- `is_image_to_image: bool` - True if image-to-image mode
|
| 35 |
+
|
| 36 |
+
**Methods:**
|
| 37 |
+
- `to_dict() -> dict` - Convert to dictionary (excludes images)
|
| 38 |
+
|
| 39 |
+
**Usage:**
|
| 40 |
+
```python
|
| 41 |
+
request = GenerationRequest(
|
| 42 |
+
prompt="sunset over mountains",
|
| 43 |
+
backend="Gemini API (Cloud)",
|
| 44 |
+
aspect_ratio="16:9",
|
| 45 |
+
temperature=0.4,
|
| 46 |
+
input_images=[img1, img2]
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
if request.is_image_to_image:
|
| 50 |
+
# Handle multi-image
|
| 51 |
+
pass
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## Related Files
|
| 55 |
+
- `models/generation_result.py` - Result model
|
| 56 |
+
- All services - Create requests
|
| 57 |
+
- `core/backend_router.py` - Receives requests
|
character_forge_image/models/generation_request.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generation Request Model
|
| 3 |
+
========================
|
| 4 |
+
|
| 5 |
+
Data model for image generation requests.
|
| 6 |
+
Provides type-safe structure for all generation parameters.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import List, Optional
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class GenerationRequest:
|
| 16 |
+
"""
|
| 17 |
+
Represents a request for image generation.
|
| 18 |
+
|
| 19 |
+
This is the standard interface for all generation requests,
|
| 20 |
+
regardless of backend or generation type.
|
| 21 |
+
|
| 22 |
+
Attributes:
|
| 23 |
+
prompt: Text prompt describing desired image
|
| 24 |
+
backend: Backend to use ("Gemini API (Cloud)" or "OmniGen2 (Local)")
|
| 25 |
+
aspect_ratio: Aspect ratio (e.g. "16:9", "3:4")
|
| 26 |
+
temperature: Temperature/creativity parameter (0.0-1.0)
|
| 27 |
+
input_images: Optional list of input images for image-to-image
|
| 28 |
+
is_character_sheet: Mark input images as character sheets
|
| 29 |
+
metadata: Additional metadata for tracking
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
# Required fields
|
| 33 |
+
prompt: str
|
| 34 |
+
backend: str
|
| 35 |
+
aspect_ratio: str
|
| 36 |
+
temperature: float
|
| 37 |
+
|
| 38 |
+
# Optional fields
|
| 39 |
+
input_images: Optional[List[Image.Image]] = None
|
| 40 |
+
is_character_sheet: List[bool] = field(default_factory=list)
|
| 41 |
+
metadata: dict = field(default_factory=dict)
|
| 42 |
+
negative_prompt: Optional[str] = None
|
| 43 |
+
seed: Optional[int] = None
|
| 44 |
+
|
| 45 |
+
def __post_init__(self):
|
| 46 |
+
"""Validate and normalize data after initialization."""
|
| 47 |
+
# Ensure prompt is string
|
| 48 |
+
if not isinstance(self.prompt, str):
|
| 49 |
+
raise TypeError("prompt must be a string")
|
| 50 |
+
|
| 51 |
+
# Strip whitespace from prompt
|
| 52 |
+
self.prompt = self.prompt.strip()
|
| 53 |
+
|
| 54 |
+
# Ensure temperature is numeric
|
| 55 |
+
if not isinstance(self.temperature, (int, float)):
|
| 56 |
+
raise TypeError("temperature must be numeric")
|
| 57 |
+
|
| 58 |
+
# Convert temperature to float
|
| 59 |
+
self.temperature = float(self.temperature)
|
| 60 |
+
|
| 61 |
+
# Normalize input_images (convert None to empty list)
|
| 62 |
+
if self.input_images is None:
|
| 63 |
+
self.input_images = []
|
| 64 |
+
|
| 65 |
+
# Ensure is_character_sheet matches input_images length
|
| 66 |
+
if len(self.is_character_sheet) < len(self.input_images):
|
| 67 |
+
# Pad with False
|
| 68 |
+
self.is_character_sheet.extend(
|
| 69 |
+
[False] * (len(self.input_images) - len(self.is_character_sheet))
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def has_input_images(self) -> bool:
|
| 74 |
+
"""Check if request has input images."""
|
| 75 |
+
return self.input_images is not None and len(self.input_images) > 0
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def image_count(self) -> int:
|
| 79 |
+
"""Get number of input images."""
|
| 80 |
+
return len(self.input_images) if self.input_images else 0
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def is_text_to_image(self) -> bool:
|
| 84 |
+
"""Check if this is a text-to-image request (no input images)."""
|
| 85 |
+
return not self.has_input_images
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def is_image_to_image(self) -> bool:
|
| 89 |
+
"""Check if this is an image-to-image request (has input images)."""
|
| 90 |
+
return self.has_input_images
|
| 91 |
+
|
| 92 |
+
def to_dict(self) -> dict:
|
| 93 |
+
"""
|
| 94 |
+
Convert request to dictionary (for metadata/logging).
|
| 95 |
+
|
| 96 |
+
Note: Images are not included in dict (only count).
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
Dictionary representation
|
| 100 |
+
"""
|
| 101 |
+
return {
|
| 102 |
+
"prompt": self.prompt,
|
| 103 |
+
"backend": self.backend,
|
| 104 |
+
"aspect_ratio": self.aspect_ratio,
|
| 105 |
+
"temperature": self.temperature,
|
| 106 |
+
"input_image_count": self.image_count,
|
| 107 |
+
"is_character_sheet": self.is_character_sheet,
|
| 108 |
+
"negative_prompt": self.negative_prompt,
|
| 109 |
+
"seed": self.seed,
|
| 110 |
+
"metadata": self.metadata
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
def __repr__(self) -> str:
|
| 114 |
+
"""String representation for debugging."""
|
| 115 |
+
return (
|
| 116 |
+
f"GenerationRequest("
|
| 117 |
+
f"prompt='{self.prompt[:50]}...', "
|
| 118 |
+
f"backend='{self.backend}', "
|
| 119 |
+
f"aspect_ratio='{self.aspect_ratio}', "
|
| 120 |
+
f"temperature={self.temperature}, "
|
| 121 |
+
f"input_images={self.image_count})"
|
| 122 |
+
)
|
character_forge_image/models/generation_result.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# generation_result.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Data model for image generation results. Consistent structure for results from any backend.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Encapsulate generation results (success/failure)
|
| 8 |
+
- Store generated image and metadata
|
| 9 |
+
- Validate result consistency
|
| 10 |
+
- Provide factory methods for success/error results
|
| 11 |
+
- Serialize to dictionary for logging
|
| 12 |
+
|
| 13 |
+
## Dependencies
|
| 14 |
+
- `dataclasses` - Data class decorator
|
| 15 |
+
- `PIL.Image` - Image type
|
| 16 |
+
- Used by all backends and services
|
| 17 |
+
|
| 18 |
+
## Public Interface
|
| 19 |
+
|
| 20 |
+
### `GenerationResult` dataclass
|
| 21 |
+
|
| 22 |
+
**Fields:**
|
| 23 |
+
- `success: bool` - Success status (required)
|
| 24 |
+
- `message: str` - Status/error message (required)
|
| 25 |
+
- `image: Image` - Generated image (optional, required if success)
|
| 26 |
+
- `generation_time: float` - Time in seconds (optional)
|
| 27 |
+
- `saved_path: Path` - Save location (optional)
|
| 28 |
+
- `metadata: dict` - Additional metadata (optional)
|
| 29 |
+
- `timestamp: datetime` - Auto-populated
|
| 30 |
+
|
| 31 |
+
**Properties:**
|
| 32 |
+
- `is_successful: bool` - Alias for success
|
| 33 |
+
- `has_image: bool` - True if image present
|
| 34 |
+
- `is_saved: bool` - True if saved to disk
|
| 35 |
+
|
| 36 |
+
**Factory Methods:**
|
| 37 |
+
- `success_result(image, message, generation_time, **kwargs)` - Create success result
|
| 38 |
+
- `error_result(message, **kwargs)` - Create error result
|
| 39 |
+
|
| 40 |
+
**Usage:**
|
| 41 |
+
```python
|
| 42 |
+
# Success
|
| 43 |
+
result = GenerationResult.success_result(
|
| 44 |
+
image=generated_image,
|
| 45 |
+
message="Generated in 3.2s",
|
| 46 |
+
generation_time=3.2
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Error
|
| 50 |
+
result = GenerationResult.error_result(
|
| 51 |
+
message="Backend not available"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if result.is_successful:
|
| 55 |
+
st.image(result.image)
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Related Files
|
| 59 |
+
- `models/generation_request.py` - Request model
|
| 60 |
+
- All backends - Return results
|
| 61 |
+
- All services - Process results
|
character_forge_image/models/generation_result.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generation Result Model
|
| 3 |
+
=======================
|
| 4 |
+
|
| 5 |
+
Data model for image generation results.
|
| 6 |
+
Provides consistent structure for results from any backend.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class GenerationResult:
|
| 18 |
+
"""
|
| 19 |
+
Represents the result of an image generation request.
|
| 20 |
+
|
| 21 |
+
This is the standard interface for all generation results,
|
| 22 |
+
regardless of backend or generation type.
|
| 23 |
+
|
| 24 |
+
Attributes:
|
| 25 |
+
success: Whether generation succeeded
|
| 26 |
+
image: Generated image (None if failed)
|
| 27 |
+
message: Status/error message
|
| 28 |
+
generation_time: Time taken in seconds
|
| 29 |
+
saved_path: Path where image was saved (if saved)
|
| 30 |
+
metadata: Additional metadata
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
# Required fields
|
| 34 |
+
success: bool
|
| 35 |
+
message: str
|
| 36 |
+
|
| 37 |
+
# Optional fields
|
| 38 |
+
image: Optional[Image.Image] = None
|
| 39 |
+
generation_time: Optional[float] = None
|
| 40 |
+
saved_path: Optional[Path] = None
|
| 41 |
+
metadata: dict = field(default_factory=dict)
|
| 42 |
+
|
| 43 |
+
# Auto-populated fields
|
| 44 |
+
timestamp: datetime = field(default_factory=datetime.now)
|
| 45 |
+
|
| 46 |
+
def __post_init__(self):
|
| 47 |
+
"""Validate data after initialization."""
|
| 48 |
+
# Ensure success is boolean
|
| 49 |
+
if not isinstance(self.success, bool):
|
| 50 |
+
raise TypeError("success must be boolean")
|
| 51 |
+
|
| 52 |
+
# Ensure message is string
|
| 53 |
+
if not isinstance(self.message, str):
|
| 54 |
+
raise TypeError("message must be a string")
|
| 55 |
+
|
| 56 |
+
# If success, image should be provided
|
| 57 |
+
if self.success and self.image is None:
|
| 58 |
+
raise ValueError("Success result must have an image")
|
| 59 |
+
|
| 60 |
+
# If failed, image should be None
|
| 61 |
+
if not self.success and self.image is not None:
|
| 62 |
+
raise ValueError("Failed result should not have an image")
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def is_successful(self) -> bool:
|
| 66 |
+
"""Alias for success field."""
|
| 67 |
+
return self.success
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def has_image(self) -> bool:
|
| 71 |
+
"""Check if result has an image."""
|
| 72 |
+
return self.image is not None
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def is_saved(self) -> bool:
|
| 76 |
+
"""Check if result was saved to disk."""
|
| 77 |
+
return self.saved_path is not None and self.saved_path.exists()
|
| 78 |
+
|
| 79 |
+
@classmethod
|
| 80 |
+
def success_result(
|
| 81 |
+
cls,
|
| 82 |
+
image: Image.Image,
|
| 83 |
+
message: str = "Generation successful",
|
| 84 |
+
generation_time: Optional[float] = None,
|
| 85 |
+
**kwargs
|
| 86 |
+
) -> 'GenerationResult':
|
| 87 |
+
"""
|
| 88 |
+
Create a successful generation result.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
image: Generated image
|
| 92 |
+
message: Success message
|
| 93 |
+
generation_time: Time taken in seconds
|
| 94 |
+
**kwargs: Additional metadata
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
GenerationResult instance
|
| 98 |
+
"""
|
| 99 |
+
return cls(
|
| 100 |
+
success=True,
|
| 101 |
+
image=image,
|
| 102 |
+
message=message,
|
| 103 |
+
generation_time=generation_time,
|
| 104 |
+
metadata=kwargs
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
@classmethod
|
| 108 |
+
def error_result(
|
| 109 |
+
cls,
|
| 110 |
+
message: str,
|
| 111 |
+
**kwargs
|
| 112 |
+
) -> 'GenerationResult':
|
| 113 |
+
"""
|
| 114 |
+
Create a failed generation result.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
message: Error message
|
| 118 |
+
**kwargs: Additional metadata
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
GenerationResult instance
|
| 122 |
+
"""
|
| 123 |
+
return cls(
|
| 124 |
+
success=False,
|
| 125 |
+
image=None,
|
| 126 |
+
message=message,
|
| 127 |
+
metadata=kwargs
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
def to_dict(self) -> dict:
|
| 131 |
+
"""
|
| 132 |
+
Convert result to dictionary (for metadata/logging).
|
| 133 |
+
|
| 134 |
+
Note: Image is not included in dict (only status).
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Dictionary representation
|
| 138 |
+
"""
|
| 139 |
+
result = {
|
| 140 |
+
"success": self.success,
|
| 141 |
+
"message": self.message,
|
| 142 |
+
"timestamp": self.timestamp.isoformat(),
|
| 143 |
+
"has_image": self.has_image
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if self.generation_time is not None:
|
| 147 |
+
result["generation_time_seconds"] = round(self.generation_time, 2)
|
| 148 |
+
|
| 149 |
+
if self.saved_path:
|
| 150 |
+
result["saved_path"] = str(self.saved_path)
|
| 151 |
+
|
| 152 |
+
result["metadata"] = self.metadata
|
| 153 |
+
|
| 154 |
+
return result
|
| 155 |
+
|
| 156 |
+
def __repr__(self) -> str:
|
| 157 |
+
"""String representation for debugging."""
|
| 158 |
+
status = "SUCCESS" if self.success else "FAILED"
|
| 159 |
+
time_str = f", {self.generation_time:.2f}s" if self.generation_time else ""
|
| 160 |
+
return f"GenerationResult({status}: {self.message}{time_str})"
|
character_forge_image/pages/01_🔥_Character_Forge.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Character Forge Page
|
| 3 |
+
====================
|
| 4 |
+
|
| 5 |
+
Character sheet generation and wardrobe change functionality.
|
| 6 |
+
Creates professional turnaround character sheets with multiple views.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from services import CharacterForgeService, WardrobeService
|
| 13 |
+
from ui.components.image_uploader import render_image_uploader
|
| 14 |
+
from ui.components.backend_selector import render_backend_selector
|
| 15 |
+
from ui.components.status_display import render_status_display, render_backend_health, render_progress_tracker, render_fullscreen_modal
|
| 16 |
+
from ui.components.library_selector import render_library_button, render_library_modal
|
| 17 |
+
from utils.library_manager import LibraryManager
|
| 18 |
+
from config.settings import Settings
|
| 19 |
+
|
| 20 |
+
# Page config
|
| 21 |
+
st.set_page_config(
|
| 22 |
+
page_title="Character Forge - Nano Banana",
|
| 23 |
+
page_icon="🔥",
|
| 24 |
+
layout="wide"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
st.title("🔥 Character Forge")
|
| 28 |
+
st.markdown("Create professional character turnaround sheets with multiple views")
|
| 29 |
+
|
| 30 |
+
# Render fullscreen modal if active
|
| 31 |
+
render_fullscreen_modal()
|
| 32 |
+
|
| 33 |
+
# Initialize session state
|
| 34 |
+
if 'forge_result' not in st.session_state:
|
| 35 |
+
st.session_state.forge_result = None
|
| 36 |
+
if 'forge_progress' not in st.session_state:
|
| 37 |
+
st.session_state.forge_progress = {'stage': 0, 'message': ''}
|
| 38 |
+
if 'wardrobe_result' not in st.session_state:
|
| 39 |
+
st.session_state.wardrobe_result = None
|
| 40 |
+
|
| 41 |
+
# Create services
|
| 42 |
+
forge_service = CharacterForgeService(api_key=st.session_state.get('gemini_api_key'))
|
| 43 |
+
wardrobe_service = WardrobeService(api_key=st.session_state.get('gemini_api_key'))
|
| 44 |
+
library = LibraryManager()
|
| 45 |
+
|
| 46 |
+
# Show backend health
|
| 47 |
+
with st.expander("🏥 Backend Status", expanded=False):
|
| 48 |
+
render_backend_health(forge_service, show_all=True)
|
| 49 |
+
|
| 50 |
+
st.divider()
|
| 51 |
+
|
| 52 |
+
# Tabs for character generation and wardrobe change
|
| 53 |
+
tab1, tab2 = st.tabs(["🆕 Generate Character Sheet", "👔 Wardrobe Change"])
|
| 54 |
+
|
| 55 |
+
# ============================================================================
|
| 56 |
+
# TAB 1: Generate Character Sheet
|
| 57 |
+
# ============================================================================
|
| 58 |
+
with tab1:
|
| 59 |
+
st.subheader("Generate New Character Sheet")
|
| 60 |
+
|
| 61 |
+
st.info(
|
| 62 |
+
"""
|
| 63 |
+
📚 **How it works:**
|
| 64 |
+
|
| 65 |
+
1. Upload a face or full body image of your character
|
| 66 |
+
2. Optionally describe or upload a costume reference
|
| 67 |
+
3. The system will generate a complete character sheet with:
|
| 68 |
+
- Front and side portraits
|
| 69 |
+
- Front, side, and rear full body views
|
| 70 |
+
"""
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
col1, col2 = st.columns([1, 1])
|
| 74 |
+
|
| 75 |
+
with col1:
|
| 76 |
+
st.markdown("### Input")
|
| 77 |
+
|
| 78 |
+
# Input mode selection
|
| 79 |
+
input_mode = st.radio(
|
| 80 |
+
"Input Mode",
|
| 81 |
+
options=["Face Only", "Full Body", "Face + Body (Separate)"],
|
| 82 |
+
index=0,
|
| 83 |
+
help="Select what type of input you're providing"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Input images based on mode
|
| 87 |
+
if input_mode == "Face + Body (Separate)":
|
| 88 |
+
# Face image with library
|
| 89 |
+
st.markdown("**Face Image**")
|
| 90 |
+
col_face_upload, col_face_lib = st.columns([3, 1])
|
| 91 |
+
with col_face_upload:
|
| 92 |
+
face_image = render_image_uploader(
|
| 93 |
+
label="",
|
| 94 |
+
key="forge_face_image",
|
| 95 |
+
help_text="Upload a clear face image"
|
| 96 |
+
)
|
| 97 |
+
with col_face_lib:
|
| 98 |
+
render_library_button("forge_face_input", "📚 Library")
|
| 99 |
+
|
| 100 |
+
# Check for library selection
|
| 101 |
+
selected_face = render_library_modal("forge_face_input")
|
| 102 |
+
if selected_face:
|
| 103 |
+
face_image = library.load_image(selected_face[0])
|
| 104 |
+
st.success(f"✅ Loaded from library")
|
| 105 |
+
|
| 106 |
+
# Body image with library
|
| 107 |
+
st.markdown("**Body Image (with costume)**")
|
| 108 |
+
col_body_upload, col_body_lib = st.columns([3, 1])
|
| 109 |
+
with col_body_upload:
|
| 110 |
+
body_image = render_image_uploader(
|
| 111 |
+
label="",
|
| 112 |
+
key="forge_body_image",
|
| 113 |
+
help_text="Upload a full body image showing the costume"
|
| 114 |
+
)
|
| 115 |
+
with col_body_lib:
|
| 116 |
+
render_library_button("forge_body_input", "📚 Library")
|
| 117 |
+
|
| 118 |
+
# Check for library selection
|
| 119 |
+
selected_body = render_library_modal("forge_body_input")
|
| 120 |
+
if selected_body:
|
| 121 |
+
body_image = library.load_image(selected_body[0])
|
| 122 |
+
st.success(f"✅ Loaded from library")
|
| 123 |
+
|
| 124 |
+
initial_image = None
|
| 125 |
+
else:
|
| 126 |
+
# Single input with library
|
| 127 |
+
st.markdown(f"**{input_mode} Image**")
|
| 128 |
+
col_initial_upload, col_initial_lib = st.columns([3, 1])
|
| 129 |
+
with col_initial_upload:
|
| 130 |
+
initial_image = render_image_uploader(
|
| 131 |
+
label="",
|
| 132 |
+
key="forge_initial_image",
|
| 133 |
+
help_text=f"Upload a {input_mode.lower()} image"
|
| 134 |
+
)
|
| 135 |
+
with col_initial_lib:
|
| 136 |
+
render_library_button("forge_initial_input", "📚 Library")
|
| 137 |
+
|
| 138 |
+
# Check for library selection
|
| 139 |
+
selected_initial = render_library_modal("forge_initial_input")
|
| 140 |
+
if selected_initial:
|
| 141 |
+
initial_image = library.load_image(selected_initial[0])
|
| 142 |
+
st.success(f"✅ Loaded from library")
|
| 143 |
+
|
| 144 |
+
face_image = None
|
| 145 |
+
body_image = None
|
| 146 |
+
|
| 147 |
+
st.divider()
|
| 148 |
+
|
| 149 |
+
# Character name
|
| 150 |
+
character_name = st.text_input(
|
| 151 |
+
"Character Name",
|
| 152 |
+
value="Character",
|
| 153 |
+
help="Name for your character"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Gender selection (improves prompt quality and safety filter handling)
|
| 157 |
+
gender = st.radio(
|
| 158 |
+
"Character Gender",
|
| 159 |
+
options=["Neutral (Character)", "Male", "Female"],
|
| 160 |
+
index=0,
|
| 161 |
+
horizontal=True,
|
| 162 |
+
help="Specifying gender improves AI output quality and reduces safety filter false positives"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Costume description
|
| 166 |
+
costume_description = st.text_area(
|
| 167 |
+
"Costume Description (Optional)",
|
| 168 |
+
height=100,
|
| 169 |
+
placeholder="e.g., medieval knight armor, modern casual wear...",
|
| 170 |
+
help="Describe the costume in text"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Costume reference image with library
|
| 174 |
+
st.markdown("**Costume Reference Image (Optional)**")
|
| 175 |
+
col_costume_upload, col_costume_lib = st.columns([3, 1])
|
| 176 |
+
with col_costume_upload:
|
| 177 |
+
costume_image = render_image_uploader(
|
| 178 |
+
label="",
|
| 179 |
+
key="forge_costume_image",
|
| 180 |
+
help_text="Upload a reference image for the costume"
|
| 181 |
+
)
|
| 182 |
+
with col_costume_lib:
|
| 183 |
+
render_library_button("forge_costume_input", "📚 Library")
|
| 184 |
+
|
| 185 |
+
# Check for library selection
|
| 186 |
+
selected_costume = render_library_modal("forge_costume_input")
|
| 187 |
+
if selected_costume:
|
| 188 |
+
costume_image = library.load_image(selected_costume[0])
|
| 189 |
+
st.success(f"✅ Loaded from library")
|
| 190 |
+
|
| 191 |
+
# Backend selection
|
| 192 |
+
backend = render_backend_selector(key="forge_backend")
|
| 193 |
+
|
| 194 |
+
with col2:
|
| 195 |
+
st.markdown("### Output Preview")
|
| 196 |
+
|
| 197 |
+
# Check if inputs are valid
|
| 198 |
+
has_input = False
|
| 199 |
+
if input_mode == "Face + Body (Separate)":
|
| 200 |
+
has_input = face_image is not None and body_image is not None
|
| 201 |
+
else:
|
| 202 |
+
has_input = initial_image is not None
|
| 203 |
+
|
| 204 |
+
# Generate button
|
| 205 |
+
generate_clicked = st.button(
|
| 206 |
+
"🔥 Generate Character Sheet",
|
| 207 |
+
type="primary",
|
| 208 |
+
use_container_width=True,
|
| 209 |
+
disabled=not has_input
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
if not has_input:
|
| 213 |
+
if input_mode == "Face + Body (Separate)":
|
| 214 |
+
st.warning("⚠️ Please upload both face and body images")
|
| 215 |
+
else:
|
| 216 |
+
st.warning("⚠️ Please upload an input image")
|
| 217 |
+
|
| 218 |
+
# Progress tracking
|
| 219 |
+
progress_placeholder = st.empty()
|
| 220 |
+
|
| 221 |
+
# Generate
|
| 222 |
+
if generate_clicked and has_input:
|
| 223 |
+
# Progress callback
|
| 224 |
+
def update_progress(stage, message):
|
| 225 |
+
st.session_state.forge_progress = {'stage': stage, 'message': message}
|
| 226 |
+
with progress_placeholder:
|
| 227 |
+
render_progress_tracker(stage, 6, message)
|
| 228 |
+
|
| 229 |
+
with st.spinner("Generating character sheet..."):
|
| 230 |
+
# Convert gender selection to prompt term
|
| 231 |
+
gender_term = {
|
| 232 |
+
"Neutral (Character)": "character",
|
| 233 |
+
"Male": "man",
|
| 234 |
+
"Female": "woman"
|
| 235 |
+
}.get(gender, "character")
|
| 236 |
+
|
| 237 |
+
# Call service
|
| 238 |
+
if input_mode == "Face + Body (Separate)":
|
| 239 |
+
sheet, message, metadata = forge_service.generate_character_sheet(
|
| 240 |
+
initial_image=None,
|
| 241 |
+
initial_image_type=input_mode,
|
| 242 |
+
character_name=character_name,
|
| 243 |
+
gender_term=gender_term,
|
| 244 |
+
costume_description=costume_description,
|
| 245 |
+
costume_image=costume_image,
|
| 246 |
+
face_image=face_image,
|
| 247 |
+
body_image=body_image,
|
| 248 |
+
backend=backend,
|
| 249 |
+
progress_callback=update_progress,
|
| 250 |
+
output_dir=Settings.CHARACTER_SHEETS_DIR
|
| 251 |
+
)
|
| 252 |
+
else:
|
| 253 |
+
sheet, message, metadata = forge_service.generate_character_sheet(
|
| 254 |
+
initial_image=initial_image,
|
| 255 |
+
initial_image_type=input_mode,
|
| 256 |
+
character_name=character_name,
|
| 257 |
+
gender_term=gender_term,
|
| 258 |
+
costume_description=costume_description,
|
| 259 |
+
costume_image=costume_image,
|
| 260 |
+
backend=backend,
|
| 261 |
+
progress_callback=update_progress,
|
| 262 |
+
output_dir=Settings.CHARACTER_SHEETS_DIR
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Create result object
|
| 266 |
+
from models.generation_result import GenerationResult
|
| 267 |
+
if sheet is not None:
|
| 268 |
+
result = GenerationResult.success_result(
|
| 269 |
+
image=sheet,
|
| 270 |
+
message=message
|
| 271 |
+
)
|
| 272 |
+
result.metadata = metadata
|
| 273 |
+
|
| 274 |
+
# Auto-register in library
|
| 275 |
+
try:
|
| 276 |
+
entry_id = library.register_image(
|
| 277 |
+
image=sheet,
|
| 278 |
+
name=character_name,
|
| 279 |
+
type="character_sheet",
|
| 280 |
+
metadata=metadata,
|
| 281 |
+
description=f"Character sheet generated from {input_mode}"
|
| 282 |
+
)
|
| 283 |
+
st.success(f"📚 Added to library: {character_name}")
|
| 284 |
+
except Exception as e:
|
| 285 |
+
st.warning(f"Failed to add to library: {e}")
|
| 286 |
+
else:
|
| 287 |
+
result = GenerationResult.error_result(message=message)
|
| 288 |
+
|
| 289 |
+
st.session_state.forge_result = result
|
| 290 |
+
|
| 291 |
+
# Display result
|
| 292 |
+
if st.session_state.forge_result:
|
| 293 |
+
st.divider()
|
| 294 |
+
render_status_display(
|
| 295 |
+
result=st.session_state.forge_result,
|
| 296 |
+
show_logs=False
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# ============================================================================
|
| 300 |
+
# TAB 2: Wardrobe Change
|
| 301 |
+
# ============================================================================
|
| 302 |
+
with tab2:
|
| 303 |
+
st.subheader("Change Costume on Existing Character Sheet")
|
| 304 |
+
|
| 305 |
+
st.info(
|
| 306 |
+
"""
|
| 307 |
+
📚 **How it works:**
|
| 308 |
+
|
| 309 |
+
1. Upload an existing character sheet
|
| 310 |
+
2. Describe or upload a new costume
|
| 311 |
+
3. The system will regenerate all views with the new costume
|
| 312 |
+
while maintaining character identity
|
| 313 |
+
"""
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
col1, col2 = st.columns([1, 1])
|
| 317 |
+
|
| 318 |
+
with col1:
|
| 319 |
+
st.markdown("### Input")
|
| 320 |
+
|
| 321 |
+
# Upload existing character sheet with library
|
| 322 |
+
st.markdown("**Existing Character Sheet**")
|
| 323 |
+
col_sheet_upload, col_sheet_lib = st.columns([3, 1])
|
| 324 |
+
with col_sheet_upload:
|
| 325 |
+
uploaded_sheet = render_image_uploader(
|
| 326 |
+
label="",
|
| 327 |
+
key="wardrobe_sheet",
|
| 328 |
+
help_text="Upload a character sheet to modify"
|
| 329 |
+
)
|
| 330 |
+
with col_sheet_lib:
|
| 331 |
+
render_library_button("wardrobe_sheet_input", "📚 Library")
|
| 332 |
+
|
| 333 |
+
# Check for library selection and store in session state
|
| 334 |
+
selected_sheet = render_library_modal("wardrobe_sheet_input", filter_type="character_sheet")
|
| 335 |
+
if selected_sheet:
|
| 336 |
+
st.session_state['wardrobe_loaded_sheet'] = library.load_image(selected_sheet[0])
|
| 337 |
+
st.session_state['wardrobe_sheet_source'] = 'library'
|
| 338 |
+
st.success(f"✅ Loaded from library")
|
| 339 |
+
|
| 340 |
+
# Handle uploaded image
|
| 341 |
+
if uploaded_sheet is not None:
|
| 342 |
+
st.session_state['wardrobe_loaded_sheet'] = uploaded_sheet
|
| 343 |
+
st.session_state['wardrobe_sheet_source'] = 'upload'
|
| 344 |
+
|
| 345 |
+
# Use the persisted image from session state
|
| 346 |
+
character_sheet = st.session_state.get('wardrobe_loaded_sheet', None)
|
| 347 |
+
|
| 348 |
+
# Show preview if image is loaded
|
| 349 |
+
if character_sheet is not None:
|
| 350 |
+
source = st.session_state.get('wardrobe_sheet_source', 'unknown')
|
| 351 |
+
col_preview, col_clear = st.columns([4, 1])
|
| 352 |
+
with col_preview:
|
| 353 |
+
st.caption(f"📄 Loaded from: {source}")
|
| 354 |
+
# Use proper high-quality display (max 512px with aspect ratio maintained)
|
| 355 |
+
from ui.components.status_display import render_image_with_download
|
| 356 |
+
render_image_with_download(
|
| 357 |
+
image=character_sheet,
|
| 358 |
+
filename="character_sheet.png",
|
| 359 |
+
max_display_size=512,
|
| 360 |
+
show_fullscreen_button=True
|
| 361 |
+
)
|
| 362 |
+
with col_clear:
|
| 363 |
+
st.write("") # Spacer
|
| 364 |
+
st.write("") # Spacer
|
| 365 |
+
if st.button("🗑️ Clear", key="clear_wardrobe_sheet", use_container_width=True):
|
| 366 |
+
st.session_state['wardrobe_loaded_sheet'] = None
|
| 367 |
+
st.session_state['wardrobe_sheet_source'] = None
|
| 368 |
+
st.rerun()
|
| 369 |
+
|
| 370 |
+
# Character name for variant
|
| 371 |
+
variant_name = st.text_input(
|
| 372 |
+
"Variant Name",
|
| 373 |
+
value="Character_Variant",
|
| 374 |
+
help="Name for this costume variant"
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
# New costume description
|
| 378 |
+
new_costume_description = st.text_area(
|
| 379 |
+
"New Costume Description",
|
| 380 |
+
height=100,
|
| 381 |
+
placeholder="e.g., futuristic space suit, casual modern clothing...",
|
| 382 |
+
help="Describe the new costume"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# New costume reference with library
|
| 386 |
+
st.markdown("**New Costume Reference (Optional)**")
|
| 387 |
+
col_wardcostume_upload, col_wardcostume_lib = st.columns([3, 1])
|
| 388 |
+
with col_wardcostume_upload:
|
| 389 |
+
uploaded_costume = render_image_uploader(
|
| 390 |
+
label="",
|
| 391 |
+
key="wardrobe_costume_ref",
|
| 392 |
+
help_text="Upload a reference for the new costume"
|
| 393 |
+
)
|
| 394 |
+
with col_wardcostume_lib:
|
| 395 |
+
render_library_button("wardrobe_costume_input", "📚 Library")
|
| 396 |
+
|
| 397 |
+
# Check for library selection and store in session state
|
| 398 |
+
selected_wardcostume = render_library_modal("wardrobe_costume_input")
|
| 399 |
+
if selected_wardcostume:
|
| 400 |
+
st.session_state['wardrobe_loaded_costume'] = library.load_image(selected_wardcostume[0])
|
| 401 |
+
st.session_state['wardrobe_costume_source'] = 'library'
|
| 402 |
+
st.success(f"✅ Loaded costume from library")
|
| 403 |
+
|
| 404 |
+
# Handle uploaded costume image
|
| 405 |
+
if uploaded_costume is not None:
|
| 406 |
+
st.session_state['wardrobe_loaded_costume'] = uploaded_costume
|
| 407 |
+
st.session_state['wardrobe_costume_source'] = 'upload'
|
| 408 |
+
|
| 409 |
+
# Use the persisted costume image from session state
|
| 410 |
+
new_costume_image = st.session_state.get('wardrobe_loaded_costume', None)
|
| 411 |
+
|
| 412 |
+
# Show preview if costume image is loaded
|
| 413 |
+
if new_costume_image is not None:
|
| 414 |
+
source = st.session_state.get('wardrobe_costume_source', 'unknown')
|
| 415 |
+
col_costume_preview, col_costume_clear = st.columns([4, 1])
|
| 416 |
+
with col_costume_preview:
|
| 417 |
+
st.caption(f"👔 Costume reference loaded from: {source}")
|
| 418 |
+
# Use proper high-quality display
|
| 419 |
+
from ui.components.status_display import render_image_with_download
|
| 420 |
+
render_image_with_download(
|
| 421 |
+
image=new_costume_image,
|
| 422 |
+
filename="costume_reference.png",
|
| 423 |
+
max_display_size=512,
|
| 424 |
+
show_fullscreen_button=True
|
| 425 |
+
)
|
| 426 |
+
with col_costume_clear:
|
| 427 |
+
st.write("") # Spacer
|
| 428 |
+
st.write("") # Spacer
|
| 429 |
+
if st.button("🗑️ Clear", key="clear_wardrobe_costume", use_container_width=True):
|
| 430 |
+
st.session_state['wardrobe_loaded_costume'] = None
|
| 431 |
+
st.session_state['wardrobe_costume_source'] = None
|
| 432 |
+
st.rerun()
|
| 433 |
+
|
| 434 |
+
# Backend
|
| 435 |
+
wardrobe_backend = render_backend_selector(key="wardrobe_backend")
|
| 436 |
+
|
| 437 |
+
# Debug extraction (pixel-perfect validation)
|
| 438 |
+
st.markdown("**🔬 Debug Mode**")
|
| 439 |
+
debug_extraction = st.checkbox(
|
| 440 |
+
"Enable Extraction Validation",
|
| 441 |
+
value=False,
|
| 442 |
+
key="debug_extraction",
|
| 443 |
+
help="Saves intermediate images and validates that extraction is the perfect inverse of composition. Creates assembled/ and disassembled/ subdirectories with pixel-perfect comparison reports."
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
if debug_extraction:
|
| 447 |
+
st.info("🔬 Debug mode enabled: Will save source images, extracted views, and pixel-perfect validation reports")
|
| 448 |
+
|
| 449 |
+
with col2:
|
| 450 |
+
st.markdown("### Output Preview")
|
| 451 |
+
|
| 452 |
+
# Check if inputs valid
|
| 453 |
+
has_wardrobe_input = (
|
| 454 |
+
character_sheet is not None and
|
| 455 |
+
(new_costume_description.strip() or new_costume_image is not None)
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
# Generate button
|
| 459 |
+
wardrobe_clicked = st.button(
|
| 460 |
+
"👔 Generate Wardrobe Change",
|
| 461 |
+
type="primary",
|
| 462 |
+
use_container_width=True,
|
| 463 |
+
disabled=not has_wardrobe_input
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
if not has_wardrobe_input:
|
| 467 |
+
if character_sheet is None:
|
| 468 |
+
st.warning("⚠️ Please upload a character sheet")
|
| 469 |
+
else:
|
| 470 |
+
st.warning("⚠️ Please provide new costume description or reference")
|
| 471 |
+
|
| 472 |
+
# Progress tracking
|
| 473 |
+
wardrobe_progress_placeholder = st.empty()
|
| 474 |
+
|
| 475 |
+
# Generate
|
| 476 |
+
if wardrobe_clicked and has_wardrobe_input:
|
| 477 |
+
# Progress callback
|
| 478 |
+
def update_wardrobe_progress(stage, message):
|
| 479 |
+
with wardrobe_progress_placeholder:
|
| 480 |
+
render_progress_tracker(stage, 7, message)
|
| 481 |
+
|
| 482 |
+
with st.spinner("Generating wardrobe change..."):
|
| 483 |
+
new_sheet, message, metadata = wardrobe_service.wardrobe_change(
|
| 484 |
+
character_sheet=character_sheet,
|
| 485 |
+
character_name=variant_name,
|
| 486 |
+
new_costume_description=new_costume_description,
|
| 487 |
+
new_costume_image=new_costume_image,
|
| 488 |
+
backend=wardrobe_backend,
|
| 489 |
+
progress_callback=update_wardrobe_progress,
|
| 490 |
+
output_dir=Settings.WARDROBE_CHANGES_DIR,
|
| 491 |
+
debug_extraction=debug_extraction
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
# Create result
|
| 495 |
+
from models.generation_result import GenerationResult
|
| 496 |
+
if new_sheet is not None:
|
| 497 |
+
result = GenerationResult.success_result(
|
| 498 |
+
image=new_sheet,
|
| 499 |
+
message=message
|
| 500 |
+
)
|
| 501 |
+
result.metadata = metadata
|
| 502 |
+
|
| 503 |
+
# Auto-register in library
|
| 504 |
+
try:
|
| 505 |
+
entry_id = library.register_image(
|
| 506 |
+
image=new_sheet,
|
| 507 |
+
name=variant_name,
|
| 508 |
+
type="wardrobe",
|
| 509 |
+
metadata=metadata,
|
| 510 |
+
description=f"Wardrobe variant with {new_costume_description or 'costume reference'}"
|
| 511 |
+
)
|
| 512 |
+
st.success(f"📚 Added to library: {variant_name}")
|
| 513 |
+
except Exception as e:
|
| 514 |
+
st.warning(f"Failed to add to library: {e}")
|
| 515 |
+
else:
|
| 516 |
+
result = GenerationResult.error_result(message=message)
|
| 517 |
+
|
| 518 |
+
st.session_state.wardrobe_result = result
|
| 519 |
+
|
| 520 |
+
# Display result
|
| 521 |
+
if st.session_state.wardrobe_result:
|
| 522 |
+
st.divider()
|
| 523 |
+
render_status_display(
|
| 524 |
+
result=st.session_state.wardrobe_result,
|
| 525 |
+
show_logs=False
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
# Logs
|
| 529 |
+
with st.expander("📋 View Recent Logs", expanded=False):
|
| 530 |
+
from utils.logging_utils import get_recent_logs
|
| 531 |
+
logs = get_recent_logs(limit=200)
|
| 532 |
+
if logs:
|
| 533 |
+
st.code("\n".join(logs), language="log", line_numbers=False)
|
| 534 |
+
else:
|
| 535 |
+
st.info("No logs available")
|
character_forge_image/pages/02_🎬_Composition_Assistant.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Composition Assistant Page
|
| 3 |
+
===========================
|
| 4 |
+
|
| 5 |
+
Smart multi-image composition with automatic prompt generation.
|
| 6 |
+
Based on Google's best practices for Gemini 2.5 Flash Image.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from services import CompositionService
|
| 13 |
+
from ui.components.image_uploader import render_image_with_type_selector
|
| 14 |
+
from ui.components.aspect_ratio_selector import render_aspect_ratio_selector, render_temperature_slider
|
| 15 |
+
from ui.components.backend_selector import render_backend_selector
|
| 16 |
+
from ui.components.status_display import render_status_display, render_backend_health
|
| 17 |
+
from ui.components.library_selector import render_library_button, render_library_modal
|
| 18 |
+
from utils.library_manager import LibraryManager
|
| 19 |
+
from config.settings import Settings
|
| 20 |
+
|
| 21 |
+
# Page config
|
| 22 |
+
st.set_page_config(
|
| 23 |
+
page_title="Composition Assistant - Nano Banana",
|
| 24 |
+
page_icon="🎬",
|
| 25 |
+
layout="wide"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
st.title("🎬 Composition Assistant")
|
| 29 |
+
st.markdown("Smart multi-image composition with auto-generated prompts")
|
| 30 |
+
|
| 31 |
+
# Initialize session state
|
| 32 |
+
if 'comp_result' not in st.session_state:
|
| 33 |
+
st.session_state.comp_result = None
|
| 34 |
+
if 'comp_prompt' not in st.session_state:
|
| 35 |
+
st.session_state.comp_prompt = ""
|
| 36 |
+
|
| 37 |
+
# Create service and library
|
| 38 |
+
service = CompositionService(api_key=st.session_state.get('gemini_api_key'))
|
| 39 |
+
library = LibraryManager()
|
| 40 |
+
|
| 41 |
+
# Show backend health
|
| 42 |
+
with st.expander("🏥 Backend Status", expanded=False):
|
| 43 |
+
render_backend_health(service, show_all=True)
|
| 44 |
+
|
| 45 |
+
st.divider()
|
| 46 |
+
|
| 47 |
+
# Info banner
|
| 48 |
+
st.info(
|
| 49 |
+
"""
|
| 50 |
+
📚 **Based on Google's best practices for Gemini 2.5 Flash Image**
|
| 51 |
+
|
| 52 |
+
This tool helps you create professional multi-image compositions without writing complex prompts.
|
| 53 |
+
Just select image types, camera angles, and lighting - the prompt is generated automatically!
|
| 54 |
+
"""
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
st.divider()
|
| 58 |
+
|
| 59 |
+
# Main interface
|
| 60 |
+
col1, col2 = st.columns([1, 1])
|
| 61 |
+
|
| 62 |
+
with col1:
|
| 63 |
+
st.subheader("📸 Upload Images")
|
| 64 |
+
|
| 65 |
+
# Image 1
|
| 66 |
+
col_img1, col_lib1 = st.columns([3, 1])
|
| 67 |
+
with col_img1:
|
| 68 |
+
image1, image1_type = render_image_with_type_selector(
|
| 69 |
+
image_index=1,
|
| 70 |
+
image_types=CompositionService.IMAGE_TYPES,
|
| 71 |
+
default_type="Subject/Character",
|
| 72 |
+
key_prefix="comp_img"
|
| 73 |
+
)
|
| 74 |
+
with col_lib1:
|
| 75 |
+
st.write("") # Spacing
|
| 76 |
+
st.write("")
|
| 77 |
+
render_library_button("comp_img1", "📚")
|
| 78 |
+
|
| 79 |
+
# Per-image character sheet checkbox
|
| 80 |
+
image1_is_char_sheet = st.checkbox(
|
| 81 |
+
"📋 Image 1 is Character Sheet",
|
| 82 |
+
value=False,
|
| 83 |
+
key="comp_img1_is_char_sheet",
|
| 84 |
+
help="Check if this image is a character sheet to handle it appropriately"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Check for library selection
|
| 88 |
+
selected1 = render_library_modal("comp_img1")
|
| 89 |
+
if selected1:
|
| 90 |
+
image1 = library.load_image(selected1[0])
|
| 91 |
+
st.success(f"✅ Loaded Image 1 from library")
|
| 92 |
+
|
| 93 |
+
# Image 2
|
| 94 |
+
col_img2, col_lib2 = st.columns([3, 1])
|
| 95 |
+
with col_img2:
|
| 96 |
+
image2, image2_type = render_image_with_type_selector(
|
| 97 |
+
image_index=2,
|
| 98 |
+
image_types=CompositionService.IMAGE_TYPES,
|
| 99 |
+
default_type="Background/Environment",
|
| 100 |
+
key_prefix="comp_img"
|
| 101 |
+
)
|
| 102 |
+
with col_lib2:
|
| 103 |
+
st.write("") # Spacing
|
| 104 |
+
st.write("")
|
| 105 |
+
render_library_button("comp_img2", "📚")
|
| 106 |
+
|
| 107 |
+
# Per-image character sheet checkbox
|
| 108 |
+
image2_is_char_sheet = st.checkbox(
|
| 109 |
+
"📋 Image 2 is Character Sheet",
|
| 110 |
+
value=False,
|
| 111 |
+
key="comp_img2_is_char_sheet",
|
| 112 |
+
help="Check if this image is a character sheet to handle it appropriately"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# Check for library selection
|
| 116 |
+
selected2 = render_library_modal("comp_img2")
|
| 117 |
+
if selected2:
|
| 118 |
+
image2 = library.load_image(selected2[0])
|
| 119 |
+
st.success(f"✅ Loaded Image 2 from library")
|
| 120 |
+
|
| 121 |
+
# Image 3
|
| 122 |
+
col_img3, col_lib3 = st.columns([3, 1])
|
| 123 |
+
with col_img3:
|
| 124 |
+
image3, image3_type = render_image_with_type_selector(
|
| 125 |
+
image_index=3,
|
| 126 |
+
image_types=CompositionService.IMAGE_TYPES,
|
| 127 |
+
default_type="Not Used",
|
| 128 |
+
key_prefix="comp_img"
|
| 129 |
+
)
|
| 130 |
+
with col_lib3:
|
| 131 |
+
st.write("") # Spacing
|
| 132 |
+
st.write("")
|
| 133 |
+
render_library_button("comp_img3", "📚")
|
| 134 |
+
|
| 135 |
+
# Per-image character sheet checkbox
|
| 136 |
+
image3_is_char_sheet = st.checkbox(
|
| 137 |
+
"📋 Image 3 is Character Sheet",
|
| 138 |
+
value=False,
|
| 139 |
+
key="comp_img3_is_char_sheet",
|
| 140 |
+
help="Check if this image is a character sheet to handle it appropriately"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Check for library selection
|
| 144 |
+
selected3 = render_library_modal("comp_img3")
|
| 145 |
+
if selected3:
|
| 146 |
+
image3 = library.load_image(selected3[0])
|
| 147 |
+
st.success(f"✅ Loaded Image 3 from library")
|
| 148 |
+
|
| 149 |
+
# Vision analysis helper
|
| 150 |
+
uploaded_count = sum(1 for img in [image1, image2, image3] if img is not None)
|
| 151 |
+
if uploaded_count > 0:
|
| 152 |
+
st.info("""🖼️ **Vision Analysis**
|
| 153 |
+
|
| 154 |
+
I see {count} image{plural}. Use the composition controls below to refine how these images should be combined.
|
| 155 |
+
|
| 156 |
+
**Quick Start:**
|
| 157 |
+
1. Set image types (Subject, Background, etc.)
|
| 158 |
+
2. Choose shot type and camera angle
|
| 159 |
+
3. Select lighting preference
|
| 160 |
+
4. Click "🔄 Preview Prompt" to see the auto-generated instructions
|
| 161 |
+
5. Click "🎬 Generate Composition" to create your image
|
| 162 |
+
""".format(count=uploaded_count, plural="s" if uploaded_count > 1 else ""))
|
| 163 |
+
|
| 164 |
+
st.divider()
|
| 165 |
+
|
| 166 |
+
st.subheader("🎥 Composition Controls")
|
| 167 |
+
|
| 168 |
+
# Determine if any image is a character sheet
|
| 169 |
+
is_character_sheet = image1_is_char_sheet or image2_is_char_sheet or image3_is_char_sheet
|
| 170 |
+
|
| 171 |
+
# Shot type
|
| 172 |
+
shot_type = st.radio(
|
| 173 |
+
"Shot Type",
|
| 174 |
+
options=CompositionService.SHOT_TYPES,
|
| 175 |
+
index=1, # medium shot
|
| 176 |
+
help="Overall framing of the composition"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# Camera angles
|
| 180 |
+
camera_angles = st.multiselect(
|
| 181 |
+
"Camera Angle (select all that apply)",
|
| 182 |
+
options=CompositionService.CAMERA_ANGLES,
|
| 183 |
+
default=["eye-level perspective"],
|
| 184 |
+
help="Camera positioning"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Lighting
|
| 188 |
+
lighting = st.selectbox(
|
| 189 |
+
"Lighting",
|
| 190 |
+
options=CompositionService.LIGHTING_OPTIONS,
|
| 191 |
+
index=0, # Auto
|
| 192 |
+
help="Lighting type and direction"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Custom instructions
|
| 196 |
+
custom_instructions = st.text_area(
|
| 197 |
+
"Custom Instructions (Optional)",
|
| 198 |
+
height=80,
|
| 199 |
+
placeholder="Add any additional instructions...",
|
| 200 |
+
help="Extra details or instructions for the composition"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
with col2:
|
| 204 |
+
st.subheader("📝 Generated Prompt")
|
| 205 |
+
|
| 206 |
+
# Build and display prompt
|
| 207 |
+
if st.button("🔄 Preview Prompt", type="secondary", use_container_width=True):
|
| 208 |
+
st.session_state.comp_prompt = service.build_composition_prompt(
|
| 209 |
+
image1_type=image1_type,
|
| 210 |
+
image2_type=image2_type,
|
| 211 |
+
image3_type=image3_type,
|
| 212 |
+
camera_angles=camera_angles,
|
| 213 |
+
lighting=lighting,
|
| 214 |
+
shot_type=shot_type,
|
| 215 |
+
custom_instructions=custom_instructions,
|
| 216 |
+
is_character_sheet=is_character_sheet
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
if st.session_state.comp_prompt:
|
| 220 |
+
st.text_area(
|
| 221 |
+
"Auto-generated prompt:",
|
| 222 |
+
value=st.session_state.comp_prompt,
|
| 223 |
+
height=150,
|
| 224 |
+
disabled=True
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
st.divider()
|
| 228 |
+
|
| 229 |
+
st.subheader("⚙️ Generation Settings")
|
| 230 |
+
|
| 231 |
+
# Get suggested aspect ratio
|
| 232 |
+
suggested_ratio = service.get_suggested_aspect_ratio(
|
| 233 |
+
shot_type=shot_type,
|
| 234 |
+
is_character_sheet=is_character_sheet
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# Aspect ratio
|
| 238 |
+
aspect_ratio = render_aspect_ratio_selector(
|
| 239 |
+
key="comp_aspect_ratio",
|
| 240 |
+
default=suggested_ratio
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
if aspect_ratio != suggested_ratio:
|
| 244 |
+
st.info(f"💡 Suggested aspect ratio for {shot_type}: {suggested_ratio}")
|
| 245 |
+
|
| 246 |
+
# Temperature
|
| 247 |
+
temperature = render_temperature_slider(
|
| 248 |
+
key="comp_temperature",
|
| 249 |
+
default=0.7
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# Backend
|
| 253 |
+
backend = render_backend_selector(key="comp_backend")
|
| 254 |
+
|
| 255 |
+
st.divider()
|
| 256 |
+
|
| 257 |
+
# Generate button
|
| 258 |
+
images = [image1, image2, image3]
|
| 259 |
+
image_types = [image1_type, image2_type, image3_type]
|
| 260 |
+
|
| 261 |
+
# Check if at least one image is provided
|
| 262 |
+
has_images = any(img is not None for img in images)
|
| 263 |
+
|
| 264 |
+
generate_clicked = st.button(
|
| 265 |
+
"🎨 Generate Composition",
|
| 266 |
+
type="primary",
|
| 267 |
+
use_container_width=True,
|
| 268 |
+
disabled=not has_images
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
if not has_images:
|
| 272 |
+
st.warning("⚠️ Please upload at least one image")
|
| 273 |
+
|
| 274 |
+
# Generate and display result
|
| 275 |
+
if generate_clicked and has_images:
|
| 276 |
+
with st.spinner("Generating composition..."):
|
| 277 |
+
result = service.compose_images(
|
| 278 |
+
images=images,
|
| 279 |
+
image_types=image_types,
|
| 280 |
+
camera_angles=camera_angles,
|
| 281 |
+
lighting=lighting,
|
| 282 |
+
shot_type=shot_type,
|
| 283 |
+
custom_instructions=custom_instructions,
|
| 284 |
+
is_character_sheet=is_character_sheet,
|
| 285 |
+
aspect_ratio=aspect_ratio,
|
| 286 |
+
temperature=temperature,
|
| 287 |
+
backend=backend
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
st.session_state.comp_result = result
|
| 291 |
+
|
| 292 |
+
# Display result below both columns
|
| 293 |
+
if st.session_state.comp_result:
|
| 294 |
+
st.divider()
|
| 295 |
+
st.subheader("✨ Result")
|
| 296 |
+
render_status_display(
|
| 297 |
+
result=st.session_state.comp_result,
|
| 298 |
+
show_logs=False
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Logs
|
| 302 |
+
with st.expander("📋 View Recent Logs", expanded=False):
|
| 303 |
+
from utils.logging_utils import get_recent_logs
|
| 304 |
+
logs = get_recent_logs(limit=100)
|
| 305 |
+
if logs:
|
| 306 |
+
st.code("\n".join(logs), language="log", line_numbers=False)
|
| 307 |
+
else:
|
| 308 |
+
st.info("No logs available")
|
character_forge_image/pages/03_📸_Standard_Interface.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Standard Interface Page
|
| 3 |
+
=======================
|
| 4 |
+
|
| 5 |
+
Direct text-to-image and image-to-image generation.
|
| 6 |
+
Simple, straightforward interface for general-purpose image generation.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from services import GenerationService
|
| 13 |
+
from models.generation_request import GenerationRequest
|
| 14 |
+
from ui.components.image_uploader import render_multi_image_uploader
|
| 15 |
+
from ui.components.aspect_ratio_selector import render_aspect_ratio_selector, render_temperature_slider
|
| 16 |
+
from ui.components.backend_selector import render_backend_selector
|
| 17 |
+
from ui.components.status_display import render_status_display, render_backend_health
|
| 18 |
+
from ui.components.library_selector import render_library_button, render_library_modal
|
| 19 |
+
from utils.library_manager import LibraryManager
|
| 20 |
+
from config.settings import Settings
|
| 21 |
+
|
| 22 |
+
# Page config
|
| 23 |
+
st.set_page_config(
|
| 24 |
+
page_title="Standard Interface - Nano Banana",
|
| 25 |
+
page_icon="📸",
|
| 26 |
+
layout="wide"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
st.title("📸 Standard Interface")
|
| 30 |
+
st.markdown("Direct text-to-image and image-to-image generation")
|
| 31 |
+
|
| 32 |
+
# Initialize session state
|
| 33 |
+
if 'standard_result' not in st.session_state:
|
| 34 |
+
st.session_state.standard_result = None
|
| 35 |
+
|
| 36 |
+
# Create service and library
|
| 37 |
+
service = GenerationService(api_key=st.session_state.get('gemini_api_key'))
|
| 38 |
+
library = LibraryManager()
|
| 39 |
+
|
| 40 |
+
# Show backend health
|
| 41 |
+
with st.expander("🏥 Backend Status", expanded=False):
|
| 42 |
+
render_backend_health(service, show_all=True)
|
| 43 |
+
|
| 44 |
+
st.divider()
|
| 45 |
+
|
| 46 |
+
# Main interface
|
| 47 |
+
col1, col2 = st.columns([1, 1])
|
| 48 |
+
|
| 49 |
+
with col1:
|
| 50 |
+
st.subheader("Input")
|
| 51 |
+
|
| 52 |
+
# Character sheet template prompt
|
| 53 |
+
CHARACTER_SHEET_TEMPLATE = """Create a professional character turnaround sheet with multiple views of the same character:
|
| 54 |
+
|
| 55 |
+
- Front portrait (close-up of face, filling frame)
|
| 56 |
+
- Side profile portrait (90-degree angle, face filling frame)
|
| 57 |
+
- Front full body (standing neutral pose, head to toe)
|
| 58 |
+
- Side full body (90-degree angle, full body visible)
|
| 59 |
+
- Rear view (back of character, full body)
|
| 60 |
+
|
| 61 |
+
All views should show the EXACT SAME character with consistent facial features, body proportions, hairstyle, and costume. Professional photo studio lighting with neutral grey background. Character should be: [DESCRIBE YOUR CHARACTER HERE]"""
|
| 62 |
+
|
| 63 |
+
# Character sheet template option
|
| 64 |
+
use_character_sheet_template = st.checkbox(
|
| 65 |
+
"📋 Use Character Sheet Template",
|
| 66 |
+
value=False,
|
| 67 |
+
help="Check this to populate the prompt with a character turnaround sheet template. You can then modify it as needed."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Show template info if checked
|
| 71 |
+
if use_character_sheet_template:
|
| 72 |
+
st.info("💡 **Template loaded!** The prompt below contains a character sheet template. Replace [DESCRIBE YOUR CHARACTER HERE] with your character details, or modify the entire prompt as needed.")
|
| 73 |
+
|
| 74 |
+
# Prompt input
|
| 75 |
+
default_prompt = CHARACTER_SHEET_TEMPLATE if use_character_sheet_template else ""
|
| 76 |
+
|
| 77 |
+
prompt = st.text_area(
|
| 78 |
+
"Prompt",
|
| 79 |
+
height=200,
|
| 80 |
+
value=default_prompt,
|
| 81 |
+
placeholder="Describe the image you want to generate...",
|
| 82 |
+
help="Describe what you want to create. Be specific and descriptive.",
|
| 83 |
+
key=f"standard_prompt_{'template' if use_character_sheet_template else 'normal'}"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Input images (optional)
|
| 87 |
+
st.markdown("**Reference Images (Optional)**")
|
| 88 |
+
|
| 89 |
+
col_upload, col_library = st.columns([3, 1])
|
| 90 |
+
|
| 91 |
+
with col_upload:
|
| 92 |
+
input_images = render_multi_image_uploader(
|
| 93 |
+
label="Upload reference images (e.g., face reference, costume reference)",
|
| 94 |
+
key="standard_input_images",
|
| 95 |
+
max_images=3,
|
| 96 |
+
show_previews=True
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
with col_library:
|
| 100 |
+
st.write("") # Spacing
|
| 101 |
+
st.write("")
|
| 102 |
+
render_library_button("standard_input", "📚 From Library")
|
| 103 |
+
|
| 104 |
+
# Check for library selection
|
| 105 |
+
selected_library = render_library_modal("standard_input", allow_multiple=True)
|
| 106 |
+
if selected_library:
|
| 107 |
+
# Load images from library
|
| 108 |
+
library_images = [library.load_image(img_id) for img_id in selected_library]
|
| 109 |
+
|
| 110 |
+
# Merge with uploaded images
|
| 111 |
+
# First, get valid uploaded images
|
| 112 |
+
uploaded_valid = [img for img in input_images if img is not None]
|
| 113 |
+
|
| 114 |
+
# Combine: uploaded first, then library images (up to max 3 total)
|
| 115 |
+
combined_images = uploaded_valid + library_images
|
| 116 |
+
combined_images = combined_images[:3] # Limit to 3 images
|
| 117 |
+
|
| 118 |
+
# Store in session state to persist
|
| 119 |
+
st.session_state['standard_combined_images'] = combined_images
|
| 120 |
+
st.success(f"✅ Added {len(library_images)} image(s) from library")
|
| 121 |
+
|
| 122 |
+
# Use combined images if available, otherwise use uploaded
|
| 123 |
+
if 'standard_combined_images' in st.session_state and st.session_state['standard_combined_images']:
|
| 124 |
+
valid_images = st.session_state['standard_combined_images']
|
| 125 |
+
else:
|
| 126 |
+
# Filter out None images
|
| 127 |
+
valid_images = [img for img in input_images if img is not None]
|
| 128 |
+
|
| 129 |
+
# Per-image character sheet checkboxes
|
| 130 |
+
image_is_char_sheet = []
|
| 131 |
+
if valid_images:
|
| 132 |
+
st.markdown("**Mark Character Sheets:**")
|
| 133 |
+
cols = st.columns(len(valid_images))
|
| 134 |
+
for i, (col, img) in enumerate(zip(cols, valid_images)):
|
| 135 |
+
with col:
|
| 136 |
+
is_char_sheet = st.checkbox(
|
| 137 |
+
f"📋 Image {i+1} is Character Sheet",
|
| 138 |
+
value=False,
|
| 139 |
+
key=f"standard_img{i+1}_is_char_sheet",
|
| 140 |
+
help="Check if this reference image is a character sheet"
|
| 141 |
+
)
|
| 142 |
+
image_is_char_sheet.append(is_char_sheet)
|
| 143 |
+
|
| 144 |
+
# Show info about images
|
| 145 |
+
if use_character_sheet_template:
|
| 146 |
+
st.info(f"📎 Using {len(valid_images)} reference image(s) for character sheet generation")
|
| 147 |
+
else:
|
| 148 |
+
st.info(f"📎 Using {len(valid_images)} reference image(s)")
|
| 149 |
+
|
| 150 |
+
# Vision analysis helper - show if images present but no prompt
|
| 151 |
+
if not prompt.strip():
|
| 152 |
+
st.info("""🖼️ **Vision Analysis**
|
| 153 |
+
|
| 154 |
+
I see {count} image{plural}. Please describe what you'd like me to focus on, and I'll help you refine your creative vision.
|
| 155 |
+
|
| 156 |
+
**Examples:**
|
| 157 |
+
- "Create a variation with different lighting"
|
| 158 |
+
- "Combine elements from these images"
|
| 159 |
+
- "Change the background to a forest scene"
|
| 160 |
+
- "Apply the style from image 1 to image 2"
|
| 161 |
+
""".format(count=len(valid_images), plural="s" if len(valid_images) > 1 else ""))
|
| 162 |
+
|
| 163 |
+
st.divider()
|
| 164 |
+
|
| 165 |
+
st.subheader("Generation Settings")
|
| 166 |
+
|
| 167 |
+
# Aspect ratio
|
| 168 |
+
aspect_ratio = render_aspect_ratio_selector(
|
| 169 |
+
key="standard_aspect_ratio",
|
| 170 |
+
default="16:9 (1344x768)"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Temperature
|
| 174 |
+
temperature = render_temperature_slider(
|
| 175 |
+
key="standard_temperature",
|
| 176 |
+
default=0.7
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# Backend
|
| 180 |
+
backend = render_backend_selector(key="standard_backend")
|
| 181 |
+
|
| 182 |
+
st.divider()
|
| 183 |
+
|
| 184 |
+
# Generate button
|
| 185 |
+
generate_clicked = st.button(
|
| 186 |
+
"🎨 Generate Image",
|
| 187 |
+
type="primary",
|
| 188 |
+
use_container_width=True,
|
| 189 |
+
disabled=not prompt.strip()
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
if not prompt.strip():
|
| 193 |
+
st.warning("⚠️ Please enter a prompt")
|
| 194 |
+
|
| 195 |
+
with col2:
|
| 196 |
+
st.subheader("Output")
|
| 197 |
+
|
| 198 |
+
if generate_clicked and prompt.strip():
|
| 199 |
+
with st.spinner("Generating image..."):
|
| 200 |
+
# Create request
|
| 201 |
+
request = GenerationRequest(
|
| 202 |
+
prompt=prompt,
|
| 203 |
+
backend=backend,
|
| 204 |
+
aspect_ratio=aspect_ratio,
|
| 205 |
+
temperature=temperature,
|
| 206 |
+
input_images=valid_images
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# Generate
|
| 210 |
+
result = service.generate_and_save(
|
| 211 |
+
request=request,
|
| 212 |
+
output_dir=Settings.STANDARD_DIR,
|
| 213 |
+
base_filename="standard_generation"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
st.session_state.standard_result = result
|
| 217 |
+
|
| 218 |
+
# Display result
|
| 219 |
+
if st.session_state.standard_result:
|
| 220 |
+
render_status_display(
|
| 221 |
+
result=st.session_state.standard_result,
|
| 222 |
+
show_logs=False
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# Additional options
|
| 226 |
+
with st.expander("📋 View Recent Logs", expanded=False):
|
| 227 |
+
from utils.logging_utils import get_recent_logs
|
| 228 |
+
logs = get_recent_logs(limit=100)
|
| 229 |
+
if logs:
|
| 230 |
+
st.code("\n".join(logs), language="log", line_numbers=False)
|
| 231 |
+
else:
|
| 232 |
+
st.info("No logs available")
|
character_forge_image/pages/04_📚_Library.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Library Management Page
|
| 3 |
+
=======================
|
| 4 |
+
|
| 5 |
+
View, search, and manage all generated images in the library.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import streamlit as st
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from utils.library_manager import LibraryManager
|
| 13 |
+
from ui.components.library_selector import render_library_stats
|
| 14 |
+
from ui.components.status_display import render_image_with_download, render_fullscreen_modal
|
| 15 |
+
from utils.logging_utils import get_logger
|
| 16 |
+
|
| 17 |
+
logger = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
# Page config
|
| 20 |
+
st.set_page_config(
|
| 21 |
+
page_title="Library - Nano Banana",
|
| 22 |
+
page_icon="📚",
|
| 23 |
+
layout="wide"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
st.title("📚 Image Library")
|
| 27 |
+
st.markdown("View and manage all generated images")
|
| 28 |
+
|
| 29 |
+
# Render fullscreen modal
|
| 30 |
+
render_fullscreen_modal()
|
| 31 |
+
|
| 32 |
+
# Initialize library
|
| 33 |
+
library = LibraryManager()
|
| 34 |
+
|
| 35 |
+
# Show stats
|
| 36 |
+
st.markdown("### Library Statistics")
|
| 37 |
+
render_library_stats()
|
| 38 |
+
|
| 39 |
+
st.divider()
|
| 40 |
+
|
| 41 |
+
# Search and filter controls
|
| 42 |
+
col1, col2, col3 = st.columns([2, 1, 1])
|
| 43 |
+
|
| 44 |
+
with col1:
|
| 45 |
+
search_query = st.text_input(
|
| 46 |
+
"Search",
|
| 47 |
+
placeholder="Search by name, tags, or prompt...",
|
| 48 |
+
help="Search library images"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
with col2:
|
| 52 |
+
filter_options = {
|
| 53 |
+
"All Types": None,
|
| 54 |
+
"Character Sheets": "character_sheet",
|
| 55 |
+
"Wardrobe Changes": "wardrobe",
|
| 56 |
+
"Compositions": "composition",
|
| 57 |
+
"Standard": "standard"
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
filter_type = st.selectbox(
|
| 61 |
+
"Filter by Type",
|
| 62 |
+
options=list(filter_options.keys())
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
with col3:
|
| 66 |
+
sort_options = {
|
| 67 |
+
"Newest First": "newest",
|
| 68 |
+
"Oldest First": "oldest",
|
| 69 |
+
"Most Used": "most_used",
|
| 70 |
+
"Name (A-Z)": "name"
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
sort_by = st.selectbox(
|
| 74 |
+
"Sort By",
|
| 75 |
+
options=list(sort_options.keys())
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Additional filters
|
| 79 |
+
col4, col5 = st.columns(2)
|
| 80 |
+
|
| 81 |
+
with col4:
|
| 82 |
+
favorites_only = st.checkbox("⭐ Favorites Only", value=False)
|
| 83 |
+
|
| 84 |
+
with col5:
|
| 85 |
+
limit = st.slider("Images per page", min_value=10, max_value=100, value=50, step=10)
|
| 86 |
+
|
| 87 |
+
st.divider()
|
| 88 |
+
|
| 89 |
+
# Get entries with filters
|
| 90 |
+
entries = library.get_entries(
|
| 91 |
+
filter_type=filter_options[filter_type],
|
| 92 |
+
search=search_query if search_query else None,
|
| 93 |
+
favorites_only=favorites_only,
|
| 94 |
+
sort_by=sort_options[sort_by],
|
| 95 |
+
limit=limit
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Display count
|
| 99 |
+
st.markdown(f"**Showing {len(entries)} images**")
|
| 100 |
+
|
| 101 |
+
if not entries:
|
| 102 |
+
st.info("No images found. Generate some images to populate your library!")
|
| 103 |
+
else:
|
| 104 |
+
# Grid display (3 columns)
|
| 105 |
+
cols_per_row = 3
|
| 106 |
+
|
| 107 |
+
for i in range(0, len(entries), cols_per_row):
|
| 108 |
+
cols = st.columns(cols_per_row)
|
| 109 |
+
|
| 110 |
+
for col_idx, col in enumerate(cols):
|
| 111 |
+
entry_idx = i + col_idx
|
| 112 |
+
if entry_idx >= len(entries):
|
| 113 |
+
break
|
| 114 |
+
|
| 115 |
+
entry = entries[entry_idx]
|
| 116 |
+
|
| 117 |
+
with col:
|
| 118 |
+
# Container for each entry
|
| 119 |
+
with st.container(border=True):
|
| 120 |
+
# Load thumbnail
|
| 121 |
+
thumbnail = library.load_thumbnail(entry["id"])
|
| 122 |
+
if thumbnail:
|
| 123 |
+
st.image(thumbnail, use_container_width=True)
|
| 124 |
+
else:
|
| 125 |
+
st.warning("Thumbnail not found")
|
| 126 |
+
|
| 127 |
+
# Entry name
|
| 128 |
+
st.markdown(f"**{entry['name']}**")
|
| 129 |
+
|
| 130 |
+
# Metadata
|
| 131 |
+
created = datetime.fromisoformat(entry['created_at'])
|
| 132 |
+
st.caption(f"📅 {created.strftime('%b %d, %Y %H:%M')}")
|
| 133 |
+
st.caption(f"📐 {entry['width']}×{entry['height']}")
|
| 134 |
+
|
| 135 |
+
# Type badge
|
| 136 |
+
type_labels = {
|
| 137 |
+
"character_sheet": "🔥 Character",
|
| 138 |
+
"wardrobe": "👔 Wardrobe",
|
| 139 |
+
"composition": "🎬 Composition",
|
| 140 |
+
"standard": "📸 Standard"
|
| 141 |
+
}
|
| 142 |
+
st.caption(type_labels.get(entry['type'], entry['type']))
|
| 143 |
+
|
| 144 |
+
# Actions
|
| 145 |
+
col_actions = st.columns(3)
|
| 146 |
+
|
| 147 |
+
with col_actions[0]:
|
| 148 |
+
# View button
|
| 149 |
+
if st.button("👁️ View", key=f"view_{entry['id']}", use_container_width=True):
|
| 150 |
+
# Load full image and display
|
| 151 |
+
image = library.load_image(entry["id"])
|
| 152 |
+
if image:
|
| 153 |
+
st.session_state['fullscreen_image'] = image
|
| 154 |
+
st.rerun()
|
| 155 |
+
|
| 156 |
+
with col_actions[1]:
|
| 157 |
+
# Favorite toggle
|
| 158 |
+
is_fav = entry.get("favorite", False)
|
| 159 |
+
fav_label = "⭐" if is_fav else "☆"
|
| 160 |
+
if st.button(fav_label, key=f"fav_{entry['id']}", use_container_width=True):
|
| 161 |
+
library.update_entry(entry["id"], {"favorite": not is_fav})
|
| 162 |
+
st.rerun()
|
| 163 |
+
|
| 164 |
+
with col_actions[2]:
|
| 165 |
+
# Delete button
|
| 166 |
+
if st.button("🗑️", key=f"del_{entry['id']}", use_container_width=True):
|
| 167 |
+
st.session_state[f"confirm_delete_{entry['id']}"] = True
|
| 168 |
+
st.rerun()
|
| 169 |
+
|
| 170 |
+
# Confirm delete dialog
|
| 171 |
+
if st.session_state.get(f"confirm_delete_{entry['id']}", False):
|
| 172 |
+
st.warning("Delete this image?")
|
| 173 |
+
col_confirm = st.columns(2)
|
| 174 |
+
with col_confirm[0]:
|
| 175 |
+
if st.button("✓ Delete", key=f"confirm_del_{entry['id']}", type="primary"):
|
| 176 |
+
library.delete_entry(entry["id"], delete_files=True)
|
| 177 |
+
st.session_state[f"confirm_delete_{entry['id']}"] = False
|
| 178 |
+
st.success("Deleted")
|
| 179 |
+
st.rerun()
|
| 180 |
+
with col_confirm[1]:
|
| 181 |
+
if st.button("✕ Cancel", key=f"cancel_del_{entry['id']}"):
|
| 182 |
+
st.session_state[f"confirm_delete_{entry['id']}"] = False
|
| 183 |
+
st.rerun()
|
| 184 |
+
|
| 185 |
+
# Description (expandable)
|
| 186 |
+
if entry.get("description"):
|
| 187 |
+
with st.expander("Description"):
|
| 188 |
+
st.write(entry["description"])
|
| 189 |
+
|
| 190 |
+
# Tags
|
| 191 |
+
if entry.get("tags"):
|
| 192 |
+
st.markdown(f"🏷️ {', '.join(entry['tags'])}")
|
| 193 |
+
|
| 194 |
+
# Usage stats
|
| 195 |
+
times_used = entry.get("times_used", 0)
|
| 196 |
+
if times_used > 0:
|
| 197 |
+
st.caption(f"Used {times_used} times")
|
| 198 |
+
|
| 199 |
+
st.divider()
|
| 200 |
+
|
| 201 |
+
# Management tools
|
| 202 |
+
st.markdown("### Management Tools")
|
| 203 |
+
|
| 204 |
+
col_tools = st.columns(3)
|
| 205 |
+
|
| 206 |
+
with col_tools[0]:
|
| 207 |
+
if st.button("🔄 Rebuild Index", help="Rebuild library index from file system"):
|
| 208 |
+
with st.spinner("Rebuilding index..."):
|
| 209 |
+
count = library.rebuild_index()
|
| 210 |
+
st.success(f"✅ Rebuilt index with {count} entries")
|
| 211 |
+
st.rerun()
|
| 212 |
+
|
| 213 |
+
with col_tools[1]:
|
| 214 |
+
if st.button("📊 Export Library Info", help="Export library metadata as JSON"):
|
| 215 |
+
import json
|
| 216 |
+
stats = library.get_stats()
|
| 217 |
+
json_str = json.dumps(stats, indent=2)
|
| 218 |
+
st.download_button(
|
| 219 |
+
label="⬇️ Download JSON",
|
| 220 |
+
data=json_str,
|
| 221 |
+
file_name="library_info.json",
|
| 222 |
+
mime="application/json"
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
with col_tools[2]:
|
| 226 |
+
st.markdown(f"**Total Storage:** {library.get_stats()['total_size_mb']} MB")
|
character_forge_image/pages/05_🎭_Character_Persistence.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Character Persistence Page
|
| 3 |
+
===========================
|
| 4 |
+
|
| 5 |
+
Generate new images with consistent characters using character sheets as references.
|
| 6 |
+
Implements the "Persistent Characters in Image Generation" approach from the research paper.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
from services import GenerationService
|
| 14 |
+
from models.generation_request import GenerationRequest
|
| 15 |
+
from ui.components.image_uploader import render_image_uploader
|
| 16 |
+
from ui.components.aspect_ratio_selector import render_aspect_ratio_selector, render_temperature_slider
|
| 17 |
+
from ui.components.backend_selector import render_backend_selector
|
| 18 |
+
from ui.components.status_display import render_status_display, render_backend_health, render_image_with_download
|
| 19 |
+
from ui.components.library_selector import render_library_button, render_library_modal
|
| 20 |
+
from utils.library_manager import LibraryManager
|
| 21 |
+
from config.settings import Settings
|
| 22 |
+
|
| 23 |
+
# Page config
|
| 24 |
+
st.set_page_config(
|
| 25 |
+
page_title="Character Persistence - Nano Banana",
|
| 26 |
+
page_icon="🎭",
|
| 27 |
+
layout="wide"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
st.title("🎭 Character Persistence")
|
| 31 |
+
st.markdown("Generate new images with consistent characters using character sheets")
|
| 32 |
+
|
| 33 |
+
# Initialize session state
|
| 34 |
+
if 'persistence_result' not in st.session_state:
|
| 35 |
+
st.session_state.persistence_result = None
|
| 36 |
+
if 'selected_character_sheet' not in st.session_state:
|
| 37 |
+
st.session_state.selected_character_sheet = None
|
| 38 |
+
|
| 39 |
+
# Create service and library
|
| 40 |
+
service = GenerationService(api_key=st.session_state.get('gemini_api_key'))
|
| 41 |
+
library = LibraryManager()
|
| 42 |
+
|
| 43 |
+
# Show backend health
|
| 44 |
+
with st.expander("🏥 Backend Status", expanded=False):
|
| 45 |
+
render_backend_health(service, show_all=True)
|
| 46 |
+
|
| 47 |
+
st.divider()
|
| 48 |
+
|
| 49 |
+
# Information box
|
| 50 |
+
st.info(
|
| 51 |
+
"""
|
| 52 |
+
💡 **How Character Persistence Works:**
|
| 53 |
+
|
| 54 |
+
1. **Select a character sheet** from your library or upload one
|
| 55 |
+
2. **Describe a new scene** where you want this character to appear
|
| 56 |
+
3. **Generate!** The system will maintain character consistency across all views
|
| 57 |
+
|
| 58 |
+
**Examples:**
|
| 59 |
+
- "The character walking through a forest at sunset"
|
| 60 |
+
- "The character sitting at a cafe, drinking coffee"
|
| 61 |
+
- "The character in medieval armor, standing on a castle wall"
|
| 62 |
+
|
| 63 |
+
The character sheet provides multiple reference views (front, side, rear) ensuring
|
| 64 |
+
consistent appearance from all angles!
|
| 65 |
+
"""
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
st.divider()
|
| 69 |
+
|
| 70 |
+
# Main interface
|
| 71 |
+
col1, col2 = st.columns([1, 1])
|
| 72 |
+
|
| 73 |
+
with col1:
|
| 74 |
+
st.subheader("1. Select Character Sheet")
|
| 75 |
+
|
| 76 |
+
# Character sheet selection
|
| 77 |
+
col_upload, col_library = st.columns([2, 1])
|
| 78 |
+
|
| 79 |
+
with col_upload:
|
| 80 |
+
character_sheet = render_image_uploader(
|
| 81 |
+
label="Upload Character Sheet",
|
| 82 |
+
key="persistence_character_sheet",
|
| 83 |
+
help_text="Upload a character sheet generated from Character Forge"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
with col_library:
|
| 87 |
+
st.write("") # Spacing
|
| 88 |
+
st.write("") # More spacing
|
| 89 |
+
render_library_button("persistence_character_sheet_input", "📚 From Library")
|
| 90 |
+
|
| 91 |
+
# Check for library selection
|
| 92 |
+
if st.session_state.get('persistence_character_sheet_input_selected'):
|
| 93 |
+
selected_path = st.session_state.get('persistence_character_sheet_input_selected')
|
| 94 |
+
try:
|
| 95 |
+
character_sheet = Image.open(selected_path)
|
| 96 |
+
st.session_state.selected_character_sheet = character_sheet
|
| 97 |
+
source = st.session_state.get('persistence_character_sheet_input_source', 'Unknown')
|
| 98 |
+
st.success(f"✅ Character sheet loaded from: {source}")
|
| 99 |
+
# Clear the selection flag
|
| 100 |
+
st.session_state['persistence_character_sheet_input_selected'] = None
|
| 101 |
+
except Exception as e:
|
| 102 |
+
st.error(f"❌ Error loading character sheet: {str(e)}")
|
| 103 |
+
|
| 104 |
+
# Use session state if available
|
| 105 |
+
if character_sheet is None and st.session_state.selected_character_sheet is not None:
|
| 106 |
+
character_sheet = st.session_state.selected_character_sheet
|
| 107 |
+
|
| 108 |
+
# Show preview if loaded
|
| 109 |
+
if character_sheet:
|
| 110 |
+
with st.expander("📋 Character Sheet Preview", expanded=True):
|
| 111 |
+
st.image(character_sheet, use_container_width=True)
|
| 112 |
+
|
| 113 |
+
st.divider()
|
| 114 |
+
|
| 115 |
+
st.subheader("2. Describe New Scene")
|
| 116 |
+
|
| 117 |
+
# Preset scenarios
|
| 118 |
+
preset_scenarios = {
|
| 119 |
+
"Custom (write your own)": "",
|
| 120 |
+
"Walking through forest at sunset": "The character walking through a lush forest at golden hour sunset, dappled sunlight filtering through trees",
|
| 121 |
+
"Sitting at cafe": "The character sitting at an outdoor cafe table, drinking coffee, relaxed casual pose, urban environment",
|
| 122 |
+
"Medieval armor on castle wall": "The character wearing ornate medieval plate armor, standing on castle battlements, dramatic sky in background",
|
| 123 |
+
"Running on beach": "The character running along a beach at sunrise, barefoot in sand, ocean waves in background",
|
| 124 |
+
"Dancing at party": "The character dancing joyfully at a party, colorful lights, festive atmosphere",
|
| 125 |
+
"Reading in library": "The character sitting in a cozy library reading a book, surrounded by bookshelves, warm lighting",
|
| 126 |
+
"Cooking in kitchen": "The character cooking in a modern kitchen, focused expression, ingredients on counter",
|
| 127 |
+
"Playing guitar": "The character playing an acoustic guitar, sitting on a stool, intimate concert setting"
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
selected_preset = st.selectbox(
|
| 131 |
+
"Preset Scenarios (optional)",
|
| 132 |
+
options=list(preset_scenarios.keys()),
|
| 133 |
+
index=0,
|
| 134 |
+
help="Choose a preset scenario or write your own custom description"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Prompt input
|
| 138 |
+
default_prompt = preset_scenarios[selected_preset]
|
| 139 |
+
|
| 140 |
+
scene_prompt = st.text_area(
|
| 141 |
+
"Scene Description",
|
| 142 |
+
height=150,
|
| 143 |
+
value=default_prompt,
|
| 144 |
+
placeholder="Describe the new scene where your character should appear...",
|
| 145 |
+
help="Describe the action, environment, and mood. The character's appearance will be maintained from the reference sheet.",
|
| 146 |
+
key="persistence_scene_prompt"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# Additional options
|
| 150 |
+
with st.expander("⚙️ Advanced Options", expanded=False):
|
| 151 |
+
# Aspect ratio
|
| 152 |
+
aspect_ratio = render_aspect_ratio_selector(key="persistence_aspect_ratio")
|
| 153 |
+
|
| 154 |
+
# Parse width and height from aspect_ratio string like "16:9 (1344x768)"
|
| 155 |
+
import re
|
| 156 |
+
match = re.search(r'\((\d+)x(\d+)\)', aspect_ratio)
|
| 157 |
+
if match:
|
| 158 |
+
width = int(match.group(1))
|
| 159 |
+
height = int(match.group(2))
|
| 160 |
+
else:
|
| 161 |
+
# Default to 1344x768 if parsing fails
|
| 162 |
+
width, height = 1344, 768
|
| 163 |
+
|
| 164 |
+
# Temperature
|
| 165 |
+
temperature = render_temperature_slider(
|
| 166 |
+
default=0.45,
|
| 167 |
+
key="persistence_temperature",
|
| 168 |
+
help_text="Lower values = more faithful to character sheet. Recommended: 0.35-0.55 for character consistency."
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Reference strength (conceptual - implementation depends on backend)
|
| 172 |
+
reference_strength = st.slider(
|
| 173 |
+
"Character Reference Strength",
|
| 174 |
+
min_value=0.0,
|
| 175 |
+
max_value=1.0,
|
| 176 |
+
value=0.85,
|
| 177 |
+
step=0.05,
|
| 178 |
+
help="How strongly to maintain character appearance. Higher = more consistent with sheet, but less variation in pose/expression."
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Multi-character support
|
| 182 |
+
enable_multi_character = st.checkbox(
|
| 183 |
+
"Multi-Character Scene",
|
| 184 |
+
value=False,
|
| 185 |
+
help="Enable this to add additional characters to the scene (experimental)"
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
if enable_multi_character:
|
| 189 |
+
st.info("💡 Upload a second character sheet to add another character to the scene")
|
| 190 |
+
character_sheet_2 = render_image_uploader(
|
| 191 |
+
label="Second Character Sheet (Optional)",
|
| 192 |
+
key="persistence_character_sheet_2",
|
| 193 |
+
help_text="Add a second character to the scene"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Backend selection
|
| 197 |
+
st.divider()
|
| 198 |
+
backend = render_backend_selector(
|
| 199 |
+
key="persistence_backend",
|
| 200 |
+
help_text="Choose the backend for image generation. Gemini recommended for best character consistency."
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Generate button
|
| 204 |
+
st.divider()
|
| 205 |
+
|
| 206 |
+
can_generate = character_sheet is not None and scene_prompt.strip() != ""
|
| 207 |
+
|
| 208 |
+
if not can_generate:
|
| 209 |
+
if character_sheet is None:
|
| 210 |
+
st.warning("⚠️ Please upload or select a character sheet first")
|
| 211 |
+
if scene_prompt.strip() == "":
|
| 212 |
+
st.warning("⚠️ Please describe the new scene")
|
| 213 |
+
|
| 214 |
+
generate_button = st.button(
|
| 215 |
+
"🎨 Generate Scene with Character",
|
| 216 |
+
type="primary",
|
| 217 |
+
disabled=not can_generate,
|
| 218 |
+
use_container_width=True
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
with col2:
|
| 222 |
+
st.subheader("Result")
|
| 223 |
+
|
| 224 |
+
# Generation logic
|
| 225 |
+
if generate_button and character_sheet and scene_prompt:
|
| 226 |
+
with st.spinner("🎨 Generating scene with your character..."):
|
| 227 |
+
try:
|
| 228 |
+
# Build prompt that references the character
|
| 229 |
+
full_prompt = f"{scene_prompt}. Maintain exact character appearance, facial features, hairstyle, body proportions, and costume from the reference images. Ensure consistency across all details."
|
| 230 |
+
|
| 231 |
+
# Create generation request
|
| 232 |
+
request = GenerationRequest(
|
| 233 |
+
prompt=full_prompt,
|
| 234 |
+
input_images=[character_sheet],
|
| 235 |
+
is_character_sheet=[True], # Mark as character sheet reference
|
| 236 |
+
aspect_ratio=f"{width}:{height}",
|
| 237 |
+
temperature=temperature,
|
| 238 |
+
backend=backend
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Generate
|
| 242 |
+
result_image, status = service.generate(request)
|
| 243 |
+
|
| 244 |
+
if result_image:
|
| 245 |
+
st.session_state.persistence_result = {
|
| 246 |
+
'image': result_image,
|
| 247 |
+
'prompt': scene_prompt,
|
| 248 |
+
'full_prompt': full_prompt,
|
| 249 |
+
'character_sheet': character_sheet,
|
| 250 |
+
'backend': backend,
|
| 251 |
+
'aspect_ratio': aspect_ratio,
|
| 252 |
+
'temperature': temperature
|
| 253 |
+
}
|
| 254 |
+
st.success(f"✅ {status}")
|
| 255 |
+
else:
|
| 256 |
+
st.error(f"❌ {status}")
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
st.error(f"❌ Error during generation: {str(e)}")
|
| 260 |
+
|
| 261 |
+
# Display result
|
| 262 |
+
if st.session_state.persistence_result:
|
| 263 |
+
result = st.session_state.persistence_result
|
| 264 |
+
|
| 265 |
+
# Show generated image
|
| 266 |
+
render_image_with_download(
|
| 267 |
+
result['image'],
|
| 268 |
+
filename="character_persistence.png",
|
| 269 |
+
caption="Generated Scene"
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Show metadata
|
| 273 |
+
with st.expander("📊 Generation Details", expanded=False):
|
| 274 |
+
st.markdown(f"**Scene Description:** {result['prompt']}")
|
| 275 |
+
st.markdown(f"**Backend:** {result['backend']}")
|
| 276 |
+
st.markdown(f"**Aspect Ratio:** {result['aspect_ratio']}")
|
| 277 |
+
st.markdown(f"**Temperature:** {result['temperature']:.2f}")
|
| 278 |
+
|
| 279 |
+
st.markdown("---")
|
| 280 |
+
st.markdown("**Full Prompt Sent:**")
|
| 281 |
+
st.code(result['full_prompt'], language=None)
|
| 282 |
+
|
| 283 |
+
# Save to library
|
| 284 |
+
st.divider()
|
| 285 |
+
|
| 286 |
+
col_save1, col_save2 = st.columns(2)
|
| 287 |
+
|
| 288 |
+
with col_save1:
|
| 289 |
+
save_name = st.text_input(
|
| 290 |
+
"Save to Library",
|
| 291 |
+
value="character_scene",
|
| 292 |
+
key="persistence_save_name",
|
| 293 |
+
placeholder="Enter filename..."
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
with col_save2:
|
| 297 |
+
st.write("") # Spacing
|
| 298 |
+
st.write("") # More spacing
|
| 299 |
+
if st.button("💾 Save to Library", key="persistence_save_button"):
|
| 300 |
+
if save_name.strip():
|
| 301 |
+
try:
|
| 302 |
+
saved_path = library.save_image(
|
| 303 |
+
result['image'],
|
| 304 |
+
name=save_name.strip(),
|
| 305 |
+
category="generated",
|
| 306 |
+
description=f"Character persistence: {result['prompt'][:100]}"
|
| 307 |
+
)
|
| 308 |
+
st.success(f"✅ Saved to library: {saved_path.name}")
|
| 309 |
+
except Exception as e:
|
| 310 |
+
st.error(f"❌ Error saving: {str(e)}")
|
| 311 |
+
else:
|
| 312 |
+
st.warning("⚠️ Please enter a filename")
|
| 313 |
+
|
| 314 |
+
# Generate variations
|
| 315 |
+
st.divider()
|
| 316 |
+
st.markdown("### Generate More Scenes")
|
| 317 |
+
|
| 318 |
+
if st.button("🔄 Generate Another Scene", use_container_width=True):
|
| 319 |
+
st.session_state.persistence_result = None
|
| 320 |
+
st.rerun()
|
| 321 |
+
|
| 322 |
+
else:
|
| 323 |
+
# Show placeholder
|
| 324 |
+
st.info(
|
| 325 |
+
"""
|
| 326 |
+
👈 **Get Started:**
|
| 327 |
+
|
| 328 |
+
1. Select or upload a character sheet on the left
|
| 329 |
+
2. Choose a preset scenario or write your own
|
| 330 |
+
3. Click "Generate Scene with Character"
|
| 331 |
+
|
| 332 |
+
Your character will appear in the new scene while maintaining
|
| 333 |
+
consistent appearance from all angles!
|
| 334 |
+
"""
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
# Show example
|
| 338 |
+
st.markdown("---")
|
| 339 |
+
st.markdown("### 📖 Example Workflow")
|
| 340 |
+
st.markdown(
|
| 341 |
+
"""
|
| 342 |
+
1. **Create Character Sheet** (in Character Forge):
|
| 343 |
+
- Upload face/body image
|
| 344 |
+
- Generate turnaround sheet (front, side, rear views)
|
| 345 |
+
|
| 346 |
+
2. **Use Character Sheet** (here):
|
| 347 |
+
- Load character sheet from library
|
| 348 |
+
- Describe: "Character walking through forest at sunset"
|
| 349 |
+
- Generate!
|
| 350 |
+
|
| 351 |
+
3. **Create Multiple Scenes**:
|
| 352 |
+
- Same character, different scenarios
|
| 353 |
+
- Consistent appearance across all generations
|
| 354 |
+
- Build a complete story or portfolio
|
| 355 |
+
|
| 356 |
+
**Why this works:** The character sheet provides multiple reference views,
|
| 357 |
+
so the AI knows how your character looks from every angle. No more
|
| 358 |
+
inconsistent features when the character turns around!
|
| 359 |
+
"""
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
# Render library modal
|
| 363 |
+
render_library_modal()
|
| 364 |
+
|
| 365 |
+
# Footer
|
| 366 |
+
st.divider()
|
| 367 |
+
st.markdown(
|
| 368 |
+
"""
|
| 369 |
+
<div style='text-align: center; color: #666; font-size: 0.9em;'>
|
| 370 |
+
<p>💡 <strong>Pro Tip:</strong> Use Character Forge to create high-quality character sheets,
|
| 371 |
+
then use them here to generate unlimited scenes with perfect character consistency!</p>
|
| 372 |
+
|
| 373 |
+
<p>📚 <strong>Research:</strong> This feature implements "Persistent Characters in Image Generation"
|
| 374 |
+
from our <a href="docs/PERSISTENT_CHARACTERS_PAPER.md">research paper</a>.</p>
|
| 375 |
+
</div>
|
| 376 |
+
""",
|
| 377 |
+
unsafe_allow_html=True
|
| 378 |
+
)
|
character_forge_image/plugins/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Backend Plugins
|
| 3 |
+
|
| 4 |
+
Plugin adapters for all image generation backends.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .gemini_plugin import GeminiPlugin
|
| 8 |
+
from .omnigen2_plugin import OmniGen2Plugin
|
| 9 |
+
from .comfyui_plugin import ComfyUIPlugin
|
| 10 |
+
|
| 11 |
+
__all__ = ['GeminiPlugin', 'OmniGen2Plugin', 'ComfyUIPlugin']
|
character_forge_image/plugins/comfyui_plugin.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ComfyUI Backend Plugin
|
| 3 |
+
|
| 4 |
+
Plugin adapter for ComfyUI local backend with qwen_image_edit_2509.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Dict, Optional, List
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
# Add parent directories to path for imports
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 16 |
+
|
| 17 |
+
from core.comfyui_client import ComfyUIClient
|
| 18 |
+
from config.settings import Settings
|
| 19 |
+
|
| 20 |
+
# Import from shared plugin system
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'shared'))
|
| 22 |
+
from plugin_system.base_plugin import BaseBackendPlugin
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ComfyUIPlugin(BaseBackendPlugin):
|
| 26 |
+
"""Plugin adapter for ComfyUI backend using qwen_image_edit_2509."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, config_path: Path):
|
| 29 |
+
"""Initialize ComfyUI plugin."""
|
| 30 |
+
super().__init__(config_path)
|
| 31 |
+
|
| 32 |
+
# Get settings
|
| 33 |
+
settings = Settings()
|
| 34 |
+
server_address = settings.COMFYUI_BASE_URL.replace("http://", "")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
self.client = ComfyUIClient(server_address=server_address)
|
| 38 |
+
# Test connection
|
| 39 |
+
healthy, _ = self.client.health_check()
|
| 40 |
+
self.available = healthy
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"Warning: ComfyUI backend not available: {e}")
|
| 43 |
+
self.client = None
|
| 44 |
+
self.available = False
|
| 45 |
+
|
| 46 |
+
# Load qwen workflow template
|
| 47 |
+
self.workflow_template = None
|
| 48 |
+
workflow_path = Path(__file__).parent.parent.parent / 'tools' / 'comfyui' / 'workflows' / 'qwen_image_edit.json'
|
| 49 |
+
if workflow_path.exists():
|
| 50 |
+
with open(workflow_path) as f:
|
| 51 |
+
self.workflow_template = json.load(f)
|
| 52 |
+
else:
|
| 53 |
+
print(f"Warning: Workflow template not found at {workflow_path}")
|
| 54 |
+
|
| 55 |
+
def health_check(self) -> bool:
|
| 56 |
+
"""Check if ComfyUI backend is available."""
|
| 57 |
+
if not self.available or self.client is None:
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
healthy, _ = self.client.health_check()
|
| 62 |
+
return healthy
|
| 63 |
+
except:
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
def _update_qwen_workflow(
|
| 67 |
+
self,
|
| 68 |
+
workflow: dict,
|
| 69 |
+
prompt: str = None,
|
| 70 |
+
negative_prompt: str = None,
|
| 71 |
+
input_image_filename: str = None,
|
| 72 |
+
seed: int = None,
|
| 73 |
+
width: int = None,
|
| 74 |
+
height: int = None
|
| 75 |
+
) -> dict:
|
| 76 |
+
"""
|
| 77 |
+
Update workflow parameters for qwen_image_edit workflow.
|
| 78 |
+
|
| 79 |
+
Node IDs for qwen_image_edit.json:
|
| 80 |
+
- 111: Positive prompt (TextEncodeQwenImageEditPlus)
|
| 81 |
+
- 110: Negative prompt (TextEncodeQwenImageEditPlus)
|
| 82 |
+
- 78: Load Image
|
| 83 |
+
- 3: KSampler (seed)
|
| 84 |
+
- 112: EmptySD3LatentImage (width, height)
|
| 85 |
+
"""
|
| 86 |
+
# Clone workflow to avoid modifying original
|
| 87 |
+
wf = json.loads(json.dumps(workflow))
|
| 88 |
+
|
| 89 |
+
# Update prompt
|
| 90 |
+
if prompt is not None:
|
| 91 |
+
wf["111"]["inputs"]["prompt"] = prompt
|
| 92 |
+
|
| 93 |
+
# Update negative prompt
|
| 94 |
+
if negative_prompt is not None:
|
| 95 |
+
wf["110"]["inputs"]["prompt"] = negative_prompt
|
| 96 |
+
|
| 97 |
+
# Update input image
|
| 98 |
+
if input_image_filename is not None:
|
| 99 |
+
wf["78"]["inputs"]["image"] = input_image_filename
|
| 100 |
+
|
| 101 |
+
# Update seed
|
| 102 |
+
if seed is not None:
|
| 103 |
+
wf["3"]["inputs"]["seed"] = seed
|
| 104 |
+
else:
|
| 105 |
+
# Random seed if not specified
|
| 106 |
+
wf["3"]["inputs"]["seed"] = random.randint(1, 2**32 - 1)
|
| 107 |
+
|
| 108 |
+
# Update dimensions
|
| 109 |
+
if width is not None:
|
| 110 |
+
wf["112"]["inputs"]["width"] = width
|
| 111 |
+
if height is not None:
|
| 112 |
+
wf["112"]["inputs"]["height"] = height
|
| 113 |
+
|
| 114 |
+
return wf
|
| 115 |
+
|
| 116 |
+
def generate_image(
|
| 117 |
+
self,
|
| 118 |
+
prompt: str,
|
| 119 |
+
input_images: Optional[List[Image.Image]] = None,
|
| 120 |
+
**kwargs
|
| 121 |
+
) -> Image.Image:
|
| 122 |
+
"""
|
| 123 |
+
Generate image using ComfyUI qwen_image_edit_2509 workflow.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
prompt: Text prompt for image editing
|
| 127 |
+
input_images: Optional list of input images (uses first image)
|
| 128 |
+
**kwargs: Additional parameters (negative_prompt, seed, width, height)
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
Generated PIL Image
|
| 132 |
+
"""
|
| 133 |
+
if not self.health_check():
|
| 134 |
+
raise RuntimeError("ComfyUI backend not available")
|
| 135 |
+
|
| 136 |
+
if self.workflow_template is None:
|
| 137 |
+
raise RuntimeError("Workflow template not loaded")
|
| 138 |
+
|
| 139 |
+
if not input_images or len(input_images) == 0:
|
| 140 |
+
raise ValueError("qwen_image_edit_2509 requires an input image")
|
| 141 |
+
|
| 142 |
+
# Upload input image
|
| 143 |
+
input_image = input_images[0]
|
| 144 |
+
uploaded_filename = self.client.upload_image(input_image)
|
| 145 |
+
|
| 146 |
+
# Get parameters from kwargs
|
| 147 |
+
negative_prompt = kwargs.get('negative_prompt', '')
|
| 148 |
+
seed = kwargs.get('seed', None)
|
| 149 |
+
width = kwargs.get('width', 1024)
|
| 150 |
+
height = kwargs.get('height', 1024)
|
| 151 |
+
|
| 152 |
+
# Update workflow with parameters
|
| 153 |
+
workflow = self._update_qwen_workflow(
|
| 154 |
+
self.workflow_template,
|
| 155 |
+
prompt=prompt,
|
| 156 |
+
negative_prompt=negative_prompt,
|
| 157 |
+
input_image_filename=uploaded_filename,
|
| 158 |
+
seed=seed,
|
| 159 |
+
width=width,
|
| 160 |
+
height=height
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Execute workflow
|
| 164 |
+
images = self.client.execute_workflow(workflow)
|
| 165 |
+
|
| 166 |
+
if not images:
|
| 167 |
+
raise RuntimeError("No images generated")
|
| 168 |
+
|
| 169 |
+
# Return first image
|
| 170 |
+
return images[0]
|
| 171 |
+
|
| 172 |
+
def get_capabilities(self) -> Dict[str, Any]:
|
| 173 |
+
"""Report ComfyUI backend capabilities."""
|
| 174 |
+
return {
|
| 175 |
+
'name': 'ComfyUI Local',
|
| 176 |
+
'type': 'local',
|
| 177 |
+
'supports_input_images': True,
|
| 178 |
+
'supports_multi_image': True,
|
| 179 |
+
'max_input_images': 16,
|
| 180 |
+
'supports_aspect_ratios': True,
|
| 181 |
+
'available_aspect_ratios': ['1:1', '3:4', '4:3', '9:16', '16:9'],
|
| 182 |
+
'supports_guidance_scale': True,
|
| 183 |
+
'supports_inference_steps': True,
|
| 184 |
+
'supports_seed': True,
|
| 185 |
+
'available_models': [
|
| 186 |
+
'qwen_image_edit_2509', # To be installed
|
| 187 |
+
'flux.1_kontext_ai' # To be installed
|
| 188 |
+
],
|
| 189 |
+
'status': 'partial', # Needs workflow implementation
|
| 190 |
+
'estimated_time_per_image': 3.0, # seconds (depends on GPU and model)
|
| 191 |
+
'cost_per_image': 0.0, # Free, local
|
| 192 |
+
}
|
character_forge_image/plugins/gemini_plugin.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini Backend Plugin
|
| 3 |
+
|
| 4 |
+
Plugin adapter for Gemini 2.5 Flash Image API backend.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, Optional, List
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
# Add parent directories to path for imports
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 14 |
+
|
| 15 |
+
from core.gemini_client import GeminiClient
|
| 16 |
+
from models.generation_request import GenerationRequest
|
| 17 |
+
from config.settings import Settings
|
| 18 |
+
|
| 19 |
+
# Import from shared plugin system
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'shared'))
|
| 21 |
+
from plugin_system.base_plugin import BaseBackendPlugin
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class GeminiPlugin(BaseBackendPlugin):
|
| 25 |
+
"""Plugin adapter for Gemini API backend."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, config_path: Path):
|
| 28 |
+
"""Initialize Gemini plugin."""
|
| 29 |
+
super().__init__(config_path)
|
| 30 |
+
|
| 31 |
+
# Get API key from settings
|
| 32 |
+
settings = Settings()
|
| 33 |
+
api_key = settings.get_api_key()
|
| 34 |
+
|
| 35 |
+
if api_key:
|
| 36 |
+
self.client = GeminiClient(api_key)
|
| 37 |
+
self.available = True
|
| 38 |
+
else:
|
| 39 |
+
self.client = None
|
| 40 |
+
self.available = False
|
| 41 |
+
print("Warning: Gemini API key not found")
|
| 42 |
+
|
| 43 |
+
def health_check(self) -> bool:
|
| 44 |
+
"""Check if Gemini backend is available."""
|
| 45 |
+
return self.available and self.client is not None
|
| 46 |
+
|
| 47 |
+
def generate_image(
|
| 48 |
+
self,
|
| 49 |
+
prompt: str,
|
| 50 |
+
input_images: Optional[List[Image.Image]] = None,
|
| 51 |
+
**kwargs
|
| 52 |
+
) -> Image.Image:
|
| 53 |
+
"""
|
| 54 |
+
Generate image using Gemini backend.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
prompt: Text prompt for generation
|
| 58 |
+
input_images: Optional list of input images
|
| 59 |
+
**kwargs: Additional generation parameters
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Generated PIL Image
|
| 63 |
+
"""
|
| 64 |
+
if not self.health_check():
|
| 65 |
+
raise RuntimeError("Gemini backend not available")
|
| 66 |
+
|
| 67 |
+
# Create generation request
|
| 68 |
+
request = GenerationRequest(
|
| 69 |
+
prompt=prompt,
|
| 70 |
+
input_images=input_images or [],
|
| 71 |
+
aspect_ratio=kwargs.get('aspect_ratio', '1:1'),
|
| 72 |
+
number_of_images=kwargs.get('number_of_images', 1),
|
| 73 |
+
safety_filter_level=kwargs.get('safety_filter_level', 'block_some'),
|
| 74 |
+
person_generation=kwargs.get('person_generation', 'allow_all')
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Generate image
|
| 78 |
+
result = self.client.generate(request)
|
| 79 |
+
|
| 80 |
+
if result.images:
|
| 81 |
+
return result.images[0]
|
| 82 |
+
else:
|
| 83 |
+
raise RuntimeError(f"Gemini generation failed: {result.error}")
|
| 84 |
+
|
| 85 |
+
def get_capabilities(self) -> Dict[str, Any]:
|
| 86 |
+
"""Report Gemini backend capabilities."""
|
| 87 |
+
return {
|
| 88 |
+
'name': 'Gemini 2.5 Flash Image',
|
| 89 |
+
'type': 'cloud',
|
| 90 |
+
'supports_input_images': True,
|
| 91 |
+
'supports_multi_image': True,
|
| 92 |
+
'max_input_images': 16,
|
| 93 |
+
'supports_aspect_ratios': True,
|
| 94 |
+
'available_aspect_ratios': ['1:1', '3:4', '4:3', '9:16', '16:9'],
|
| 95 |
+
'supports_safety_filter': True,
|
| 96 |
+
'supports_person_generation': True,
|
| 97 |
+
'estimated_time_per_image': 3.0, # seconds
|
| 98 |
+
'cost_per_image': 0.02, # USD estimate
|
| 99 |
+
}
|
character_forge_image/plugins/omnigen2_plugin.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OmniGen2 Backend Plugin
|
| 3 |
+
|
| 4 |
+
Plugin adapter for OmniGen2 local backend.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, Optional, List
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
# Add parent directories to path for imports
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 14 |
+
|
| 15 |
+
from core.omnigen2_client import OmniGen2Client
|
| 16 |
+
from models.generation_request import GenerationRequest
|
| 17 |
+
from config.settings import Settings
|
| 18 |
+
|
| 19 |
+
# Import from shared plugin system
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'shared'))
|
| 21 |
+
from plugin_system.base_plugin import BaseBackendPlugin
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class OmniGen2Plugin(BaseBackendPlugin):
|
| 25 |
+
"""Plugin adapter for OmniGen2 local backend."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, config_path: Path):
|
| 28 |
+
"""Initialize OmniGen2 plugin."""
|
| 29 |
+
super().__init__(config_path)
|
| 30 |
+
|
| 31 |
+
# Get settings
|
| 32 |
+
settings = Settings()
|
| 33 |
+
base_url = settings.omnigen2_base_url
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
self.client = OmniGen2Client(base_url=base_url)
|
| 37 |
+
# Test connection
|
| 38 |
+
self.available = self.client.health_check()
|
| 39 |
+
except Exception as e:
|
| 40 |
+
print(f"Warning: OmniGen2 backend not available: {e}")
|
| 41 |
+
self.client = None
|
| 42 |
+
self.available = False
|
| 43 |
+
|
| 44 |
+
def health_check(self) -> bool:
|
| 45 |
+
"""Check if OmniGen2 backend is available."""
|
| 46 |
+
if not self.available or self.client is None:
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
return self.client.health_check()
|
| 51 |
+
except:
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
def generate_image(
|
| 55 |
+
self,
|
| 56 |
+
prompt: str,
|
| 57 |
+
input_images: Optional[List[Image.Image]] = None,
|
| 58 |
+
**kwargs
|
| 59 |
+
) -> Image.Image:
|
| 60 |
+
"""
|
| 61 |
+
Generate image using OmniGen2 backend.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
prompt: Text prompt for generation
|
| 65 |
+
input_images: Optional list of input images
|
| 66 |
+
**kwargs: Additional generation parameters
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Generated PIL Image
|
| 70 |
+
"""
|
| 71 |
+
if not self.health_check():
|
| 72 |
+
raise RuntimeError("OmniGen2 backend not available")
|
| 73 |
+
|
| 74 |
+
# Create generation request
|
| 75 |
+
request = GenerationRequest(
|
| 76 |
+
prompt=prompt,
|
| 77 |
+
input_images=input_images or [],
|
| 78 |
+
aspect_ratio=kwargs.get('aspect_ratio', '1:1'),
|
| 79 |
+
number_of_images=kwargs.get('number_of_images', 1),
|
| 80 |
+
guidance_scale=kwargs.get('guidance_scale', 3.0),
|
| 81 |
+
num_inference_steps=kwargs.get('num_inference_steps', 50),
|
| 82 |
+
seed=kwargs.get('seed', -1)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Generate image
|
| 86 |
+
result = self.client.generate(request)
|
| 87 |
+
|
| 88 |
+
if result.images:
|
| 89 |
+
return result.images[0]
|
| 90 |
+
else:
|
| 91 |
+
raise RuntimeError(f"OmniGen2 generation failed: {result.error}")
|
| 92 |
+
|
| 93 |
+
def get_capabilities(self) -> Dict[str, Any]:
|
| 94 |
+
"""Report OmniGen2 backend capabilities."""
|
| 95 |
+
return {
|
| 96 |
+
'name': 'OmniGen2 Local',
|
| 97 |
+
'type': 'local',
|
| 98 |
+
'supports_input_images': True,
|
| 99 |
+
'supports_multi_image': True,
|
| 100 |
+
'max_input_images': 8,
|
| 101 |
+
'supports_aspect_ratios': True,
|
| 102 |
+
'available_aspect_ratios': ['1:1', '3:4', '4:3', '9:16', '16:9', '3:2', '2:3', '4:5', '5:4', '21:9'],
|
| 103 |
+
'supports_guidance_scale': True,
|
| 104 |
+
'supports_inference_steps': True,
|
| 105 |
+
'supports_seed': True,
|
| 106 |
+
'estimated_time_per_image': 8.0, # seconds (depends on GPU)
|
| 107 |
+
'cost_per_image': 0.0, # Free, local
|
| 108 |
+
}
|
character_forge_image/plugins/plugin_registry.yaml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
plugins:
|
| 2 |
+
- name: gemini
|
| 3 |
+
module: gemini_plugin
|
| 4 |
+
class: GeminiPlugin
|
| 5 |
+
enabled: true
|
| 6 |
+
priority: 1
|
| 7 |
+
description: Gemini API cloud backend
|
| 8 |
+
|
| 9 |
+
- name: omnigen2
|
| 10 |
+
module: omnigen2_plugin
|
| 11 |
+
class: OmniGen2Plugin
|
| 12 |
+
enabled: true
|
| 13 |
+
priority: 2
|
| 14 |
+
description: OmniGen2 local multi-modal backend
|
| 15 |
+
|
| 16 |
+
- name: comfyui
|
| 17 |
+
module: comfyui_plugin
|
| 18 |
+
class: ComfyUIPlugin
|
| 19 |
+
enabled: true
|
| 20 |
+
priority: 3
|
| 21 |
+
description: ComfyUI local backend with qwen and Flux.1 Kontext AI
|
character_forge_image/requirements.txt
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nano Banana Streamlit - Python Dependencies
|
| 2 |
+
# Generated: 2025-10-23
|
| 3 |
+
|
| 4 |
+
# Core Framework
|
| 5 |
+
streamlit>=1.31.0
|
| 6 |
+
|
| 7 |
+
# Image Generation Backends
|
| 8 |
+
google-genai>=0.3.0 # Gemini API
|
| 9 |
+
requests>=2.31.0 # For OmniGen2 HTTP client
|
| 10 |
+
websocket-client>=1.7.0 # For ComfyUI WebSocket communication
|
| 11 |
+
|
| 12 |
+
# Image Processing
|
| 13 |
+
Pillow>=10.0.0 # PIL/Image operations
|
| 14 |
+
numpy>=1.24.0 # Array operations
|
| 15 |
+
|
| 16 |
+
# Utilities
|
| 17 |
+
python-dateutil>=2.8.2 # Date/time handling
|
| 18 |
+
pathlib>=1.0.1 # Path operations
|
| 19 |
+
PyYAML>=6.0.0 # YAML configuration files
|
| 20 |
+
|
| 21 |
+
# Logging & Monitoring
|
| 22 |
+
colorlog>=6.7.0 # Colored logging output
|
| 23 |
+
|
| 24 |
+
# Testing
|
| 25 |
+
pytest>=7.4.0 # Test framework
|
| 26 |
+
pytest-cov>=4.1.0 # Coverage reports
|
| 27 |
+
|
| 28 |
+
# Development
|
| 29 |
+
black>=23.12.0 # Code formatting
|
| 30 |
+
flake8>=6.1.0 # Linting
|
| 31 |
+
mypy>=1.8.0 # Type checking
|
| 32 |
+
|
| 33 |
+
# Optional: Enhanced UI
|
| 34 |
+
streamlit-image-comparison # Side-by-side image comparison
|
| 35 |
+
streamlit-extras # Additional components
|
character_forge_image/services/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service layer for Nano Banana Streamlit.
|
| 2 |
+
|
| 3 |
+
Business logic layer that orchestrates backend operations,
|
| 4 |
+
file management, and generation workflows.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from services.generation_service import GenerationService
|
| 8 |
+
from services.character_forge_service import CharacterForgeService
|
| 9 |
+
from services.wardrobe_service import WardrobeService
|
| 10 |
+
from services.composition_service import CompositionService
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
'GenerationService',
|
| 14 |
+
'CharacterForgeService',
|
| 15 |
+
'WardrobeService',
|
| 16 |
+
'CompositionService'
|
| 17 |
+
]
|
character_forge_image/services/character_forge_service.md
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# character_forge_service.py
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Business logic for character turnaround sheet generation. Orchestrates 6-stage pipeline to create professional character sheets with multiple views (front portrait, side portrait, front body, side body, rear body). Core feature extracted from original Gradio implementation.
|
| 5 |
+
|
| 6 |
+
## Responsibilities
|
| 7 |
+
- Orchestrate 6-stage character sheet generation pipeline
|
| 8 |
+
- Normalize input images (face→body or body→face+body)
|
| 9 |
+
- Generate multiple character views with consistency
|
| 10 |
+
- Composite views into final character sheet
|
| 11 |
+
- Extract individual views from completed sheets
|
| 12 |
+
- Manage retry logic with exponential backoff
|
| 13 |
+
- Save complete character sheet packages to disk
|
| 14 |
+
- Handle three input modes: Face Only, Full Body, Face + Body (Separate)
|
| 15 |
+
|
| 16 |
+
## Dependencies
|
| 17 |
+
- `core.BackendRouter` - Backend routing
|
| 18 |
+
- `models.GenerationRequest` - Request dataclass
|
| 19 |
+
- `models.GenerationResult` - Result dataclass
|
| 20 |
+
- `utils.file_utils` - File operations (save_image, ensure_directory_exists, sanitize_filename)
|
| 21 |
+
- `utils.logging_utils` - Logging
|
| 22 |
+
- `config.settings.Settings` - Configuration
|
| 23 |
+
- `PIL.Image` - Image manipulation
|
| 24 |
+
- `time` - Rate limiting between API calls
|
| 25 |
+
- `random` - Rate limiting jitter
|
| 26 |
+
|
| 27 |
+
## Source
|
| 28 |
+
Extracted from `character_forge.py` lines 1120-1690 (Gradio implementation). Refactored to use new architecture.
|
| 29 |
+
|
| 30 |
+
## Public Interface
|
| 31 |
+
|
| 32 |
+
### `CharacterForgeService` class
|
| 33 |
+
|
| 34 |
+
**Constructor:**
|
| 35 |
+
```python
|
| 36 |
+
def __init__(self, api_key: Optional[str] = None)
|
| 37 |
+
```
|
| 38 |
+
- `api_key`: Optional Gemini API key (defaults to Settings)
|
| 39 |
+
- Initializes BackendRouter for backend communication
|
| 40 |
+
|
| 41 |
+
### Key Methods
|
| 42 |
+
|
| 43 |
+
#### `generate_character_sheet(initial_image, initial_image_type, character_name="Character", costume_description="", costume_image=None, face_image=None, body_image=None, backend=Settings.BACKEND_GEMINI, progress_callback=None, output_dir=None) -> Tuple[Optional[Image], str, dict]`
|
| 44 |
+
|
| 45 |
+
Main entry point for character sheet generation.
|
| 46 |
+
|
| 47 |
+
**Pipeline:**
|
| 48 |
+
0. Normalize input (face→body or body→face+body)
|
| 49 |
+
1. Front portrait
|
| 50 |
+
2. Side profile portrait
|
| 51 |
+
3. Side profile full body
|
| 52 |
+
4. Rear view full body
|
| 53 |
+
5. Composite character sheet
|
| 54 |
+
|
| 55 |
+
**Args:**
|
| 56 |
+
- `initial_image`: Starting image (face or body)
|
| 57 |
+
- `initial_image_type`: "Face Only", "Full Body", or "Face + Body (Separate)"
|
| 58 |
+
- `character_name`: Character name (default: "Character")
|
| 59 |
+
- `costume_description`: Text costume description
|
| 60 |
+
- `costume_image`: Optional costume reference
|
| 61 |
+
- `face_image`: Face image (for Face + Body mode)
|
| 62 |
+
- `body_image`: Body image (for Face + Body mode)
|
| 63 |
+
- `backend`: Backend to use (default: Gemini)
|
| 64 |
+
- `progress_callback`: Optional callback(stage: int, message: str)
|
| 65 |
+
- `output_dir`: Optional output directory (defaults to Settings.CHARACTER_SHEETS_DIR)
|
| 66 |
+
|
| 67 |
+
**Returns:**
|
| 68 |
+
- Tuple of `(character_sheet: Image, status_message: str, metadata: dict)`
|
| 69 |
+
|
| 70 |
+
**Usage:**
|
| 71 |
+
```python
|
| 72 |
+
service = CharacterForgeService(api_key="your-key")
|
| 73 |
+
|
| 74 |
+
# Face Only mode
|
| 75 |
+
face_image = Image.open("character_face.png")
|
| 76 |
+
sheet, message, metadata = service.generate_character_sheet(
|
| 77 |
+
initial_image=face_image,
|
| 78 |
+
initial_image_type="Face Only",
|
| 79 |
+
character_name="Hero",
|
| 80 |
+
costume_description="medieval knight armor",
|
| 81 |
+
backend="Gemini API (Cloud)",
|
| 82 |
+
progress_callback=lambda stage, msg: print(f"[{stage}] {msg}"),
|
| 83 |
+
output_dir=Path("outputs/character_sheets")
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
if sheet:
|
| 87 |
+
sheet.show()
|
| 88 |
+
print(f"Success: {message}")
|
| 89 |
+
print(f"Saved to: {metadata.get('saved_to')}")
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
**Input Modes:**
|
| 93 |
+
|
| 94 |
+
1. **Face Only**: User provides face, service generates full body
|
| 95 |
+
- Stage 0a: Generate full body from face
|
| 96 |
+
- Stages 1-5: Generate all views
|
| 97 |
+
|
| 98 |
+
2. **Full Body**: User provides full body, service extracts face
|
| 99 |
+
- Stage 0a: Normalize body to front view
|
| 100 |
+
- Stage 0b: Extract face closeup from body
|
| 101 |
+
- Stages 1-5: Generate all views
|
| 102 |
+
|
| 103 |
+
3. **Face + Body (Separate)**: User provides separate face and body
|
| 104 |
+
- Stage 0a: Normalize body with face details
|
| 105 |
+
- Stages 1-5: Generate all views (use both references)
|
| 106 |
+
|
| 107 |
+
#### `composite_character_sheet(front_portrait, side_portrait, front_body, side_body, rear_body, character_name="Character") -> Image`
|
| 108 |
+
|
| 109 |
+
Composite all views into final character sheet.
|
| 110 |
+
|
| 111 |
+
**Layout:**
|
| 112 |
+
```
|
| 113 |
+
+-------------------+-------------------+
|
| 114 |
+
| Front Portrait | Side Portrait | (3:4 = 1008x1344)
|
| 115 |
+
+-------------------+-------------------+
|
| 116 |
+
| Front Body | Side Body | Rear Body | (9:16 = 768x1344)
|
| 117 |
+
+-------------------+-------------------+
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
**Args:**
|
| 121 |
+
- `front_portrait`: Front face view (3:4)
|
| 122 |
+
- `side_portrait`: Side profile face (3:4)
|
| 123 |
+
- `front_body`: Front full body (9:16)
|
| 124 |
+
- `side_body`: Side full body (9:16)
|
| 125 |
+
- `rear_body`: Rear full body (9:16)
|
| 126 |
+
- `character_name`: Character name
|
| 127 |
+
|
| 128 |
+
**Returns:**
|
| 129 |
+
- Composited character sheet image
|
| 130 |
+
|
| 131 |
+
**Important:**
|
| 132 |
+
- NO SCALING - 1:1 pixel mapping
|
| 133 |
+
- Images pasted as-is from API
|
| 134 |
+
- Must match `extract_views_from_sheet()` layout
|
| 135 |
+
|
| 136 |
+
**Usage:**
|
| 137 |
+
```python
|
| 138 |
+
sheet = service.composite_character_sheet(
|
| 139 |
+
front_portrait=front_port,
|
| 140 |
+
side_portrait=side_port,
|
| 141 |
+
front_body=front_body,
|
| 142 |
+
side_body=side_body,
|
| 143 |
+
rear_body=rear_body,
|
| 144 |
+
character_name="Hero"
|
| 145 |
+
)
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
#### `extract_views_from_sheet(character_sheet) -> Dict[str, Image]`
|
| 149 |
+
|
| 150 |
+
Extract individual views from character sheet.
|
| 151 |
+
|
| 152 |
+
**Must match `composite_character_sheet()` layout exactly.**
|
| 153 |
+
|
| 154 |
+
**Args:**
|
| 155 |
+
- `character_sheet`: Composited character sheet
|
| 156 |
+
|
| 157 |
+
**Returns:**
|
| 158 |
+
- Dictionary with keys: `front_portrait`, `side_portrait`, `front_body`, `side_body`, `rear_body`
|
| 159 |
+
|
| 160 |
+
**Usage:**
|
| 161 |
+
```python
|
| 162 |
+
sheet = Image.open("character_sheet.png")
|
| 163 |
+
views = service.extract_views_from_sheet(sheet)
|
| 164 |
+
views['front_portrait'].show()
|
| 165 |
+
views['side_body'].save("side_body.png")
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
## Private Methods
|
| 169 |
+
|
| 170 |
+
### `_normalize_input(...) -> Tuple[Optional[Image], Optional[Image]]`
|
| 171 |
+
|
| 172 |
+
Normalize input images to create reference full body and face.
|
| 173 |
+
|
| 174 |
+
**Handles three input modes:**
|
| 175 |
+
1. Face + Body (Separate): Normalize body with face details
|
| 176 |
+
2. Face Only: Generate full body from face
|
| 177 |
+
3. Full Body: Normalize body and extract face
|
| 178 |
+
|
| 179 |
+
**Returns:**
|
| 180 |
+
- Tuple of `(reference_full_body, reference_face)`
|
| 181 |
+
|
| 182 |
+
### `_generate_stage(prompt, input_images, aspect_ratio, temperature, backend, stage_name, max_retries=3) -> Tuple[Optional[Image], str]`
|
| 183 |
+
|
| 184 |
+
Generate single stage with retry logic.
|
| 185 |
+
|
| 186 |
+
**Features:**
|
| 187 |
+
- Exponential backoff (2s, 4s, 8s)
|
| 188 |
+
- Rate limiting delay after success (2-3s jitter)
|
| 189 |
+
- Safety block detection (no retry)
|
| 190 |
+
- Detailed logging
|
| 191 |
+
|
| 192 |
+
**Args:**
|
| 193 |
+
- `prompt`: Generation prompt
|
| 194 |
+
- `input_images`: Input reference images
|
| 195 |
+
- `aspect_ratio`: Aspect ratio
|
| 196 |
+
- `temperature`: Temperature
|
| 197 |
+
- `backend`: Backend to use
|
| 198 |
+
- `stage_name`: Stage name for logging
|
| 199 |
+
- `max_retries`: Maximum retry attempts (default: 3)
|
| 200 |
+
|
| 201 |
+
**Returns:**
|
| 202 |
+
- Tuple of `(image, status_message)`
|
| 203 |
+
|
| 204 |
+
**Retry Logic:**
|
| 205 |
+
```python
|
| 206 |
+
for attempt in range(max_retries):
|
| 207 |
+
if attempt > 0:
|
| 208 |
+
wait_time = 2 ** attempt # 2s, 4s, 8s
|
| 209 |
+
time.sleep(wait_time)
|
| 210 |
+
|
| 211 |
+
result = self.router.generate(request)
|
| 212 |
+
|
| 213 |
+
if result.success:
|
| 214 |
+
time.sleep(random.uniform(2.0, 3.0)) # Rate limiting
|
| 215 |
+
return result.image, result.message
|
| 216 |
+
|
| 217 |
+
if "SAFETY" in result.message:
|
| 218 |
+
return None, result.message # No retry
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### `_save_character_sheet(...) -> Path`
|
| 222 |
+
|
| 223 |
+
Save character sheet and all stages to disk.
|
| 224 |
+
|
| 225 |
+
**Saves:**
|
| 226 |
+
- Character sheet (with metadata JSON)
|
| 227 |
+
- All intermediate stages (in `stages/` subdirectory)
|
| 228 |
+
- Input images (in `inputs/` subdirectory)
|
| 229 |
+
- Costume references (if provided)
|
| 230 |
+
|
| 231 |
+
**Directory Structure:**
|
| 232 |
+
```
|
| 233 |
+
output_dir/
|
| 234 |
+
└── {character_name}_{timestamp}/
|
| 235 |
+
├── {character_name}_character_sheet.png
|
| 236 |
+
├── {character_name}_character_sheet.json
|
| 237 |
+
├── stages/
|
| 238 |
+
│ ├── {character_name}_front_portrait.png
|
| 239 |
+
│ ├── {character_name}_side_portrait.png
|
| 240 |
+
│ ├── {character_name}_front_body.png
|
| 241 |
+
│ ├── {character_name}_side_body.png
|
| 242 |
+
│ └── {character_name}_rear_body.png
|
| 243 |
+
└── inputs/
|
| 244 |
+
├── {character_name}_initial_{type}.png
|
| 245 |
+
└── {character_name}_costume_reference.png
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
**Returns:**
|
| 249 |
+
- Path to saved directory
|
| 250 |
+
|
| 251 |
+
## Generation Pipeline
|
| 252 |
+
|
| 253 |
+
### Face Only Mode
|
| 254 |
+
```
|
| 255 |
+
Input: Face image
|
| 256 |
+
↓
|
| 257 |
+
Stage 0a: Generate full body from face
|
| 258 |
+
↓
|
| 259 |
+
Stage 1: Front portrait (from face + body)
|
| 260 |
+
↓
|
| 261 |
+
Stage 2: Side profile portrait (from stage 1 + body)
|
| 262 |
+
↓
|
| 263 |
+
Stage 3: Side profile full body (from stage 2 + 1 + body)
|
| 264 |
+
↓
|
| 265 |
+
Stage 4: Rear view (from stage 1 + 2)
|
| 266 |
+
↓
|
| 267 |
+
Stage 5: Composite all views
|
| 268 |
+
↓
|
| 269 |
+
Output: Character sheet
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
### Full Body Mode
|
| 273 |
+
```
|
| 274 |
+
Input: Full body image
|
| 275 |
+
↓
|
| 276 |
+
Stage 0a: Normalize body to front view
|
| 277 |
+
↓
|
| 278 |
+
Stage 0b: Extract face closeup from body
|
| 279 |
+
↓
|
| 280 |
+
Stage 1: Front portrait (from face + body)
|
| 281 |
+
↓
|
| 282 |
+
Stage 2: Side profile portrait (from stage 1 + body)
|
| 283 |
+
↓
|
| 284 |
+
Stage 3: Side profile full body (from stage 2 + 1 + body)
|
| 285 |
+
↓
|
| 286 |
+
Stage 4: Rear view (from stage 1 + 2)
|
| 287 |
+
↓
|
| 288 |
+
Stage 5: Composite all views
|
| 289 |
+
↓
|
| 290 |
+
Output: Character sheet
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
### Face + Body (Separate) Mode
|
| 294 |
+
```
|
| 295 |
+
Input: Face image + Body image
|
| 296 |
+
↓
|
| 297 |
+
Stage 0a: Normalize body with face details
|
| 298 |
+
↓
|
| 299 |
+
Stage 1: Front portrait (body first, face second - extract face)
|
| 300 |
+
↓
|
| 301 |
+
Stage 2: Side profile portrait (from stage 1 + body + face)
|
| 302 |
+
↓
|
| 303 |
+
Stage 3: Side profile full body (from stage 2 + 1 + body)
|
| 304 |
+
↓
|
| 305 |
+
Stage 4: Rear view (from stage 1 + 2)
|
| 306 |
+
↓
|
| 307 |
+
Stage 5: Composite all views
|
| 308 |
+
↓
|
| 309 |
+
Output: Character sheet
|
| 310 |
+
```
|
| 311 |
+
|
| 312 |
+
## Error Handling
|
| 313 |
+
|
| 314 |
+
Each stage can fail independently:
|
| 315 |
+
```python
|
| 316 |
+
image, status = self._generate_stage(...)
|
| 317 |
+
if image is None:
|
| 318 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 319 |
+
return None, f"Stage X failed: {status}", {}
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
All exceptions caught at top level:
|
| 323 |
+
```python
|
| 324 |
+
except Exception as e:
|
| 325 |
+
logger.exception(f"Character sheet generation failed: {e}")
|
| 326 |
+
return None, f"Character forge error: {str(e)}", {}
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
## Progress Tracking
|
| 330 |
+
|
| 331 |
+
Optional progress callback for UI updates:
|
| 332 |
+
```python
|
| 333 |
+
def progress_callback(stage: int, message: str):
|
| 334 |
+
st.write(f"Stage {stage}/6: {message}")
|
| 335 |
+
|
| 336 |
+
sheet, msg, meta = service.generate_character_sheet(
|
| 337 |
+
...,
|
| 338 |
+
progress_callback=progress_callback
|
| 339 |
+
)
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
## Metadata Format
|
| 343 |
+
|
| 344 |
+
```python
|
| 345 |
+
{
|
| 346 |
+
"character_name": "Hero",
|
| 347 |
+
"initial_image_type": "Face Only",
|
| 348 |
+
"costume_description": "medieval knight armor",
|
| 349 |
+
"has_costume_image": False,
|
| 350 |
+
"backend": "Gemini API (Cloud)",
|
| 351 |
+
"timestamp": "2025-10-23T14:30:00",
|
| 352 |
+
"stages": {
|
| 353 |
+
"front_portrait": "generated",
|
| 354 |
+
"side_portrait": "generated",
|
| 355 |
+
"front_body": "generated", # or "provided"
|
| 356 |
+
"side_body": "generated",
|
| 357 |
+
"rear_body": "generated"
|
| 358 |
+
},
|
| 359 |
+
"saved_to": "/path/to/output/dir"
|
| 360 |
+
}
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
## Related Files
|
| 364 |
+
- `character_forge.py` (old) - Original Gradio implementation source
|
| 365 |
+
- `services/wardrobe_service.py` - Extends this service for wardrobe changes
|
| 366 |
+
- `services/generation_service.py` - Base generation capabilities
|
| 367 |
+
- `core/backend_router.py` - Backend routing
|
| 368 |
+
- `models/generation_request.py` - Request structure
|
| 369 |
+
- `models/generation_result.py` - Result structure
|
| 370 |
+
- `ui/pages/01_🔥_Character_Forge.py` - UI that uses this service
|
character_forge_image/services/character_forge_service.py
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Character Forge Service
|
| 3 |
+
=======================
|
| 4 |
+
|
| 5 |
+
Business logic for character sheet generation.
|
| 6 |
+
Orchestrates 6-stage generation pipeline for turnaround character sheets.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
import random
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional, Tuple, Dict, Any, Callable, List
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 15 |
+
|
| 16 |
+
from core import BackendRouter
|
| 17 |
+
from models.generation_request import GenerationRequest
|
| 18 |
+
from models.generation_result import GenerationResult
|
| 19 |
+
from utils.file_utils import (
|
| 20 |
+
save_image,
|
| 21 |
+
create_generation_metadata,
|
| 22 |
+
ensure_directory_exists,
|
| 23 |
+
sanitize_filename,
|
| 24 |
+
ensure_pil_image
|
| 25 |
+
)
|
| 26 |
+
from utils.logging_utils import get_logger
|
| 27 |
+
from config.settings import Settings
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
logger = get_logger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class CharacterForgeService:
|
| 34 |
+
"""
|
| 35 |
+
Service for generating character turnaround sheets.
|
| 36 |
+
|
| 37 |
+
Orchestrates 6-stage pipeline:
|
| 38 |
+
0. Normalize input (face→body or body→face+body)
|
| 39 |
+
1. Front portrait
|
| 40 |
+
2. Side profile portrait
|
| 41 |
+
3. Side profile full body
|
| 42 |
+
4. Rear view full body
|
| 43 |
+
5. Composite character sheet
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 47 |
+
"""
|
| 48 |
+
Initialize character forge service.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
api_key: Optional Gemini API key
|
| 52 |
+
"""
|
| 53 |
+
self.router = BackendRouter(api_key=api_key)
|
| 54 |
+
logger.info("CharacterForgeService initialized")
|
| 55 |
+
|
| 56 |
+
def get_all_backend_status(self) -> dict:
|
| 57 |
+
"""
|
| 58 |
+
Get health status of all backends.
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Dictionary with backend status info
|
| 62 |
+
"""
|
| 63 |
+
return self.router.get_all_backend_status()
|
| 64 |
+
|
| 65 |
+
def check_backend_availability(self, backend: str) -> Tuple[bool, str]:
|
| 66 |
+
"""
|
| 67 |
+
Check if a specific backend is available.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
backend: Backend name to check
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
Tuple of (is_healthy, status_message)
|
| 74 |
+
"""
|
| 75 |
+
return self.router.check_backend_health(backend)
|
| 76 |
+
|
| 77 |
+
def generate_character_sheet(
|
| 78 |
+
self,
|
| 79 |
+
initial_image: Optional[Image.Image],
|
| 80 |
+
initial_image_type: str,
|
| 81 |
+
character_name: str = "Character",
|
| 82 |
+
gender_term: str = "character",
|
| 83 |
+
costume_description: str = "",
|
| 84 |
+
costume_image: Optional[Image.Image] = None,
|
| 85 |
+
face_image: Optional[Image.Image] = None,
|
| 86 |
+
body_image: Optional[Image.Image] = None,
|
| 87 |
+
backend: str = Settings.BACKEND_GEMINI,
|
| 88 |
+
progress_callback: Optional[Callable[[int, str], None]] = None,
|
| 89 |
+
output_dir: Optional[Path] = None
|
| 90 |
+
) -> Tuple[Optional[Image.Image], str, Dict[str, Any]]:
|
| 91 |
+
"""
|
| 92 |
+
Generate complete character turnaround sheet.
|
| 93 |
+
|
| 94 |
+
Pipeline:
|
| 95 |
+
- Face Only: Generate body, then 5 views
|
| 96 |
+
- Full Body: Extract face, normalize, then 5 views
|
| 97 |
+
- Face + Body: Normalize body, then 5 views
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
initial_image: Starting image (face or body)
|
| 101 |
+
initial_image_type: "Face Only", "Full Body", or "Face + Body (Separate)"
|
| 102 |
+
character_name: Character name
|
| 103 |
+
gender_term: Gender-specific term ("character", "man", or "woman") for better prompts
|
| 104 |
+
costume_description: Text costume description
|
| 105 |
+
costume_image: Optional costume reference
|
| 106 |
+
face_image: Face image (for Face + Body mode)
|
| 107 |
+
body_image: Body image (for Face + Body mode)
|
| 108 |
+
backend: Backend to use
|
| 109 |
+
progress_callback: Optional callback(stage: int, message: str)
|
| 110 |
+
output_dir: Optional output directory (defaults to Settings.CHARACTER_SHEETS_DIR)
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Tuple of (character_sheet: Image, status_message: str, metadata: dict)
|
| 114 |
+
"""
|
| 115 |
+
try:
|
| 116 |
+
logger.info("="*80)
|
| 117 |
+
logger.info(f"STARTING CHARACTER SHEET GENERATION: {character_name}")
|
| 118 |
+
logger.info(f"Initial image type: {initial_image_type}")
|
| 119 |
+
logger.info(f"Costume description: {costume_description or '(none)'}")
|
| 120 |
+
logger.info(f"Costume reference: {'Yes' if costume_image else 'No'}")
|
| 121 |
+
logger.info(f"Backend: {backend}")
|
| 122 |
+
logger.info("="*80)
|
| 123 |
+
|
| 124 |
+
# Storage for generated images
|
| 125 |
+
stages = {}
|
| 126 |
+
current_stage = "Initialization"
|
| 127 |
+
current_prompt = ""
|
| 128 |
+
|
| 129 |
+
# Build costume instruction
|
| 130 |
+
costume_instruction = ""
|
| 131 |
+
if costume_description:
|
| 132 |
+
costume_instruction = f" wearing {costume_description}"
|
| 133 |
+
elif costume_image:
|
| 134 |
+
costume_instruction = " wearing the costume shown in the reference image"
|
| 135 |
+
|
| 136 |
+
# Stage 0: Normalize input to create references
|
| 137 |
+
reference_full_body, reference_face = self._normalize_input(
|
| 138 |
+
initial_image=initial_image,
|
| 139 |
+
initial_image_type=initial_image_type,
|
| 140 |
+
face_image=face_image,
|
| 141 |
+
body_image=body_image,
|
| 142 |
+
costume_instruction=costume_instruction,
|
| 143 |
+
costume_image=costume_image,
|
| 144 |
+
character_name=character_name,
|
| 145 |
+
gender_term=gender_term,
|
| 146 |
+
backend=backend,
|
| 147 |
+
stages=stages,
|
| 148 |
+
progress_callback=progress_callback
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
if reference_full_body is None or reference_face is None:
|
| 152 |
+
return None, "Failed to normalize input images", {}
|
| 153 |
+
|
| 154 |
+
time.sleep(1)
|
| 155 |
+
|
| 156 |
+
# Stage 1: Generate front portrait
|
| 157 |
+
current_stage = "Stage 1/6: Generating front portrait"
|
| 158 |
+
if progress_callback:
|
| 159 |
+
progress_callback(1, current_stage)
|
| 160 |
+
|
| 161 |
+
if initial_image_type == "Face + Body (Separate)":
|
| 162 |
+
current_prompt = f"Generate a close-up frontal facial portrait showing the {gender_term} from the first image (body/costume reference), extrapolate and extract exact facial details from the second image (face reference). Do NOT transfer clothing or hair style from the second image to the first. The face should fill the entire vertical space, neutral grey background with professional photo studio lighting."
|
| 163 |
+
input_images = [reference_full_body, reference_face]
|
| 164 |
+
else:
|
| 165 |
+
# Original prompt - works for ALL backends
|
| 166 |
+
current_prompt = f"Generate a formal portrait view of this {gender_term}{costume_instruction} as depicted in the reference images, in front of a neutral grey background with proper photo studio lighting. The face should fill the entire vertical space. Maintain exact facial features and characteristics from the reference."
|
| 167 |
+
|
| 168 |
+
input_images = [reference_face, reference_full_body]
|
| 169 |
+
if costume_image:
|
| 170 |
+
input_images.append(costume_image)
|
| 171 |
+
|
| 172 |
+
front_portrait, status = self._generate_stage(
|
| 173 |
+
prompt=current_prompt,
|
| 174 |
+
input_images=input_images,
|
| 175 |
+
aspect_ratio="3:4",
|
| 176 |
+
temperature=0.35,
|
| 177 |
+
backend=backend,
|
| 178 |
+
stage_name=current_stage,
|
| 179 |
+
progress_callback=progress_callback
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
if front_portrait is None:
|
| 183 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 184 |
+
return None, f"Stage 1 failed: {status}", {}
|
| 185 |
+
|
| 186 |
+
logger.info(f"{current_stage} complete: {front_portrait.size}")
|
| 187 |
+
stages['front_portrait'] = front_portrait
|
| 188 |
+
stages['stage_1_prompt'] = current_prompt
|
| 189 |
+
time.sleep(1)
|
| 190 |
+
|
| 191 |
+
# Stage 2: Generate side profile portrait
|
| 192 |
+
current_stage = "Stage 2/6: Generating side profile portrait"
|
| 193 |
+
if progress_callback:
|
| 194 |
+
progress_callback(2, current_stage)
|
| 195 |
+
|
| 196 |
+
# Original prompt - works for ALL backends
|
| 197 |
+
current_prompt = f"Create a side profile view of this {gender_term}{costume_instruction} focusing on the face filling the entire available space. The {gender_term} should be shown from the side (90 degree angle) with professional studio lighting against a neutral grey background. Maintain exact facial features from the reference images."
|
| 198 |
+
|
| 199 |
+
input_images = [front_portrait, reference_full_body]
|
| 200 |
+
if initial_image_type == "Face + Body (Separate)":
|
| 201 |
+
input_images.append(reference_face)
|
| 202 |
+
elif costume_image:
|
| 203 |
+
input_images.append(costume_image)
|
| 204 |
+
|
| 205 |
+
side_portrait, status = self._generate_stage(
|
| 206 |
+
prompt=current_prompt,
|
| 207 |
+
input_images=input_images,
|
| 208 |
+
aspect_ratio="3:4",
|
| 209 |
+
temperature=0.35,
|
| 210 |
+
backend=backend,
|
| 211 |
+
stage_name=current_stage,
|
| 212 |
+
progress_callback=progress_callback
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
if side_portrait is None:
|
| 216 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 217 |
+
return None, f"Stage 2 failed: {status}", {}
|
| 218 |
+
|
| 219 |
+
logger.info(f"{current_stage} complete: {side_portrait.size}")
|
| 220 |
+
stages['side_portrait'] = side_portrait
|
| 221 |
+
stages['stage_2_prompt'] = current_prompt
|
| 222 |
+
time.sleep(1)
|
| 223 |
+
|
| 224 |
+
# Stage 3: Generate side profile full body
|
| 225 |
+
current_stage = "Stage 3/6: Generating side profile full body"
|
| 226 |
+
if progress_callback:
|
| 227 |
+
progress_callback(3, current_stage)
|
| 228 |
+
|
| 229 |
+
current_prompt = f"Generate a side profile view of the full body of this {gender_term}{costume_instruction} in front of a neutral grey background with professional studio lighting. The body should fill the entire vertical space available. The {gender_term} should be shown from the side (90 degree angle) in a neutral standing pose. Maintain exact appearance from reference images."
|
| 230 |
+
|
| 231 |
+
input_images = [side_portrait, front_portrait, reference_full_body]
|
| 232 |
+
|
| 233 |
+
side_body, status = self._generate_stage(
|
| 234 |
+
prompt=current_prompt,
|
| 235 |
+
input_images=input_images,
|
| 236 |
+
aspect_ratio="9:16",
|
| 237 |
+
temperature=0.35,
|
| 238 |
+
backend=backend,
|
| 239 |
+
stage_name=current_stage,
|
| 240 |
+
progress_callback=progress_callback
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
if side_body is None:
|
| 244 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 245 |
+
return None, f"Stage 3 failed: {status}", {}
|
| 246 |
+
|
| 247 |
+
logger.info(f"{current_stage} complete: {side_body.size}")
|
| 248 |
+
stages['side_body'] = side_body
|
| 249 |
+
stages['stage_3_prompt'] = current_prompt
|
| 250 |
+
time.sleep(1)
|
| 251 |
+
|
| 252 |
+
# Stage 4: Generate rear view
|
| 253 |
+
current_stage = "Stage 4/6: Generating rear view"
|
| 254 |
+
if progress_callback:
|
| 255 |
+
progress_callback(4, current_stage)
|
| 256 |
+
|
| 257 |
+
current_prompt = f"Generate a rear view image of this {gender_term}{costume_instruction} showing the back of the {gender_term} in a neutral standing pose against a neutral grey background with professional studio lighting. The full body should fill the vertical space. Maintain consistent appearance and proportions from the reference images."
|
| 258 |
+
|
| 259 |
+
input_images = [reference_full_body, side_body]
|
| 260 |
+
if costume_image:
|
| 261 |
+
input_images.append(costume_image)
|
| 262 |
+
|
| 263 |
+
rear_body, status = self._generate_stage(
|
| 264 |
+
prompt=current_prompt,
|
| 265 |
+
input_images=input_images,
|
| 266 |
+
aspect_ratio="9:16",
|
| 267 |
+
temperature=0.35,
|
| 268 |
+
backend=backend,
|
| 269 |
+
stage_name=current_stage,
|
| 270 |
+
progress_callback=progress_callback
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
if rear_body is None:
|
| 274 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 275 |
+
return None, f"Stage 4 failed: {status}", {}
|
| 276 |
+
|
| 277 |
+
logger.info(f"{current_stage} complete: {rear_body.size}")
|
| 278 |
+
stages['rear_body'] = rear_body
|
| 279 |
+
stages['stage_4_prompt'] = current_prompt
|
| 280 |
+
time.sleep(1)
|
| 281 |
+
|
| 282 |
+
# Stage 5: Composite character sheet
|
| 283 |
+
current_stage = "Stage 5/6: Compositing character sheet"
|
| 284 |
+
if progress_callback:
|
| 285 |
+
progress_callback(5, current_stage)
|
| 286 |
+
|
| 287 |
+
logger.info(f"[{current_stage}] Compositing all views into final sheet...")
|
| 288 |
+
|
| 289 |
+
# Quick pre-check: log types and sizes of inputs before composing
|
| 290 |
+
def _img_info(obj):
|
| 291 |
+
try:
|
| 292 |
+
return f"{type(obj).__name__}, size={getattr(obj, 'size', 'n/a')}"
|
| 293 |
+
except Exception:
|
| 294 |
+
return f"{type(obj).__name__}"
|
| 295 |
+
|
| 296 |
+
logger.info(
|
| 297 |
+
"[Composite Precheck] front_portrait=%s, side_portrait=%s, front_body=%s, side_body=%s, rear_body=%s",
|
| 298 |
+
_img_info(front_portrait),
|
| 299 |
+
_img_info(side_portrait),
|
| 300 |
+
_img_info(reference_full_body),
|
| 301 |
+
_img_info(side_body),
|
| 302 |
+
_img_info(rear_body),
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
character_sheet = self.composite_character_sheet(
|
| 306 |
+
front_portrait=front_portrait,
|
| 307 |
+
side_portrait=side_portrait,
|
| 308 |
+
front_body=reference_full_body,
|
| 309 |
+
side_body=side_body,
|
| 310 |
+
rear_body=rear_body,
|
| 311 |
+
character_name=character_name
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
logger.info(f"{current_stage} complete: {character_sheet.size}")
|
| 315 |
+
stages['character_sheet'] = character_sheet
|
| 316 |
+
|
| 317 |
+
# Build metadata (include images and prompts for debugging/testing)
|
| 318 |
+
metadata = {
|
| 319 |
+
"character_name": character_name,
|
| 320 |
+
"initial_image_type": initial_image_type,
|
| 321 |
+
"costume_description": costume_description,
|
| 322 |
+
"has_costume_image": costume_image is not None,
|
| 323 |
+
"backend": backend,
|
| 324 |
+
"timestamp": datetime.now().isoformat(),
|
| 325 |
+
"stages": {
|
| 326 |
+
"reference_full_body": {
|
| 327 |
+
"image": reference_full_body,
|
| 328 |
+
"status": "generated" if initial_image_type == "Face Only" else "provided",
|
| 329 |
+
"prompt": stages.get('stage_0a_prompt', ''),
|
| 330 |
+
"aspect_ratio": "9:16",
|
| 331 |
+
"temperature": 0.5
|
| 332 |
+
},
|
| 333 |
+
"reference_face": {
|
| 334 |
+
"image": reference_face,
|
| 335 |
+
"status": "generated" if initial_image_type in ["Face + Body (Separate)", "Full Body"] else "provided",
|
| 336 |
+
"prompt": stages.get('stage_0b_prompt', ''),
|
| 337 |
+
"aspect_ratio": "3:4",
|
| 338 |
+
"temperature": 0.35
|
| 339 |
+
},
|
| 340 |
+
"front_portrait": {
|
| 341 |
+
"image": front_portrait,
|
| 342 |
+
"status": "generated",
|
| 343 |
+
"prompt": stages.get('stage_1_prompt', ''),
|
| 344 |
+
"negative_prompt": stages.get('stage_1_negative_prompt', ''),
|
| 345 |
+
"aspect_ratio": "3:4",
|
| 346 |
+
"temperature": 0.35
|
| 347 |
+
},
|
| 348 |
+
"side_portrait": {
|
| 349 |
+
"image": side_portrait,
|
| 350 |
+
"status": "generated",
|
| 351 |
+
"prompt": stages.get('stage_2_prompt', ''),
|
| 352 |
+
"negative_prompt": stages.get('stage_2_negative_prompt', ''),
|
| 353 |
+
"aspect_ratio": "3:4",
|
| 354 |
+
"temperature": 0.35
|
| 355 |
+
},
|
| 356 |
+
"side_body": {
|
| 357 |
+
"image": side_body,
|
| 358 |
+
"status": "generated",
|
| 359 |
+
"prompt": stages.get('stage_3_prompt', ''),
|
| 360 |
+
"negative_prompt": stages.get('stage_3_negative_prompt', ''),
|
| 361 |
+
"aspect_ratio": "9:16",
|
| 362 |
+
"temperature": 0.35
|
| 363 |
+
},
|
| 364 |
+
"rear_body": {
|
| 365 |
+
"image": rear_body,
|
| 366 |
+
"status": "generated",
|
| 367 |
+
"prompt": stages.get('stage_4_prompt', ''),
|
| 368 |
+
"negative_prompt": stages.get('stage_4_negative_prompt', ''),
|
| 369 |
+
"aspect_ratio": "9:16",
|
| 370 |
+
"temperature": 0.35
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
success_msg = f"Character sheet generated successfully! Contains {len(stages)} views of {character_name}."
|
| 376 |
+
|
| 377 |
+
# Save to disk if output directory provided
|
| 378 |
+
if output_dir:
|
| 379 |
+
save_dir = self._save_character_sheet(
|
| 380 |
+
character_name=character_name,
|
| 381 |
+
stages=stages,
|
| 382 |
+
initial_image=initial_image,
|
| 383 |
+
initial_image_type=initial_image_type,
|
| 384 |
+
costume_description=costume_description,
|
| 385 |
+
costume_image=costume_image,
|
| 386 |
+
metadata=metadata,
|
| 387 |
+
face_image=face_image,
|
| 388 |
+
body_image=body_image,
|
| 389 |
+
output_dir=output_dir
|
| 390 |
+
)
|
| 391 |
+
success_msg += f"\n\nFiles saved to: {save_dir}"
|
| 392 |
+
metadata['saved_to'] = str(save_dir)
|
| 393 |
+
|
| 394 |
+
return character_sheet, success_msg, metadata
|
| 395 |
+
|
| 396 |
+
except Exception as e:
|
| 397 |
+
logger.exception(f"Character sheet generation failed: {e}")
|
| 398 |
+
return None, f"Character forge error: {str(e)}", {}
|
| 399 |
+
|
| 400 |
+
def _normalize_input(
|
| 401 |
+
self,
|
| 402 |
+
initial_image: Optional[Image.Image],
|
| 403 |
+
initial_image_type: str,
|
| 404 |
+
face_image: Optional[Image.Image],
|
| 405 |
+
body_image: Optional[Image.Image],
|
| 406 |
+
costume_instruction: str,
|
| 407 |
+
costume_image: Optional[Image.Image],
|
| 408 |
+
character_name: str,
|
| 409 |
+
gender_term: str,
|
| 410 |
+
backend: str,
|
| 411 |
+
stages: dict,
|
| 412 |
+
progress_callback: Optional[Callable]
|
| 413 |
+
) -> Tuple[Optional[Image.Image], Optional[Image.Image]]:
|
| 414 |
+
"""
|
| 415 |
+
Normalize input images to create reference full body and face.
|
| 416 |
+
|
| 417 |
+
Returns:
|
| 418 |
+
Tuple of (reference_full_body, reference_face)
|
| 419 |
+
"""
|
| 420 |
+
if initial_image_type == "Face + Body (Separate)":
|
| 421 |
+
# User provided separate face and body
|
| 422 |
+
logger.info("Using Face + Body (Separate) mode")
|
| 423 |
+
|
| 424 |
+
# Validate input
|
| 425 |
+
if face_image is None or body_image is None:
|
| 426 |
+
logger.error(f"Face + Body mode: Missing images! Face: {face_image is not None}, Body: {body_image is not None}")
|
| 427 |
+
return None, None
|
| 428 |
+
|
| 429 |
+
logger.info(f"Face + Body mode: face size = {face_image.size}, body size = {body_image.size}")
|
| 430 |
+
|
| 431 |
+
current_stage = "Stage 0a/6: Normalizing body image"
|
| 432 |
+
if progress_callback:
|
| 433 |
+
progress_callback(0, current_stage)
|
| 434 |
+
|
| 435 |
+
current_prompt = f"Front view full body portrait of this person{costume_instruction}, standing, neutral background"
|
| 436 |
+
input_images = [body_image, face_image]
|
| 437 |
+
if costume_image:
|
| 438 |
+
input_images.append(costume_image)
|
| 439 |
+
|
| 440 |
+
logger.info(f"Calling _generate_stage with {len(input_images)} input images")
|
| 441 |
+
|
| 442 |
+
normalized_body, status = self._generate_stage(
|
| 443 |
+
prompt=current_prompt,
|
| 444 |
+
input_images=input_images,
|
| 445 |
+
aspect_ratio="9:16",
|
| 446 |
+
temperature=0.5,
|
| 447 |
+
backend=backend,
|
| 448 |
+
stage_name=current_stage,
|
| 449 |
+
progress_callback=progress_callback
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
if normalized_body is None:
|
| 453 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 454 |
+
return None, None
|
| 455 |
+
|
| 456 |
+
logger.info(f"{current_stage} complete: {normalized_body.size}")
|
| 457 |
+
stages['normalized_full_body'] = normalized_body
|
| 458 |
+
stages['provided_body'] = body_image
|
| 459 |
+
stages['provided_face'] = face_image
|
| 460 |
+
|
| 461 |
+
return normalized_body, face_image
|
| 462 |
+
|
| 463 |
+
elif initial_image_type == "Face Only":
|
| 464 |
+
# Generate full body from face
|
| 465 |
+
current_stage = "Stage 0a/6: Generating full body from face"
|
| 466 |
+
if progress_callback:
|
| 467 |
+
progress_callback(0, current_stage)
|
| 468 |
+
|
| 469 |
+
# Validate input
|
| 470 |
+
if initial_image is None:
|
| 471 |
+
logger.error("Face Only mode: initial_image is None!")
|
| 472 |
+
return None, None
|
| 473 |
+
|
| 474 |
+
logger.info(f"Face Only mode: initial_image size = {initial_image.size}")
|
| 475 |
+
logger.info(f"Costume image: {costume_image is not None}")
|
| 476 |
+
|
| 477 |
+
current_prompt = f"Create a full body image of the {gender_term}{costume_instruction} standing in a neutral pose in front of a grey background with professional photo studio lighting. The {gender_term}'s face and features should match the reference image exactly."
|
| 478 |
+
|
| 479 |
+
input_images = [initial_image]
|
| 480 |
+
if costume_image:
|
| 481 |
+
input_images.append(costume_image)
|
| 482 |
+
|
| 483 |
+
logger.info(f"Calling _generate_stage with {len(input_images)} input images")
|
| 484 |
+
|
| 485 |
+
full_body, status = self._generate_stage(
|
| 486 |
+
prompt=current_prompt,
|
| 487 |
+
input_images=input_images,
|
| 488 |
+
aspect_ratio="9:16",
|
| 489 |
+
temperature=0.5,
|
| 490 |
+
backend=backend,
|
| 491 |
+
stage_name=current_stage,
|
| 492 |
+
progress_callback=progress_callback
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
if full_body is None:
|
| 496 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 497 |
+
return None, None
|
| 498 |
+
|
| 499 |
+
logger.info(f"{current_stage} complete: {full_body.size}")
|
| 500 |
+
stages['initial_full_body'] = full_body
|
| 501 |
+
|
| 502 |
+
return full_body, initial_image
|
| 503 |
+
|
| 504 |
+
else:
|
| 505 |
+
# Starting with full body - normalize and extract face
|
| 506 |
+
# Stage 0a: Normalize body
|
| 507 |
+
current_stage = "Stage 0a/6: Normalizing full body"
|
| 508 |
+
if progress_callback:
|
| 509 |
+
progress_callback(0, current_stage)
|
| 510 |
+
|
| 511 |
+
current_prompt = f"Front view full body portrait of this person{costume_instruction}, standing, neutral background"
|
| 512 |
+
|
| 513 |
+
input_images = [initial_image]
|
| 514 |
+
if costume_image:
|
| 515 |
+
input_images.append(costume_image)
|
| 516 |
+
|
| 517 |
+
normalized_body, status = self._generate_stage(
|
| 518 |
+
prompt=current_prompt,
|
| 519 |
+
input_images=input_images,
|
| 520 |
+
aspect_ratio="9:16",
|
| 521 |
+
temperature=0.5,
|
| 522 |
+
backend=backend,
|
| 523 |
+
stage_name=current_stage,
|
| 524 |
+
progress_callback=progress_callback
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
if normalized_body is None:
|
| 528 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 529 |
+
return None, None
|
| 530 |
+
|
| 531 |
+
logger.info(f"{current_stage} complete: {normalized_body.size}")
|
| 532 |
+
stages['normalized_full_body'] = normalized_body
|
| 533 |
+
time.sleep(1)
|
| 534 |
+
|
| 535 |
+
# Stage 0b: Extract face from normalized body
|
| 536 |
+
current_stage = "Stage 0b/6: Generating face closeup from body"
|
| 537 |
+
if progress_callback:
|
| 538 |
+
progress_callback(0, current_stage)
|
| 539 |
+
|
| 540 |
+
current_prompt = f"Create a frontal closeup portrait of this {gender_term}'s face{costume_instruction}, focusing only on the face and head. Use professional photo studio lighting against a neutral grey background. The face should fill the entire vertical space. Maintain exact facial features from the reference image."
|
| 541 |
+
|
| 542 |
+
input_images = [normalized_body, initial_image]
|
| 543 |
+
if costume_image:
|
| 544 |
+
input_images.append(costume_image)
|
| 545 |
+
|
| 546 |
+
face_closeup, status = self._generate_stage(
|
| 547 |
+
prompt=current_prompt,
|
| 548 |
+
input_images=input_images,
|
| 549 |
+
aspect_ratio="3:4",
|
| 550 |
+
temperature=0.35,
|
| 551 |
+
backend=backend,
|
| 552 |
+
stage_name=current_stage,
|
| 553 |
+
progress_callback=progress_callback
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
if face_closeup is None:
|
| 557 |
+
logger.error(f"{current_stage} failed: {status}")
|
| 558 |
+
return None, None
|
| 559 |
+
|
| 560 |
+
logger.info(f"{current_stage} complete: {face_closeup.size}")
|
| 561 |
+
stages['initial_face'] = face_closeup
|
| 562 |
+
|
| 563 |
+
return normalized_body, face_closeup
|
| 564 |
+
|
| 565 |
+
def _generate_stage(
|
| 566 |
+
self,
|
| 567 |
+
prompt: str,
|
| 568 |
+
input_images: List[Image.Image],
|
| 569 |
+
aspect_ratio: str,
|
| 570 |
+
temperature: float,
|
| 571 |
+
backend: str,
|
| 572 |
+
stage_name: str,
|
| 573 |
+
negative_prompt: Optional[str] = None,
|
| 574 |
+
max_retries: int = 3,
|
| 575 |
+
progress_callback: Optional[Callable[[int, str], None]] = None
|
| 576 |
+
) -> Tuple[Optional[Image.Image], str]:
|
| 577 |
+
"""
|
| 578 |
+
Generate single stage with retry logic.
|
| 579 |
+
|
| 580 |
+
Args:
|
| 581 |
+
prompt: Generation prompt
|
| 582 |
+
input_images: Input reference images
|
| 583 |
+
aspect_ratio: Aspect ratio
|
| 584 |
+
temperature: Temperature
|
| 585 |
+
backend: Backend to use
|
| 586 |
+
stage_name: Stage name for logging
|
| 587 |
+
negative_prompt: Negative prompt (optional, auto-applied for ComfyUI)
|
| 588 |
+
max_retries: Maximum retry attempts
|
| 589 |
+
|
| 590 |
+
Returns:
|
| 591 |
+
Tuple of (image, status_message)
|
| 592 |
+
"""
|
| 593 |
+
logger.info(f"[{stage_name}] Starting generation...")
|
| 594 |
+
logger.info(f" Prompt: {prompt[:100]}...")
|
| 595 |
+
logger.info(f" Input images: {len(input_images)}")
|
| 596 |
+
logger.info(f" Aspect ratio: {aspect_ratio}, Temperature: {temperature}")
|
| 597 |
+
|
| 598 |
+
# Auto-apply default negative prompts for ComfyUI if not provided
|
| 599 |
+
if negative_prompt is None and backend == Settings.BACKEND_COMFYUI:
|
| 600 |
+
# Extract stage key from stage_name (e.g., "Stage 1/6: ..." -> "stage_1")
|
| 601 |
+
stage_key = stage_name.lower().split(":")[0].strip().replace(" ", "_").replace("/", "_")
|
| 602 |
+
negative_prompt = Settings.DEFAULT_NEGATIVE_PROMPTS.get(stage_key, "blurry, low quality, distorted, deformed")
|
| 603 |
+
logger.info(f" Auto-applied negative prompt: {negative_prompt[:80]}...")
|
| 604 |
+
|
| 605 |
+
# Track if we need to modify prompt for safety
|
| 606 |
+
modified_prompt = prompt
|
| 607 |
+
safety_block_detected = False
|
| 608 |
+
|
| 609 |
+
for attempt in range(max_retries):
|
| 610 |
+
try:
|
| 611 |
+
if attempt > 0:
|
| 612 |
+
# Use 30-second delays between retries to avoid API spam
|
| 613 |
+
wait_time = 30
|
| 614 |
+
logger.info(f"Retry attempt {attempt + 1}/{max_retries}, waiting {wait_time}s...")
|
| 615 |
+
|
| 616 |
+
# Show countdown to user
|
| 617 |
+
if progress_callback:
|
| 618 |
+
for remaining in range(wait_time, 0, -1):
|
| 619 |
+
progress_callback(
|
| 620 |
+
0,
|
| 621 |
+
f"⏳ Retry {attempt + 1}/{max_retries} in {remaining}s... (API rate limit cooldown)"
|
| 622 |
+
)
|
| 623 |
+
time.sleep(1)
|
| 624 |
+
else:
|
| 625 |
+
time.sleep(wait_time)
|
| 626 |
+
|
| 627 |
+
# Build request (use modified prompt if safety block was detected)
|
| 628 |
+
request = GenerationRequest(
|
| 629 |
+
prompt=modified_prompt,
|
| 630 |
+
backend=backend,
|
| 631 |
+
aspect_ratio=aspect_ratio,
|
| 632 |
+
temperature=temperature,
|
| 633 |
+
input_images=input_images,
|
| 634 |
+
negative_prompt=negative_prompt
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
# Generate
|
| 638 |
+
result = self.router.generate(request)
|
| 639 |
+
|
| 640 |
+
if result.success:
|
| 641 |
+
# Rate limiting delay
|
| 642 |
+
delay = random.uniform(2.0, 3.0)
|
| 643 |
+
logger.info(f"Generation successful, waiting {delay:.1f}s...")
|
| 644 |
+
time.sleep(delay)
|
| 645 |
+
# Normalize to PIL Image in case backend returned a path-like
|
| 646 |
+
try:
|
| 647 |
+
normalized_image = ensure_pil_image(result.image, context=f"{stage_name}/result")
|
| 648 |
+
except Exception as e:
|
| 649 |
+
return None, f"Invalid image type from backend: {e}"
|
| 650 |
+
return normalized_image, result.message
|
| 651 |
+
|
| 652 |
+
# Detect safety/censorship blocks and modify prompt
|
| 653 |
+
error_msg_upper = result.message.upper()
|
| 654 |
+
if any(keyword in error_msg_upper for keyword in [
|
| 655 |
+
'SAFETY', 'BLOCKED', 'PROHIBITED', 'CENSORED',
|
| 656 |
+
'POLICY', 'NSFW', 'INAPPROPRIATE', 'IMAGE_OTHER'
|
| 657 |
+
]):
|
| 658 |
+
safety_block_detected = True
|
| 659 |
+
logger.warning(f"⚠️ Safety/censorship filter detected: {result.message}")
|
| 660 |
+
|
| 661 |
+
# Modify prompt to explicitly add clothing (avoid NSFW assumptions)
|
| 662 |
+
if not any(clothing in modified_prompt.lower() for clothing in [
|
| 663 |
+
'wearing', 'clothed', 'dressed', 'outfit', 'clothing',
|
| 664 |
+
'shirt', 'pants', 'dress', 'bikini', 'shorts', 'attire'
|
| 665 |
+
]):
|
| 666 |
+
# Add clothing description based on context
|
| 667 |
+
if 'portrait' in modified_prompt.lower() or 'face' in modified_prompt.lower():
|
| 668 |
+
clothing_addon = ", wearing appropriate clothing (casual shirt or top)"
|
| 669 |
+
elif 'body' in modified_prompt.lower() or 'full body' in modified_prompt.lower():
|
| 670 |
+
clothing_addon = ", fully clothed in casual wear (shirt and pants or shorts)"
|
| 671 |
+
else:
|
| 672 |
+
clothing_addon = ", wearing appropriate casual attire"
|
| 673 |
+
|
| 674 |
+
modified_prompt = prompt + clothing_addon
|
| 675 |
+
logger.info(f"🔄 Modified prompt to avoid safety filters: '{clothing_addon}'")
|
| 676 |
+
|
| 677 |
+
if progress_callback:
|
| 678 |
+
progress_callback(
|
| 679 |
+
0,
|
| 680 |
+
f"⚠️ Safety filter triggered - adding clothing description to prompt..."
|
| 681 |
+
)
|
| 682 |
+
time.sleep(2) # Brief pause to show message
|
| 683 |
+
|
| 684 |
+
# Continue to retry with modified prompt
|
| 685 |
+
logger.warning(f"Attempt {attempt + 1}/{max_retries} failed, will retry with modified prompt")
|
| 686 |
+
else:
|
| 687 |
+
logger.warning(f"Attempt {attempt + 1}/{max_retries} failed: {result.message}")
|
| 688 |
+
|
| 689 |
+
except Exception as e:
|
| 690 |
+
logger.error(f"Attempt {attempt + 1}/{max_retries} exception: {e}")
|
| 691 |
+
if attempt == max_retries - 1:
|
| 692 |
+
return None, f"All {max_retries} attempts failed: {str(e)}"
|
| 693 |
+
|
| 694 |
+
return None, f"All {max_retries} attempts exhausted"
|
| 695 |
+
|
| 696 |
+
def composite_character_sheet(
|
| 697 |
+
self,
|
| 698 |
+
front_portrait: Image.Image,
|
| 699 |
+
side_portrait: Image.Image,
|
| 700 |
+
front_body: Image.Image,
|
| 701 |
+
side_body: Image.Image,
|
| 702 |
+
rear_body: Image.Image,
|
| 703 |
+
character_name: str = "Character",
|
| 704 |
+
save_debug: bool = False,
|
| 705 |
+
debug_dir: Optional[Path] = None
|
| 706 |
+
) -> Image.Image:
|
| 707 |
+
"""
|
| 708 |
+
Composite all views into character sheet.
|
| 709 |
+
|
| 710 |
+
Layout:
|
| 711 |
+
+-------------------+-------------------+
|
| 712 |
+
| Front Portrait | Side Portrait | (3:4 = 864x1184)
|
| 713 |
+
+-------------------+-------------------+
|
| 714 |
+
| Front Body | Side Body | Rear Body | (9:16 = 768x1344)
|
| 715 |
+
+-------------------+-------------------+
|
| 716 |
+
|
| 717 |
+
Args:
|
| 718 |
+
front_portrait: Front face view
|
| 719 |
+
side_portrait: Side profile face
|
| 720 |
+
front_body: Front full body
|
| 721 |
+
side_body: Side full body
|
| 722 |
+
rear_body: Rear full body
|
| 723 |
+
character_name: Character name
|
| 724 |
+
save_debug: If True, save source images to assembled/
|
| 725 |
+
debug_dir: Directory to save debug files
|
| 726 |
+
|
| 727 |
+
Returns:
|
| 728 |
+
Composited character sheet
|
| 729 |
+
"""
|
| 730 |
+
from datetime import datetime
|
| 731 |
+
|
| 732 |
+
# Validate/normalize inputs to PIL Images and log their types/sizes if possible
|
| 733 |
+
inputs = {
|
| 734 |
+
'front_portrait': front_portrait,
|
| 735 |
+
'side_portrait': side_portrait,
|
| 736 |
+
'front_body': front_body,
|
| 737 |
+
'side_body': side_body,
|
| 738 |
+
'rear_body': rear_body,
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
normalized_inputs = {}
|
| 742 |
+
for name, img in inputs.items():
|
| 743 |
+
normalized = ensure_pil_image(img, context=f"composite:{name}")
|
| 744 |
+
normalized_inputs[name] = normalized
|
| 745 |
+
try:
|
| 746 |
+
logger.info(f"[Composite] {name}: type={type(normalized).__name__}, size={normalized.size}")
|
| 747 |
+
except Exception:
|
| 748 |
+
logger.info(f"[Composite] {name}: type={type(normalized).__name__}")
|
| 749 |
+
|
| 750 |
+
front_portrait = normalized_inputs['front_portrait']
|
| 751 |
+
side_portrait = normalized_inputs['side_portrait']
|
| 752 |
+
front_body = normalized_inputs['front_body']
|
| 753 |
+
side_body = normalized_inputs['side_body']
|
| 754 |
+
rear_body = normalized_inputs['rear_body']
|
| 755 |
+
|
| 756 |
+
# Save source images before composition (if debugging enabled)
|
| 757 |
+
if save_debug and debug_dir:
|
| 758 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 759 |
+
safe_name = sanitize_filename(character_name)
|
| 760 |
+
|
| 761 |
+
assembled_dir = debug_dir / "assembled"
|
| 762 |
+
assembled_dir.mkdir(parents=True, exist_ok=True)
|
| 763 |
+
|
| 764 |
+
logger.info(f"[DEBUG] Saving source images to: {assembled_dir}")
|
| 765 |
+
|
| 766 |
+
source_images = {
|
| 767 |
+
'front_portrait': front_portrait,
|
| 768 |
+
'side_portrait': side_portrait,
|
| 769 |
+
'front_body': front_body,
|
| 770 |
+
'side_body': side_body,
|
| 771 |
+
'rear_body': rear_body
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
for view_name, image in source_images.items():
|
| 775 |
+
save_path = assembled_dir / f"{safe_name}_{timestamp}_{view_name}.png"
|
| 776 |
+
image.save(save_path, format="PNG", compress_level=0)
|
| 777 |
+
logger.info(f"[DEBUG] Saved source: {save_path}")
|
| 778 |
+
|
| 779 |
+
spacing = 20
|
| 780 |
+
|
| 781 |
+
# Calculate canvas dimensions
|
| 782 |
+
canvas_width = front_body.width + side_body.width + rear_body.width
|
| 783 |
+
portrait_row_width = front_portrait.width + side_portrait.width
|
| 784 |
+
canvas_width = max(canvas_width, portrait_row_width)
|
| 785 |
+
canvas_height = front_portrait.height + spacing + front_body.height
|
| 786 |
+
|
| 787 |
+
# Create canvas
|
| 788 |
+
canvas = Image.new('RGB', (canvas_width, canvas_height), color='#2C2C2C')
|
| 789 |
+
|
| 790 |
+
# Upper row: Portraits
|
| 791 |
+
x_offset = 0
|
| 792 |
+
canvas.paste(front_portrait, (x_offset, 0))
|
| 793 |
+
x_offset += front_portrait.width
|
| 794 |
+
canvas.paste(side_portrait, (x_offset, 0))
|
| 795 |
+
|
| 796 |
+
# Lower row: Bodies
|
| 797 |
+
x_offset = 0
|
| 798 |
+
y_offset = front_portrait.height + spacing
|
| 799 |
+
canvas.paste(front_body, (x_offset, y_offset))
|
| 800 |
+
x_offset += front_body.width
|
| 801 |
+
canvas.paste(side_body, (x_offset, y_offset))
|
| 802 |
+
x_offset += side_body.width
|
| 803 |
+
canvas.paste(rear_body, (x_offset, y_offset))
|
| 804 |
+
|
| 805 |
+
return canvas
|
| 806 |
+
|
| 807 |
+
def extract_views_from_sheet(
|
| 808 |
+
self,
|
| 809 |
+
character_sheet: Image.Image,
|
| 810 |
+
save_debug: bool = False,
|
| 811 |
+
debug_dir: Optional[Path] = None,
|
| 812 |
+
character_name: str = "character"
|
| 813 |
+
) -> Dict[str, Image.Image]:
|
| 814 |
+
"""
|
| 815 |
+
Extract individual views from character sheet.
|
| 816 |
+
|
| 817 |
+
CRITICAL: This MUST be the EXACT mathematical inverse of composite_character_sheet().
|
| 818 |
+
Any deviation will cause corrupted images to be fed back into the AI pipeline.
|
| 819 |
+
|
| 820 |
+
Args:
|
| 821 |
+
character_sheet: Composited character sheet
|
| 822 |
+
save_debug: If True, save intermediate images and validation results
|
| 823 |
+
debug_dir: Directory to save debug files (uses output dir if None)
|
| 824 |
+
character_name: Name for debug files
|
| 825 |
+
|
| 826 |
+
Returns:
|
| 827 |
+
Dictionary with extracted views
|
| 828 |
+
"""
|
| 829 |
+
import numpy as np
|
| 830 |
+
from datetime import datetime
|
| 831 |
+
|
| 832 |
+
sheet_width, sheet_height = character_sheet.size
|
| 833 |
+
|
| 834 |
+
# Get actual dimensions from the sheet
|
| 835 |
+
# We need to reverse-engineer the composition layout
|
| 836 |
+
|
| 837 |
+
# The composition uses:
|
| 838 |
+
# spacing = 20
|
| 839 |
+
# canvas_width = max(3 * body_width, 2 * portrait_width)
|
| 840 |
+
# canvas_height = portrait_height + spacing + body_height
|
| 841 |
+
|
| 842 |
+
# From this, we can deduce:
|
| 843 |
+
# portrait_height + spacing + body_height = sheet_height
|
| 844 |
+
# Since portraits are 3:4 (1008x1344) and bodies are 9:16 (768x1344)
|
| 845 |
+
# portrait_height = 1344, body_height = 1344, spacing = 20
|
| 846 |
+
# sheet_height should be 1344 + 20 + 1344 = 2708
|
| 847 |
+
|
| 848 |
+
spacing = 20
|
| 849 |
+
|
| 850 |
+
# Find the ACTUAL separator position by scanning for the dark horizontal bar
|
| 851 |
+
# The separator is a dark gray (#2C2C2C) 20px bar between portraits and bodies
|
| 852 |
+
# We scan in the middle third of the sheet where we expect to find it
|
| 853 |
+
|
| 854 |
+
scan_start = sheet_height // 3
|
| 855 |
+
scan_end = (2 * sheet_height) // 3
|
| 856 |
+
|
| 857 |
+
logger.debug(f"Scanning for separator between y={scan_start} and y={scan_end}")
|
| 858 |
+
|
| 859 |
+
# Find the darkest horizontal strip (this is the separator)
|
| 860 |
+
min_brightness = 255
|
| 861 |
+
separator_y = scan_start
|
| 862 |
+
|
| 863 |
+
for y in range(scan_start, scan_end):
|
| 864 |
+
# Sample a horizontal line across the width
|
| 865 |
+
line = character_sheet.crop((0, y, min(200, sheet_width), y + 1))
|
| 866 |
+
pixels = list(line.getdata())
|
| 867 |
+
|
| 868 |
+
# Calculate average brightness
|
| 869 |
+
avg_brightness = sum(
|
| 870 |
+
sum(p[:3]) / 3 if isinstance(p, tuple) else p
|
| 871 |
+
for p in pixels
|
| 872 |
+
) / len(pixels)
|
| 873 |
+
|
| 874 |
+
if avg_brightness < min_brightness:
|
| 875 |
+
min_brightness = avg_brightness
|
| 876 |
+
separator_y = y
|
| 877 |
+
|
| 878 |
+
logger.info(f"Found separator at y={separator_y}, brightness={min_brightness:.1f}")
|
| 879 |
+
|
| 880 |
+
# The separator is 20px tall, portrait ends just before it
|
| 881 |
+
portrait_height = separator_y
|
| 882 |
+
body_start_y = separator_y + spacing
|
| 883 |
+
body_height = sheet_height - body_start_y
|
| 884 |
+
|
| 885 |
+
# Calculate widths using aspect ratios
|
| 886 |
+
# Portraits: 3:4 ratio
|
| 887 |
+
portrait_width = (portrait_height * 3) // 4
|
| 888 |
+
|
| 889 |
+
# Bodies: 9:16 ratio
|
| 890 |
+
body_width = (body_height * 9) // 16
|
| 891 |
+
|
| 892 |
+
logger.info(f"Sheet dimensions: {sheet_width}x{sheet_height}")
|
| 893 |
+
logger.info(f"Extracted dimensions: portrait={portrait_width}x{portrait_height}, body={body_width}x{body_height}, spacing={spacing}")
|
| 894 |
+
|
| 895 |
+
# EXACT INVERSE of composite_character_sheet():
|
| 896 |
+
# Upper row: Portraits
|
| 897 |
+
# canvas.paste(front_portrait, (0, 0))
|
| 898 |
+
front_portrait = character_sheet.crop((
|
| 899 |
+
0, 0,
|
| 900 |
+
portrait_width, portrait_height
|
| 901 |
+
))
|
| 902 |
+
|
| 903 |
+
# canvas.paste(side_portrait, (front_portrait.width, 0))
|
| 904 |
+
side_portrait = character_sheet.crop((
|
| 905 |
+
portrait_width, 0,
|
| 906 |
+
2 * portrait_width, portrait_height
|
| 907 |
+
))
|
| 908 |
+
|
| 909 |
+
# Lower row: Bodies
|
| 910 |
+
y_offset = body_start_y
|
| 911 |
+
|
| 912 |
+
# canvas.paste(front_body, (0, y_offset))
|
| 913 |
+
front_body = character_sheet.crop((
|
| 914 |
+
0, y_offset,
|
| 915 |
+
body_width, y_offset + body_height
|
| 916 |
+
))
|
| 917 |
+
|
| 918 |
+
# canvas.paste(side_body, (front_body.width, y_offset))
|
| 919 |
+
side_body = character_sheet.crop((
|
| 920 |
+
body_width, y_offset,
|
| 921 |
+
2 * body_width, y_offset + body_height
|
| 922 |
+
))
|
| 923 |
+
|
| 924 |
+
# canvas.paste(rear_body, (front_body.width + side_body.width, y_offset))
|
| 925 |
+
rear_body = character_sheet.crop((
|
| 926 |
+
2 * body_width, y_offset,
|
| 927 |
+
3 * body_width, y_offset + body_height
|
| 928 |
+
))
|
| 929 |
+
|
| 930 |
+
views = {
|
| 931 |
+
'front_portrait': front_portrait,
|
| 932 |
+
'side_portrait': side_portrait,
|
| 933 |
+
'front_body': front_body,
|
| 934 |
+
'side_body': side_body,
|
| 935 |
+
'rear_body': rear_body
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
# Debug: Save intermediate images and validate
|
| 939 |
+
if save_debug and debug_dir:
|
| 940 |
+
self._save_and_validate_extraction(
|
| 941 |
+
character_sheet=character_sheet,
|
| 942 |
+
extracted_views=views,
|
| 943 |
+
debug_dir=debug_dir,
|
| 944 |
+
character_name=character_name
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
return views
|
| 948 |
+
|
| 949 |
+
def _save_and_validate_extraction(
|
| 950 |
+
self,
|
| 951 |
+
character_sheet: Image.Image,
|
| 952 |
+
extracted_views: Dict[str, Image.Image],
|
| 953 |
+
debug_dir: Path,
|
| 954 |
+
character_name: str
|
| 955 |
+
):
|
| 956 |
+
"""
|
| 957 |
+
Save extracted views and validate that extraction is the perfect inverse of composition.
|
| 958 |
+
|
| 959 |
+
Creates two subdirectories:
|
| 960 |
+
- disassembled/: Extracted views from character sheet
|
| 961 |
+
- validation/: Recomposited sheet + pixel-perfect comparison results
|
| 962 |
+
|
| 963 |
+
Args:
|
| 964 |
+
character_sheet: Original character sheet
|
| 965 |
+
extracted_views: Dictionary of extracted views
|
| 966 |
+
debug_dir: Base directory for debug files
|
| 967 |
+
character_name: Character name for file naming
|
| 968 |
+
"""
|
| 969 |
+
import numpy as np
|
| 970 |
+
from datetime import datetime
|
| 971 |
+
|
| 972 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 973 |
+
safe_name = sanitize_filename(character_name)
|
| 974 |
+
|
| 975 |
+
# Create subdirectories
|
| 976 |
+
disassembled_dir = debug_dir / "disassembled"
|
| 977 |
+
validation_dir = debug_dir / "validation"
|
| 978 |
+
disassembled_dir.mkdir(parents=True, exist_ok=True)
|
| 979 |
+
validation_dir.mkdir(parents=True, exist_ok=True)
|
| 980 |
+
|
| 981 |
+
logger.info(f"[DEBUG] Saving extracted views to: {disassembled_dir}")
|
| 982 |
+
|
| 983 |
+
# Save extracted views to disassembled/
|
| 984 |
+
for view_name, image in extracted_views.items():
|
| 985 |
+
save_path = disassembled_dir / f"{safe_name}_{timestamp}_{view_name}.png"
|
| 986 |
+
image.save(save_path, format="PNG", compress_level=0)
|
| 987 |
+
logger.info(f"[DEBUG] Saved: {save_path}")
|
| 988 |
+
|
| 989 |
+
# Recomposite the extracted views to validate extraction
|
| 990 |
+
logger.info(f"[DEBUG] Recompositing extracted views for validation...")
|
| 991 |
+
|
| 992 |
+
recomposited = self.composite_character_sheet(
|
| 993 |
+
front_portrait=extracted_views['front_portrait'],
|
| 994 |
+
side_portrait=extracted_views['side_portrait'],
|
| 995 |
+
front_body=extracted_views['front_body'],
|
| 996 |
+
side_body=extracted_views['side_body'],
|
| 997 |
+
rear_body=extracted_views['rear_body'],
|
| 998 |
+
character_name=character_name
|
| 999 |
+
)
|
| 1000 |
+
|
| 1001 |
+
# Save recomposited sheet
|
| 1002 |
+
recomposited_path = validation_dir / f"{safe_name}_{timestamp}_recomposited.png"
|
| 1003 |
+
recomposited.save(recomposited_path, format="PNG", compress_level=0)
|
| 1004 |
+
logger.info(f"[DEBUG] Saved recomposited: {recomposited_path}")
|
| 1005 |
+
|
| 1006 |
+
# Pixel-perfect comparison
|
| 1007 |
+
logger.info(f"[DEBUG] Performing pixel-perfect comparison...")
|
| 1008 |
+
|
| 1009 |
+
original_array = np.array(character_sheet)
|
| 1010 |
+
recomposited_array = np.array(recomposited)
|
| 1011 |
+
|
| 1012 |
+
# Check dimensions match
|
| 1013 |
+
if original_array.shape != recomposited_array.shape:
|
| 1014 |
+
logger.error(f"[VALIDATION FAIL] Dimension mismatch! Original: {original_array.shape}, Recomposited: {recomposited_array.shape}")
|
| 1015 |
+
return
|
| 1016 |
+
|
| 1017 |
+
# Pixel-by-pixel comparison
|
| 1018 |
+
differences = np.abs(original_array.astype(int) - recomposited_array.astype(int))
|
| 1019 |
+
max_diff = np.max(differences)
|
| 1020 |
+
mean_diff = np.mean(differences)
|
| 1021 |
+
num_different_pixels = np.count_nonzero(differences)
|
| 1022 |
+
|
| 1023 |
+
# Create difference heatmap (amplified for visibility)
|
| 1024 |
+
diff_heatmap = np.max(differences, axis=2) * 10 # Amplify differences
|
| 1025 |
+
diff_image = Image.fromarray(diff_heatmap.astype(np.uint8))
|
| 1026 |
+
diff_path = validation_dir / f"{safe_name}_{timestamp}_diff_heatmap.png"
|
| 1027 |
+
diff_image.save(diff_path, format="PNG", compress_level=0)
|
| 1028 |
+
|
| 1029 |
+
# Validation report
|
| 1030 |
+
report = [
|
| 1031 |
+
f"=== EXTRACTION VALIDATION REPORT ===",
|
| 1032 |
+
f"Character: {character_name}",
|
| 1033 |
+
f"Timestamp: {timestamp}",
|
| 1034 |
+
f"",
|
| 1035 |
+
f"Original dimensions: {original_array.shape}",
|
| 1036 |
+
f"Recomposited dimensions: {recomposited_array.shape}",
|
| 1037 |
+
f"",
|
| 1038 |
+
f"Pixel-perfect comparison:",
|
| 1039 |
+
f" Max difference: {max_diff} / 255",
|
| 1040 |
+
f" Mean difference: {mean_diff:.4f} / 255",
|
| 1041 |
+
f" Different pixels: {num_different_pixels} / {original_array.size}",
|
| 1042 |
+
f"",
|
| 1043 |
+
]
|
| 1044 |
+
|
| 1045 |
+
if max_diff == 0:
|
| 1046 |
+
report.append("✅ PERFECT MATCH - Extraction is pixel-perfect inverse of composition!")
|
| 1047 |
+
logger.info("[VALIDATION SUCCESS] ✅ Pixel-perfect match!")
|
| 1048 |
+
elif max_diff <= 1:
|
| 1049 |
+
report.append("✅ EXCELLENT - Differences within rounding error (≤1)")
|
| 1050 |
+
logger.info(f"[VALIDATION SUCCESS] ✅ Near-perfect (max diff: {max_diff})")
|
| 1051 |
+
elif max_diff <= 5:
|
| 1052 |
+
report.append(f"⚠️ MINOR DIFFERENCES - Max diff: {max_diff} (acceptable for JPEG artifacts)")
|
| 1053 |
+
logger.warning(f"[VALIDATION WARN] ⚠️ Minor differences (max diff: {max_diff})")
|
| 1054 |
+
else:
|
| 1055 |
+
report.append(f"❌ SIGNIFICANT DIFFERENCES - Max diff: {max_diff} (EXTRACTION BUG!)")
|
| 1056 |
+
logger.error(f"[VALIDATION FAIL] ❌ Significant differences (max diff: {max_diff})")
|
| 1057 |
+
|
| 1058 |
+
report.append("")
|
| 1059 |
+
report.append(f"Files saved:")
|
| 1060 |
+
report.append(f" Original: {character_sheet.size}")
|
| 1061 |
+
report.append(f" Recomposited: {recomposited_path}")
|
| 1062 |
+
report.append(f" Diff heatmap: {diff_path}")
|
| 1063 |
+
|
| 1064 |
+
# Save report
|
| 1065 |
+
report_path = validation_dir / f"{safe_name}_{timestamp}_validation_report.txt"
|
| 1066 |
+
with open(report_path, 'w') as f:
|
| 1067 |
+
f.write('\n'.join(report))
|
| 1068 |
+
|
| 1069 |
+
logger.info(f"[DEBUG] Validation report: {report_path}")
|
| 1070 |
+
|
| 1071 |
+
# Log summary
|
| 1072 |
+
for line in report:
|
| 1073 |
+
if line.startswith('✅') or line.startswith('❌') or line.startswith('⚠️'):
|
| 1074 |
+
logger.info(f"[VALIDATION] {line}")
|
| 1075 |
+
|
| 1076 |
+
def _save_character_sheet(
|
| 1077 |
+
self,
|
| 1078 |
+
character_name: str,
|
| 1079 |
+
stages: dict,
|
| 1080 |
+
initial_image: Image.Image,
|
| 1081 |
+
initial_image_type: str,
|
| 1082 |
+
costume_description: str,
|
| 1083 |
+
costume_image: Optional[Image.Image],
|
| 1084 |
+
metadata: dict,
|
| 1085 |
+
face_image: Optional[Image.Image],
|
| 1086 |
+
body_image: Optional[Image.Image],
|
| 1087 |
+
output_dir: Path
|
| 1088 |
+
) -> Path:
|
| 1089 |
+
"""
|
| 1090 |
+
Save character sheet and all stages to disk.
|
| 1091 |
+
|
| 1092 |
+
Args:
|
| 1093 |
+
character_name: Character name
|
| 1094 |
+
stages: Dictionary of generated images
|
| 1095 |
+
initial_image: Initial input image
|
| 1096 |
+
initial_image_type: Input type
|
| 1097 |
+
costume_description: Costume description
|
| 1098 |
+
costume_image: Costume reference
|
| 1099 |
+
metadata: Generation metadata
|
| 1100 |
+
face_image: Face image (if separate)
|
| 1101 |
+
body_image: Body image (if separate)
|
| 1102 |
+
output_dir: Output directory
|
| 1103 |
+
|
| 1104 |
+
Returns:
|
| 1105 |
+
Path to saved directory
|
| 1106 |
+
"""
|
| 1107 |
+
# Create character-specific directory
|
| 1108 |
+
safe_name = sanitize_filename(character_name)
|
| 1109 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 1110 |
+
char_dir = output_dir / f"{safe_name}_{timestamp}"
|
| 1111 |
+
ensure_directory_exists(char_dir)
|
| 1112 |
+
|
| 1113 |
+
logger.info(f"Saving character sheet to: {char_dir}")
|
| 1114 |
+
|
| 1115 |
+
# Save character sheet
|
| 1116 |
+
sheet_path, _ = save_image(
|
| 1117 |
+
image=stages['character_sheet'],
|
| 1118 |
+
directory=char_dir,
|
| 1119 |
+
base_name=f"{safe_name}_character_sheet",
|
| 1120 |
+
metadata=metadata
|
| 1121 |
+
)
|
| 1122 |
+
|
| 1123 |
+
# Save individual stages
|
| 1124 |
+
for stage_name, image in stages.items():
|
| 1125 |
+
if stage_name != 'character_sheet':
|
| 1126 |
+
save_image(
|
| 1127 |
+
image=image,
|
| 1128 |
+
directory=char_dir / "stages",
|
| 1129 |
+
base_name=f"{safe_name}_{stage_name}",
|
| 1130 |
+
metadata=None
|
| 1131 |
+
)
|
| 1132 |
+
|
| 1133 |
+
# Save input images
|
| 1134 |
+
if initial_image:
|
| 1135 |
+
save_image(
|
| 1136 |
+
image=initial_image,
|
| 1137 |
+
directory=char_dir / "inputs",
|
| 1138 |
+
base_name=f"{safe_name}_initial_{initial_image_type.replace(' ', '_')}",
|
| 1139 |
+
metadata=None
|
| 1140 |
+
)
|
| 1141 |
+
|
| 1142 |
+
if costume_image:
|
| 1143 |
+
save_image(
|
| 1144 |
+
image=costume_image,
|
| 1145 |
+
directory=char_dir / "inputs",
|
| 1146 |
+
base_name=f"{safe_name}_costume_reference",
|
| 1147 |
+
metadata=None
|
| 1148 |
+
)
|
| 1149 |
+
|
| 1150 |
+
if face_image:
|
| 1151 |
+
save_image(
|
| 1152 |
+
image=face_image,
|
| 1153 |
+
directory=char_dir / "inputs",
|
| 1154 |
+
base_name=f"{safe_name}_face",
|
| 1155 |
+
metadata=None
|
| 1156 |
+
)
|
| 1157 |
+
|
| 1158 |
+
if body_image:
|
| 1159 |
+
save_image(
|
| 1160 |
+
image=body_image,
|
| 1161 |
+
directory=char_dir / "inputs",
|
| 1162 |
+
base_name=f"{safe_name}_body",
|
| 1163 |
+
metadata=None
|
| 1164 |
+
)
|
| 1165 |
+
|
| 1166 |
+
logger.info(f"All files saved to: {char_dir}")
|
| 1167 |
+
return char_dir
|