+
💡 Pro Tip: Use Character Forge to create high-quality character sheets,
+ then use them here to generate unlimited scenes with perfect character consistency!
+
+
📚 Research: This feature implements "Persistent Characters in Image Generation"
+ from our research paper.
+
+ """,
+ unsafe_allow_html=True
+)
diff --git a/character_forge_image/plugins/__init__.py b/character_forge_image/plugins/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..092024a0f72bb88dc6d8231f5bcccaa12ef6f32b
--- /dev/null
+++ b/character_forge_image/plugins/__init__.py
@@ -0,0 +1,11 @@
+"""
+Backend Plugins
+
+Plugin adapters for all image generation backends.
+"""
+
+from .gemini_plugin import GeminiPlugin
+from .omnigen2_plugin import OmniGen2Plugin
+from .comfyui_plugin import ComfyUIPlugin
+
+__all__ = ['GeminiPlugin', 'OmniGen2Plugin', 'ComfyUIPlugin']
diff --git a/character_forge_image/plugins/comfyui_plugin.py b/character_forge_image/plugins/comfyui_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ae5bbe8b8b938e3fc60848ce41f7e61f7471dcb
--- /dev/null
+++ b/character_forge_image/plugins/comfyui_plugin.py
@@ -0,0 +1,192 @@
+"""
+ComfyUI Backend Plugin
+
+Plugin adapter for ComfyUI local backend with qwen_image_edit_2509.
+"""
+
+import sys
+import json
+import random
+from pathlib import Path
+from typing import Any, Dict, Optional, List
+from PIL import Image
+
+# Add parent directories to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from core.comfyui_client import ComfyUIClient
+from config.settings import Settings
+
+# Import from shared plugin system
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'shared'))
+from plugin_system.base_plugin import BaseBackendPlugin
+
+
+class ComfyUIPlugin(BaseBackendPlugin):
+ """Plugin adapter for ComfyUI backend using qwen_image_edit_2509."""
+
+ def __init__(self, config_path: Path):
+ """Initialize ComfyUI plugin."""
+ super().__init__(config_path)
+
+ # Get settings
+ settings = Settings()
+ server_address = settings.COMFYUI_BASE_URL.replace("http://", "")
+
+ try:
+ self.client = ComfyUIClient(server_address=server_address)
+ # Test connection
+ healthy, _ = self.client.health_check()
+ self.available = healthy
+ except Exception as e:
+ print(f"Warning: ComfyUI backend not available: {e}")
+ self.client = None
+ self.available = False
+
+ # Load qwen workflow template
+ self.workflow_template = None
+ workflow_path = Path(__file__).parent.parent.parent / 'tools' / 'comfyui' / 'workflows' / 'qwen_image_edit.json'
+ if workflow_path.exists():
+ with open(workflow_path) as f:
+ self.workflow_template = json.load(f)
+ else:
+ print(f"Warning: Workflow template not found at {workflow_path}")
+
+ def health_check(self) -> bool:
+ """Check if ComfyUI backend is available."""
+ if not self.available or self.client is None:
+ return False
+
+ try:
+ healthy, _ = self.client.health_check()
+ return healthy
+ except:
+ return False
+
+ def _update_qwen_workflow(
+ self,
+ workflow: dict,
+ prompt: str = None,
+ negative_prompt: str = None,
+ input_image_filename: str = None,
+ seed: int = None,
+ width: int = None,
+ height: int = None
+ ) -> dict:
+ """
+ Update workflow parameters for qwen_image_edit workflow.
+
+ Node IDs for qwen_image_edit.json:
+ - 111: Positive prompt (TextEncodeQwenImageEditPlus)
+ - 110: Negative prompt (TextEncodeQwenImageEditPlus)
+ - 78: Load Image
+ - 3: KSampler (seed)
+ - 112: EmptySD3LatentImage (width, height)
+ """
+ # Clone workflow to avoid modifying original
+ wf = json.loads(json.dumps(workflow))
+
+ # Update prompt
+ if prompt is not None:
+ wf["111"]["inputs"]["prompt"] = prompt
+
+ # Update negative prompt
+ if negative_prompt is not None:
+ wf["110"]["inputs"]["prompt"] = negative_prompt
+
+ # Update input image
+ if input_image_filename is not None:
+ wf["78"]["inputs"]["image"] = input_image_filename
+
+ # Update seed
+ if seed is not None:
+ wf["3"]["inputs"]["seed"] = seed
+ else:
+ # Random seed if not specified
+ wf["3"]["inputs"]["seed"] = random.randint(1, 2**32 - 1)
+
+ # Update dimensions
+ if width is not None:
+ wf["112"]["inputs"]["width"] = width
+ if height is not None:
+ wf["112"]["inputs"]["height"] = height
+
+ return wf
+
+ def generate_image(
+ self,
+ prompt: str,
+ input_images: Optional[List[Image.Image]] = None,
+ **kwargs
+ ) -> Image.Image:
+ """
+ Generate image using ComfyUI qwen_image_edit_2509 workflow.
+
+ Args:
+ prompt: Text prompt for image editing
+ input_images: Optional list of input images (uses first image)
+ **kwargs: Additional parameters (negative_prompt, seed, width, height)
+
+ Returns:
+ Generated PIL Image
+ """
+ if not self.health_check():
+ raise RuntimeError("ComfyUI backend not available")
+
+ if self.workflow_template is None:
+ raise RuntimeError("Workflow template not loaded")
+
+ if not input_images or len(input_images) == 0:
+ raise ValueError("qwen_image_edit_2509 requires an input image")
+
+ # Upload input image
+ input_image = input_images[0]
+ uploaded_filename = self.client.upload_image(input_image)
+
+ # Get parameters from kwargs
+ negative_prompt = kwargs.get('negative_prompt', '')
+ seed = kwargs.get('seed', None)
+ width = kwargs.get('width', 1024)
+ height = kwargs.get('height', 1024)
+
+ # Update workflow with parameters
+ workflow = self._update_qwen_workflow(
+ self.workflow_template,
+ prompt=prompt,
+ negative_prompt=negative_prompt,
+ input_image_filename=uploaded_filename,
+ seed=seed,
+ width=width,
+ height=height
+ )
+
+ # Execute workflow
+ images = self.client.execute_workflow(workflow)
+
+ if not images:
+ raise RuntimeError("No images generated")
+
+ # Return first image
+ return images[0]
+
+ def get_capabilities(self) -> Dict[str, Any]:
+ """Report ComfyUI backend capabilities."""
+ return {
+ 'name': 'ComfyUI Local',
+ 'type': 'local',
+ 'supports_input_images': True,
+ 'supports_multi_image': True,
+ 'max_input_images': 16,
+ 'supports_aspect_ratios': True,
+ 'available_aspect_ratios': ['1:1', '3:4', '4:3', '9:16', '16:9'],
+ 'supports_guidance_scale': True,
+ 'supports_inference_steps': True,
+ 'supports_seed': True,
+ 'available_models': [
+ 'qwen_image_edit_2509', # To be installed
+ 'flux.1_kontext_ai' # To be installed
+ ],
+ 'status': 'partial', # Needs workflow implementation
+ 'estimated_time_per_image': 3.0, # seconds (depends on GPU and model)
+ 'cost_per_image': 0.0, # Free, local
+ }
diff --git a/character_forge_image/plugins/gemini_plugin.py b/character_forge_image/plugins/gemini_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..26eaa8d6bd8e29b380fc6eeba2f26cf0a7345942
--- /dev/null
+++ b/character_forge_image/plugins/gemini_plugin.py
@@ -0,0 +1,99 @@
+"""
+Gemini Backend Plugin
+
+Plugin adapter for Gemini 2.5 Flash Image API backend.
+"""
+
+import sys
+from pathlib import Path
+from typing import Any, Dict, Optional, List
+from PIL import Image
+
+# Add parent directories to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from core.gemini_client import GeminiClient
+from models.generation_request import GenerationRequest
+from config.settings import Settings
+
+# Import from shared plugin system
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'shared'))
+from plugin_system.base_plugin import BaseBackendPlugin
+
+
+class GeminiPlugin(BaseBackendPlugin):
+ """Plugin adapter for Gemini API backend."""
+
+ def __init__(self, config_path: Path):
+ """Initialize Gemini plugin."""
+ super().__init__(config_path)
+
+ # Get API key from settings
+ settings = Settings()
+ api_key = settings.get_api_key()
+
+ if api_key:
+ self.client = GeminiClient(api_key)
+ self.available = True
+ else:
+ self.client = None
+ self.available = False
+ print("Warning: Gemini API key not found")
+
+ def health_check(self) -> bool:
+ """Check if Gemini backend is available."""
+ return self.available and self.client is not None
+
+ def generate_image(
+ self,
+ prompt: str,
+ input_images: Optional[List[Image.Image]] = None,
+ **kwargs
+ ) -> Image.Image:
+ """
+ Generate image using Gemini backend.
+
+ Args:
+ prompt: Text prompt for generation
+ input_images: Optional list of input images
+ **kwargs: Additional generation parameters
+
+ Returns:
+ Generated PIL Image
+ """
+ if not self.health_check():
+ raise RuntimeError("Gemini backend not available")
+
+ # Create generation request
+ request = GenerationRequest(
+ prompt=prompt,
+ input_images=input_images or [],
+ aspect_ratio=kwargs.get('aspect_ratio', '1:1'),
+ number_of_images=kwargs.get('number_of_images', 1),
+ safety_filter_level=kwargs.get('safety_filter_level', 'block_some'),
+ person_generation=kwargs.get('person_generation', 'allow_all')
+ )
+
+ # Generate image
+ result = self.client.generate(request)
+
+ if result.images:
+ return result.images[0]
+ else:
+ raise RuntimeError(f"Gemini generation failed: {result.error}")
+
+ def get_capabilities(self) -> Dict[str, Any]:
+ """Report Gemini backend capabilities."""
+ return {
+ 'name': 'Gemini 2.5 Flash Image',
+ 'type': 'cloud',
+ 'supports_input_images': True,
+ 'supports_multi_image': True,
+ 'max_input_images': 16,
+ 'supports_aspect_ratios': True,
+ 'available_aspect_ratios': ['1:1', '3:4', '4:3', '9:16', '16:9'],
+ 'supports_safety_filter': True,
+ 'supports_person_generation': True,
+ 'estimated_time_per_image': 3.0, # seconds
+ 'cost_per_image': 0.02, # USD estimate
+ }
diff --git a/character_forge_image/plugins/omnigen2_plugin.py b/character_forge_image/plugins/omnigen2_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..58f7bf4c5c32361808765c956f9d4c2df70c90d6
--- /dev/null
+++ b/character_forge_image/plugins/omnigen2_plugin.py
@@ -0,0 +1,108 @@
+"""
+OmniGen2 Backend Plugin
+
+Plugin adapter for OmniGen2 local backend.
+"""
+
+import sys
+from pathlib import Path
+from typing import Any, Dict, Optional, List
+from PIL import Image
+
+# Add parent directories to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from core.omnigen2_client import OmniGen2Client
+from models.generation_request import GenerationRequest
+from config.settings import Settings
+
+# Import from shared plugin system
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'shared'))
+from plugin_system.base_plugin import BaseBackendPlugin
+
+
+class OmniGen2Plugin(BaseBackendPlugin):
+ """Plugin adapter for OmniGen2 local backend."""
+
+ def __init__(self, config_path: Path):
+ """Initialize OmniGen2 plugin."""
+ super().__init__(config_path)
+
+ # Get settings
+ settings = Settings()
+ base_url = settings.omnigen2_base_url
+
+ try:
+ self.client = OmniGen2Client(base_url=base_url)
+ # Test connection
+ self.available = self.client.health_check()
+ except Exception as e:
+ print(f"Warning: OmniGen2 backend not available: {e}")
+ self.client = None
+ self.available = False
+
+ def health_check(self) -> bool:
+ """Check if OmniGen2 backend is available."""
+ if not self.available or self.client is None:
+ return False
+
+ try:
+ return self.client.health_check()
+ except:
+ return False
+
+ def generate_image(
+ self,
+ prompt: str,
+ input_images: Optional[List[Image.Image]] = None,
+ **kwargs
+ ) -> Image.Image:
+ """
+ Generate image using OmniGen2 backend.
+
+ Args:
+ prompt: Text prompt for generation
+ input_images: Optional list of input images
+ **kwargs: Additional generation parameters
+
+ Returns:
+ Generated PIL Image
+ """
+ if not self.health_check():
+ raise RuntimeError("OmniGen2 backend not available")
+
+ # Create generation request
+ request = GenerationRequest(
+ prompt=prompt,
+ input_images=input_images or [],
+ aspect_ratio=kwargs.get('aspect_ratio', '1:1'),
+ number_of_images=kwargs.get('number_of_images', 1),
+ guidance_scale=kwargs.get('guidance_scale', 3.0),
+ num_inference_steps=kwargs.get('num_inference_steps', 50),
+ seed=kwargs.get('seed', -1)
+ )
+
+ # Generate image
+ result = self.client.generate(request)
+
+ if result.images:
+ return result.images[0]
+ else:
+ raise RuntimeError(f"OmniGen2 generation failed: {result.error}")
+
+ def get_capabilities(self) -> Dict[str, Any]:
+ """Report OmniGen2 backend capabilities."""
+ return {
+ 'name': 'OmniGen2 Local',
+ 'type': 'local',
+ 'supports_input_images': True,
+ 'supports_multi_image': True,
+ 'max_input_images': 8,
+ 'supports_aspect_ratios': True,
+ 'available_aspect_ratios': ['1:1', '3:4', '4:3', '9:16', '16:9', '3:2', '2:3', '4:5', '5:4', '21:9'],
+ 'supports_guidance_scale': True,
+ 'supports_inference_steps': True,
+ 'supports_seed': True,
+ 'estimated_time_per_image': 8.0, # seconds (depends on GPU)
+ 'cost_per_image': 0.0, # Free, local
+ }
diff --git a/character_forge_image/plugins/plugin_registry.yaml b/character_forge_image/plugins/plugin_registry.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f4a4afa31130574d326713c92289c7beccd5acb0
--- /dev/null
+++ b/character_forge_image/plugins/plugin_registry.yaml
@@ -0,0 +1,21 @@
+plugins:
+ - name: gemini
+ module: gemini_plugin
+ class: GeminiPlugin
+ enabled: true
+ priority: 1
+ description: Gemini API cloud backend
+
+ - name: omnigen2
+ module: omnigen2_plugin
+ class: OmniGen2Plugin
+ enabled: true
+ priority: 2
+ description: OmniGen2 local multi-modal backend
+
+ - name: comfyui
+ module: comfyui_plugin
+ class: ComfyUIPlugin
+ enabled: true
+ priority: 3
+ description: ComfyUI local backend with qwen and Flux.1 Kontext AI
diff --git a/character_forge_image/requirements.txt b/character_forge_image/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..36725158591f7c43c1a6eb76d6df3f92c1872847
--- /dev/null
+++ b/character_forge_image/requirements.txt
@@ -0,0 +1,35 @@
+# Nano Banana Streamlit - Python Dependencies
+# Generated: 2025-10-23
+
+# Core Framework
+streamlit>=1.31.0
+
+# Image Generation Backends
+google-genai>=0.3.0 # Gemini API
+requests>=2.31.0 # For OmniGen2 HTTP client
+websocket-client>=1.7.0 # For ComfyUI WebSocket communication
+
+# Image Processing
+Pillow>=10.0.0 # PIL/Image operations
+numpy>=1.24.0 # Array operations
+
+# Utilities
+python-dateutil>=2.8.2 # Date/time handling
+pathlib>=1.0.1 # Path operations
+PyYAML>=6.0.0 # YAML configuration files
+
+# Logging & Monitoring
+colorlog>=6.7.0 # Colored logging output
+
+# Testing
+pytest>=7.4.0 # Test framework
+pytest-cov>=4.1.0 # Coverage reports
+
+# Development
+black>=23.12.0 # Code formatting
+flake8>=6.1.0 # Linting
+mypy>=1.8.0 # Type checking
+
+# Optional: Enhanced UI
+streamlit-image-comparison # Side-by-side image comparison
+streamlit-extras # Additional components
diff --git a/character_forge_image/services/__init__.py b/character_forge_image/services/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5c51473ce284285279fe2f761c88b481d30e19b
--- /dev/null
+++ b/character_forge_image/services/__init__.py
@@ -0,0 +1,17 @@
+"""Service layer for Nano Banana Streamlit.
+
+Business logic layer that orchestrates backend operations,
+file management, and generation workflows.
+"""
+
+from services.generation_service import GenerationService
+from services.character_forge_service import CharacterForgeService
+from services.wardrobe_service import WardrobeService
+from services.composition_service import CompositionService
+
+__all__ = [
+ 'GenerationService',
+ 'CharacterForgeService',
+ 'WardrobeService',
+ 'CompositionService'
+]
diff --git a/character_forge_image/services/character_forge_service.md b/character_forge_image/services/character_forge_service.md
new file mode 100644
index 0000000000000000000000000000000000000000..2645b1ff785474ca19efd9df47136c1ca3e7495a
--- /dev/null
+++ b/character_forge_image/services/character_forge_service.md
@@ -0,0 +1,370 @@
+# character_forge_service.py
+
+## Purpose
+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.
+
+## Responsibilities
+- Orchestrate 6-stage character sheet generation pipeline
+- Normalize input images (face→body or body→face+body)
+- Generate multiple character views with consistency
+- Composite views into final character sheet
+- Extract individual views from completed sheets
+- Manage retry logic with exponential backoff
+- Save complete character sheet packages to disk
+- Handle three input modes: Face Only, Full Body, Face + Body (Separate)
+
+## Dependencies
+- `core.BackendRouter` - Backend routing
+- `models.GenerationRequest` - Request dataclass
+- `models.GenerationResult` - Result dataclass
+- `utils.file_utils` - File operations (save_image, ensure_directory_exists, sanitize_filename)
+- `utils.logging_utils` - Logging
+- `config.settings.Settings` - Configuration
+- `PIL.Image` - Image manipulation
+- `time` - Rate limiting between API calls
+- `random` - Rate limiting jitter
+
+## Source
+Extracted from `character_forge.py` lines 1120-1690 (Gradio implementation). Refactored to use new architecture.
+
+## Public Interface
+
+### `CharacterForgeService` class
+
+**Constructor:**
+```python
+def __init__(self, api_key: Optional[str] = None)
+```
+- `api_key`: Optional Gemini API key (defaults to Settings)
+- Initializes BackendRouter for backend communication
+
+### Key Methods
+
+#### `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]`
+
+Main entry point for character sheet generation.
+
+**Pipeline:**
+0. Normalize input (face→body or body→face+body)
+1. Front portrait
+2. Side profile portrait
+3. Side profile full body
+4. Rear view full body
+5. Composite character sheet
+
+**Args:**
+- `initial_image`: Starting image (face or body)
+- `initial_image_type`: "Face Only", "Full Body", or "Face + Body (Separate)"
+- `character_name`: Character name (default: "Character")
+- `costume_description`: Text costume description
+- `costume_image`: Optional costume reference
+- `face_image`: Face image (for Face + Body mode)
+- `body_image`: Body image (for Face + Body mode)
+- `backend`: Backend to use (default: Gemini)
+- `progress_callback`: Optional callback(stage: int, message: str)
+- `output_dir`: Optional output directory (defaults to Settings.CHARACTER_SHEETS_DIR)
+
+**Returns:**
+- Tuple of `(character_sheet: Image, status_message: str, metadata: dict)`
+
+**Usage:**
+```python
+service = CharacterForgeService(api_key="your-key")
+
+# Face Only mode
+face_image = Image.open("character_face.png")
+sheet, message, metadata = service.generate_character_sheet(
+ initial_image=face_image,
+ initial_image_type="Face Only",
+ character_name="Hero",
+ costume_description="medieval knight armor",
+ backend="Gemini API (Cloud)",
+ progress_callback=lambda stage, msg: print(f"[{stage}] {msg}"),
+ output_dir=Path("outputs/character_sheets")
+)
+
+if sheet:
+ sheet.show()
+ print(f"Success: {message}")
+ print(f"Saved to: {metadata.get('saved_to')}")
+```
+
+**Input Modes:**
+
+1. **Face Only**: User provides face, service generates full body
+ - Stage 0a: Generate full body from face
+ - Stages 1-5: Generate all views
+
+2. **Full Body**: User provides full body, service extracts face
+ - Stage 0a: Normalize body to front view
+ - Stage 0b: Extract face closeup from body
+ - Stages 1-5: Generate all views
+
+3. **Face + Body (Separate)**: User provides separate face and body
+ - Stage 0a: Normalize body with face details
+ - Stages 1-5: Generate all views (use both references)
+
+#### `composite_character_sheet(front_portrait, side_portrait, front_body, side_body, rear_body, character_name="Character") -> Image`
+
+Composite all views into final character sheet.
+
+**Layout:**
+```
++-------------------+-------------------+
+| Front Portrait | Side Portrait | (3:4 = 1008x1344)
++-------------------+-------------------+
+| Front Body | Side Body | Rear Body | (9:16 = 768x1344)
++-------------------+-------------------+
+```
+
+**Args:**
+- `front_portrait`: Front face view (3:4)
+- `side_portrait`: Side profile face (3:4)
+- `front_body`: Front full body (9:16)
+- `side_body`: Side full body (9:16)
+- `rear_body`: Rear full body (9:16)
+- `character_name`: Character name
+
+**Returns:**
+- Composited character sheet image
+
+**Important:**
+- NO SCALING - 1:1 pixel mapping
+- Images pasted as-is from API
+- Must match `extract_views_from_sheet()` layout
+
+**Usage:**
+```python
+sheet = service.composite_character_sheet(
+ front_portrait=front_port,
+ side_portrait=side_port,
+ front_body=front_body,
+ side_body=side_body,
+ rear_body=rear_body,
+ character_name="Hero"
+)
+```
+
+#### `extract_views_from_sheet(character_sheet) -> Dict[str, Image]`
+
+Extract individual views from character sheet.
+
+**Must match `composite_character_sheet()` layout exactly.**
+
+**Args:**
+- `character_sheet`: Composited character sheet
+
+**Returns:**
+- Dictionary with keys: `front_portrait`, `side_portrait`, `front_body`, `side_body`, `rear_body`
+
+**Usage:**
+```python
+sheet = Image.open("character_sheet.png")
+views = service.extract_views_from_sheet(sheet)
+views['front_portrait'].show()
+views['side_body'].save("side_body.png")
+```
+
+## Private Methods
+
+### `_normalize_input(...) -> Tuple[Optional[Image], Optional[Image]]`
+
+Normalize input images to create reference full body and face.
+
+**Handles three input modes:**
+1. Face + Body (Separate): Normalize body with face details
+2. Face Only: Generate full body from face
+3. Full Body: Normalize body and extract face
+
+**Returns:**
+- Tuple of `(reference_full_body, reference_face)`
+
+### `_generate_stage(prompt, input_images, aspect_ratio, temperature, backend, stage_name, max_retries=3) -> Tuple[Optional[Image], str]`
+
+Generate single stage with retry logic.
+
+**Features:**
+- Exponential backoff (2s, 4s, 8s)
+- Rate limiting delay after success (2-3s jitter)
+- Safety block detection (no retry)
+- Detailed logging
+
+**Args:**
+- `prompt`: Generation prompt
+- `input_images`: Input reference images
+- `aspect_ratio`: Aspect ratio
+- `temperature`: Temperature
+- `backend`: Backend to use
+- `stage_name`: Stage name for logging
+- `max_retries`: Maximum retry attempts (default: 3)
+
+**Returns:**
+- Tuple of `(image, status_message)`
+
+**Retry Logic:**
+```python
+for attempt in range(max_retries):
+ if attempt > 0:
+ wait_time = 2 ** attempt # 2s, 4s, 8s
+ time.sleep(wait_time)
+
+ result = self.router.generate(request)
+
+ if result.success:
+ time.sleep(random.uniform(2.0, 3.0)) # Rate limiting
+ return result.image, result.message
+
+ if "SAFETY" in result.message:
+ return None, result.message # No retry
+```
+
+### `_save_character_sheet(...) -> Path`
+
+Save character sheet and all stages to disk.
+
+**Saves:**
+- Character sheet (with metadata JSON)
+- All intermediate stages (in `stages/` subdirectory)
+- Input images (in `inputs/` subdirectory)
+- Costume references (if provided)
+
+**Directory Structure:**
+```
+output_dir/
+└── {character_name}_{timestamp}/
+ ├── {character_name}_character_sheet.png
+ ├── {character_name}_character_sheet.json
+ ├── stages/
+ │ ├── {character_name}_front_portrait.png
+ │ ├── {character_name}_side_portrait.png
+ │ ├── {character_name}_front_body.png
+ │ ├── {character_name}_side_body.png
+ │ └── {character_name}_rear_body.png
+ └── inputs/
+ ├── {character_name}_initial_{type}.png
+ └── {character_name}_costume_reference.png
+```
+
+**Returns:**
+- Path to saved directory
+
+## Generation Pipeline
+
+### Face Only Mode
+```
+Input: Face image
+ ↓
+Stage 0a: Generate full body from face
+ ↓
+Stage 1: Front portrait (from face + body)
+ ↓
+Stage 2: Side profile portrait (from stage 1 + body)
+ ↓
+Stage 3: Side profile full body (from stage 2 + 1 + body)
+ ↓
+Stage 4: Rear view (from stage 1 + 2)
+ ↓
+Stage 5: Composite all views
+ ↓
+Output: Character sheet
+```
+
+### Full Body Mode
+```
+Input: Full body image
+ ↓
+Stage 0a: Normalize body to front view
+ ↓
+Stage 0b: Extract face closeup from body
+ ↓
+Stage 1: Front portrait (from face + body)
+ ↓
+Stage 2: Side profile portrait (from stage 1 + body)
+ ↓
+Stage 3: Side profile full body (from stage 2 + 1 + body)
+ ↓
+Stage 4: Rear view (from stage 1 + 2)
+ ↓
+Stage 5: Composite all views
+ ↓
+Output: Character sheet
+```
+
+### Face + Body (Separate) Mode
+```
+Input: Face image + Body image
+ ↓
+Stage 0a: Normalize body with face details
+ ↓
+Stage 1: Front portrait (body first, face second - extract face)
+ ↓
+Stage 2: Side profile portrait (from stage 1 + body + face)
+ ↓
+Stage 3: Side profile full body (from stage 2 + 1 + body)
+ ↓
+Stage 4: Rear view (from stage 1 + 2)
+ ↓
+Stage 5: Composite all views
+ ↓
+Output: Character sheet
+```
+
+## Error Handling
+
+Each stage can fail independently:
+```python
+image, status = self._generate_stage(...)
+if image is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Stage X failed: {status}", {}
+```
+
+All exceptions caught at top level:
+```python
+except Exception as e:
+ logger.exception(f"Character sheet generation failed: {e}")
+ return None, f"Character forge error: {str(e)}", {}
+```
+
+## Progress Tracking
+
+Optional progress callback for UI updates:
+```python
+def progress_callback(stage: int, message: str):
+ st.write(f"Stage {stage}/6: {message}")
+
+sheet, msg, meta = service.generate_character_sheet(
+ ...,
+ progress_callback=progress_callback
+)
+```
+
+## Metadata Format
+
+```python
+{
+ "character_name": "Hero",
+ "initial_image_type": "Face Only",
+ "costume_description": "medieval knight armor",
+ "has_costume_image": False,
+ "backend": "Gemini API (Cloud)",
+ "timestamp": "2025-10-23T14:30:00",
+ "stages": {
+ "front_portrait": "generated",
+ "side_portrait": "generated",
+ "front_body": "generated", # or "provided"
+ "side_body": "generated",
+ "rear_body": "generated"
+ },
+ "saved_to": "/path/to/output/dir"
+}
+```
+
+## Related Files
+- `character_forge.py` (old) - Original Gradio implementation source
+- `services/wardrobe_service.py` - Extends this service for wardrobe changes
+- `services/generation_service.py` - Base generation capabilities
+- `core/backend_router.py` - Backend routing
+- `models/generation_request.py` - Request structure
+- `models/generation_result.py` - Result structure
+- `ui/pages/01_🔥_Character_Forge.py` - UI that uses this service
diff --git a/character_forge_image/services/character_forge_service.py b/character_forge_image/services/character_forge_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..dff85182570f151aba963f58765ac4dc4d45cf25
--- /dev/null
+++ b/character_forge_image/services/character_forge_service.py
@@ -0,0 +1,1167 @@
+"""
+Character Forge Service
+=======================
+
+Business logic for character sheet generation.
+Orchestrates 6-stage generation pipeline for turnaround character sheets.
+"""
+
+import time
+import random
+from pathlib import Path
+from typing import Optional, Tuple, Dict, Any, Callable, List
+from datetime import datetime
+from PIL import Image, ImageDraw, ImageFont
+
+from core import BackendRouter
+from models.generation_request import GenerationRequest
+from models.generation_result import GenerationResult
+from utils.file_utils import (
+ save_image,
+ create_generation_metadata,
+ ensure_directory_exists,
+ sanitize_filename,
+ ensure_pil_image
+)
+from utils.logging_utils import get_logger
+from config.settings import Settings
+
+
+logger = get_logger(__name__)
+
+
+class CharacterForgeService:
+ """
+ Service for generating character turnaround sheets.
+
+ Orchestrates 6-stage pipeline:
+ 0. Normalize input (face→body or body→face+body)
+ 1. Front portrait
+ 2. Side profile portrait
+ 3. Side profile full body
+ 4. Rear view full body
+ 5. Composite character sheet
+ """
+
+ def __init__(self, api_key: Optional[str] = None):
+ """
+ Initialize character forge service.
+
+ Args:
+ api_key: Optional Gemini API key
+ """
+ self.router = BackendRouter(api_key=api_key)
+ logger.info("CharacterForgeService initialized")
+
+ def get_all_backend_status(self) -> dict:
+ """
+ Get health status of all backends.
+
+ Returns:
+ Dictionary with backend status info
+ """
+ return self.router.get_all_backend_status()
+
+ def check_backend_availability(self, backend: str) -> Tuple[bool, str]:
+ """
+ Check if a specific backend is available.
+
+ Args:
+ backend: Backend name to check
+
+ Returns:
+ Tuple of (is_healthy, status_message)
+ """
+ return self.router.check_backend_health(backend)
+
+ def generate_character_sheet(
+ self,
+ initial_image: Optional[Image.Image],
+ initial_image_type: str,
+ character_name: str = "Character",
+ gender_term: str = "character",
+ costume_description: str = "",
+ costume_image: Optional[Image.Image] = None,
+ face_image: Optional[Image.Image] = None,
+ body_image: Optional[Image.Image] = None,
+ backend: str = Settings.BACKEND_GEMINI,
+ progress_callback: Optional[Callable[[int, str], None]] = None,
+ output_dir: Optional[Path] = None
+ ) -> Tuple[Optional[Image.Image], str, Dict[str, Any]]:
+ """
+ Generate complete character turnaround sheet.
+
+ Pipeline:
+ - Face Only: Generate body, then 5 views
+ - Full Body: Extract face, normalize, then 5 views
+ - Face + Body: Normalize body, then 5 views
+
+ Args:
+ initial_image: Starting image (face or body)
+ initial_image_type: "Face Only", "Full Body", or "Face + Body (Separate)"
+ character_name: Character name
+ gender_term: Gender-specific term ("character", "man", or "woman") for better prompts
+ costume_description: Text costume description
+ costume_image: Optional costume reference
+ face_image: Face image (for Face + Body mode)
+ body_image: Body image (for Face + Body mode)
+ backend: Backend to use
+ progress_callback: Optional callback(stage: int, message: str)
+ output_dir: Optional output directory (defaults to Settings.CHARACTER_SHEETS_DIR)
+
+ Returns:
+ Tuple of (character_sheet: Image, status_message: str, metadata: dict)
+ """
+ try:
+ logger.info("="*80)
+ logger.info(f"STARTING CHARACTER SHEET GENERATION: {character_name}")
+ logger.info(f"Initial image type: {initial_image_type}")
+ logger.info(f"Costume description: {costume_description or '(none)'}")
+ logger.info(f"Costume reference: {'Yes' if costume_image else 'No'}")
+ logger.info(f"Backend: {backend}")
+ logger.info("="*80)
+
+ # Storage for generated images
+ stages = {}
+ current_stage = "Initialization"
+ current_prompt = ""
+
+ # Build costume instruction
+ costume_instruction = ""
+ if costume_description:
+ costume_instruction = f" wearing {costume_description}"
+ elif costume_image:
+ costume_instruction = " wearing the costume shown in the reference image"
+
+ # Stage 0: Normalize input to create references
+ reference_full_body, reference_face = self._normalize_input(
+ initial_image=initial_image,
+ initial_image_type=initial_image_type,
+ face_image=face_image,
+ body_image=body_image,
+ costume_instruction=costume_instruction,
+ costume_image=costume_image,
+ character_name=character_name,
+ gender_term=gender_term,
+ backend=backend,
+ stages=stages,
+ progress_callback=progress_callback
+ )
+
+ if reference_full_body is None or reference_face is None:
+ return None, "Failed to normalize input images", {}
+
+ time.sleep(1)
+
+ # Stage 1: Generate front portrait
+ current_stage = "Stage 1/6: Generating front portrait"
+ if progress_callback:
+ progress_callback(1, current_stage)
+
+ if initial_image_type == "Face + Body (Separate)":
+ 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."
+ input_images = [reference_full_body, reference_face]
+ else:
+ # Original prompt - works for ALL backends
+ 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."
+
+ input_images = [reference_face, reference_full_body]
+ if costume_image:
+ input_images.append(costume_image)
+
+ front_portrait, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="3:4",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if front_portrait is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Stage 1 failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {front_portrait.size}")
+ stages['front_portrait'] = front_portrait
+ stages['stage_1_prompt'] = current_prompt
+ time.sleep(1)
+
+ # Stage 2: Generate side profile portrait
+ current_stage = "Stage 2/6: Generating side profile portrait"
+ if progress_callback:
+ progress_callback(2, current_stage)
+
+ # Original prompt - works for ALL backends
+ 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."
+
+ input_images = [front_portrait, reference_full_body]
+ if initial_image_type == "Face + Body (Separate)":
+ input_images.append(reference_face)
+ elif costume_image:
+ input_images.append(costume_image)
+
+ side_portrait, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="3:4",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if side_portrait is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Stage 2 failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {side_portrait.size}")
+ stages['side_portrait'] = side_portrait
+ stages['stage_2_prompt'] = current_prompt
+ time.sleep(1)
+
+ # Stage 3: Generate side profile full body
+ current_stage = "Stage 3/6: Generating side profile full body"
+ if progress_callback:
+ progress_callback(3, current_stage)
+
+ 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."
+
+ input_images = [side_portrait, front_portrait, reference_full_body]
+
+ side_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if side_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Stage 3 failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {side_body.size}")
+ stages['side_body'] = side_body
+ stages['stage_3_prompt'] = current_prompt
+ time.sleep(1)
+
+ # Stage 4: Generate rear view
+ current_stage = "Stage 4/6: Generating rear view"
+ if progress_callback:
+ progress_callback(4, current_stage)
+
+ 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."
+
+ input_images = [reference_full_body, side_body]
+ if costume_image:
+ input_images.append(costume_image)
+
+ rear_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if rear_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Stage 4 failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {rear_body.size}")
+ stages['rear_body'] = rear_body
+ stages['stage_4_prompt'] = current_prompt
+ time.sleep(1)
+
+ # Stage 5: Composite character sheet
+ current_stage = "Stage 5/6: Compositing character sheet"
+ if progress_callback:
+ progress_callback(5, current_stage)
+
+ logger.info(f"[{current_stage}] Compositing all views into final sheet...")
+
+ # Quick pre-check: log types and sizes of inputs before composing
+ def _img_info(obj):
+ try:
+ return f"{type(obj).__name__}, size={getattr(obj, 'size', 'n/a')}"
+ except Exception:
+ return f"{type(obj).__name__}"
+
+ logger.info(
+ "[Composite Precheck] front_portrait=%s, side_portrait=%s, front_body=%s, side_body=%s, rear_body=%s",
+ _img_info(front_portrait),
+ _img_info(side_portrait),
+ _img_info(reference_full_body),
+ _img_info(side_body),
+ _img_info(rear_body),
+ )
+
+ character_sheet = self.composite_character_sheet(
+ front_portrait=front_portrait,
+ side_portrait=side_portrait,
+ front_body=reference_full_body,
+ side_body=side_body,
+ rear_body=rear_body,
+ character_name=character_name
+ )
+
+ logger.info(f"{current_stage} complete: {character_sheet.size}")
+ stages['character_sheet'] = character_sheet
+
+ # Build metadata (include images and prompts for debugging/testing)
+ metadata = {
+ "character_name": character_name,
+ "initial_image_type": initial_image_type,
+ "costume_description": costume_description,
+ "has_costume_image": costume_image is not None,
+ "backend": backend,
+ "timestamp": datetime.now().isoformat(),
+ "stages": {
+ "reference_full_body": {
+ "image": reference_full_body,
+ "status": "generated" if initial_image_type == "Face Only" else "provided",
+ "prompt": stages.get('stage_0a_prompt', ''),
+ "aspect_ratio": "9:16",
+ "temperature": 0.5
+ },
+ "reference_face": {
+ "image": reference_face,
+ "status": "generated" if initial_image_type in ["Face + Body (Separate)", "Full Body"] else "provided",
+ "prompt": stages.get('stage_0b_prompt', ''),
+ "aspect_ratio": "3:4",
+ "temperature": 0.35
+ },
+ "front_portrait": {
+ "image": front_portrait,
+ "status": "generated",
+ "prompt": stages.get('stage_1_prompt', ''),
+ "negative_prompt": stages.get('stage_1_negative_prompt', ''),
+ "aspect_ratio": "3:4",
+ "temperature": 0.35
+ },
+ "side_portrait": {
+ "image": side_portrait,
+ "status": "generated",
+ "prompt": stages.get('stage_2_prompt', ''),
+ "negative_prompt": stages.get('stage_2_negative_prompt', ''),
+ "aspect_ratio": "3:4",
+ "temperature": 0.35
+ },
+ "side_body": {
+ "image": side_body,
+ "status": "generated",
+ "prompt": stages.get('stage_3_prompt', ''),
+ "negative_prompt": stages.get('stage_3_negative_prompt', ''),
+ "aspect_ratio": "9:16",
+ "temperature": 0.35
+ },
+ "rear_body": {
+ "image": rear_body,
+ "status": "generated",
+ "prompt": stages.get('stage_4_prompt', ''),
+ "negative_prompt": stages.get('stage_4_negative_prompt', ''),
+ "aspect_ratio": "9:16",
+ "temperature": 0.35
+ }
+ }
+ }
+
+ success_msg = f"Character sheet generated successfully! Contains {len(stages)} views of {character_name}."
+
+ # Save to disk if output directory provided
+ if output_dir:
+ save_dir = self._save_character_sheet(
+ character_name=character_name,
+ stages=stages,
+ initial_image=initial_image,
+ initial_image_type=initial_image_type,
+ costume_description=costume_description,
+ costume_image=costume_image,
+ metadata=metadata,
+ face_image=face_image,
+ body_image=body_image,
+ output_dir=output_dir
+ )
+ success_msg += f"\n\nFiles saved to: {save_dir}"
+ metadata['saved_to'] = str(save_dir)
+
+ return character_sheet, success_msg, metadata
+
+ except Exception as e:
+ logger.exception(f"Character sheet generation failed: {e}")
+ return None, f"Character forge error: {str(e)}", {}
+
+ def _normalize_input(
+ self,
+ initial_image: Optional[Image.Image],
+ initial_image_type: str,
+ face_image: Optional[Image.Image],
+ body_image: Optional[Image.Image],
+ costume_instruction: str,
+ costume_image: Optional[Image.Image],
+ character_name: str,
+ gender_term: str,
+ backend: str,
+ stages: dict,
+ progress_callback: Optional[Callable]
+ ) -> Tuple[Optional[Image.Image], Optional[Image.Image]]:
+ """
+ Normalize input images to create reference full body and face.
+
+ Returns:
+ Tuple of (reference_full_body, reference_face)
+ """
+ if initial_image_type == "Face + Body (Separate)":
+ # User provided separate face and body
+ logger.info("Using Face + Body (Separate) mode")
+
+ # Validate input
+ if face_image is None or body_image is None:
+ logger.error(f"Face + Body mode: Missing images! Face: {face_image is not None}, Body: {body_image is not None}")
+ return None, None
+
+ logger.info(f"Face + Body mode: face size = {face_image.size}, body size = {body_image.size}")
+
+ current_stage = "Stage 0a/6: Normalizing body image"
+ if progress_callback:
+ progress_callback(0, current_stage)
+
+ current_prompt = f"Front view full body portrait of this person{costume_instruction}, standing, neutral background"
+ input_images = [body_image, face_image]
+ if costume_image:
+ input_images.append(costume_image)
+
+ logger.info(f"Calling _generate_stage with {len(input_images)} input images")
+
+ normalized_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.5,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if normalized_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, None
+
+ logger.info(f"{current_stage} complete: {normalized_body.size}")
+ stages['normalized_full_body'] = normalized_body
+ stages['provided_body'] = body_image
+ stages['provided_face'] = face_image
+
+ return normalized_body, face_image
+
+ elif initial_image_type == "Face Only":
+ # Generate full body from face
+ current_stage = "Stage 0a/6: Generating full body from face"
+ if progress_callback:
+ progress_callback(0, current_stage)
+
+ # Validate input
+ if initial_image is None:
+ logger.error("Face Only mode: initial_image is None!")
+ return None, None
+
+ logger.info(f"Face Only mode: initial_image size = {initial_image.size}")
+ logger.info(f"Costume image: {costume_image is not None}")
+
+ 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."
+
+ input_images = [initial_image]
+ if costume_image:
+ input_images.append(costume_image)
+
+ logger.info(f"Calling _generate_stage with {len(input_images)} input images")
+
+ full_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.5,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if full_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, None
+
+ logger.info(f"{current_stage} complete: {full_body.size}")
+ stages['initial_full_body'] = full_body
+
+ return full_body, initial_image
+
+ else:
+ # Starting with full body - normalize and extract face
+ # Stage 0a: Normalize body
+ current_stage = "Stage 0a/6: Normalizing full body"
+ if progress_callback:
+ progress_callback(0, current_stage)
+
+ current_prompt = f"Front view full body portrait of this person{costume_instruction}, standing, neutral background"
+
+ input_images = [initial_image]
+ if costume_image:
+ input_images.append(costume_image)
+
+ normalized_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.5,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if normalized_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, None
+
+ logger.info(f"{current_stage} complete: {normalized_body.size}")
+ stages['normalized_full_body'] = normalized_body
+ time.sleep(1)
+
+ # Stage 0b: Extract face from normalized body
+ current_stage = "Stage 0b/6: Generating face closeup from body"
+ if progress_callback:
+ progress_callback(0, current_stage)
+
+ 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."
+
+ input_images = [normalized_body, initial_image]
+ if costume_image:
+ input_images.append(costume_image)
+
+ face_closeup, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="3:4",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage,
+ progress_callback=progress_callback
+ )
+
+ if face_closeup is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, None
+
+ logger.info(f"{current_stage} complete: {face_closeup.size}")
+ stages['initial_face'] = face_closeup
+
+ return normalized_body, face_closeup
+
+ def _generate_stage(
+ self,
+ prompt: str,
+ input_images: List[Image.Image],
+ aspect_ratio: str,
+ temperature: float,
+ backend: str,
+ stage_name: str,
+ negative_prompt: Optional[str] = None,
+ max_retries: int = 3,
+ progress_callback: Optional[Callable[[int, str], None]] = None
+ ) -> Tuple[Optional[Image.Image], str]:
+ """
+ Generate single stage with retry logic.
+
+ Args:
+ prompt: Generation prompt
+ input_images: Input reference images
+ aspect_ratio: Aspect ratio
+ temperature: Temperature
+ backend: Backend to use
+ stage_name: Stage name for logging
+ negative_prompt: Negative prompt (optional, auto-applied for ComfyUI)
+ max_retries: Maximum retry attempts
+
+ Returns:
+ Tuple of (image, status_message)
+ """
+ logger.info(f"[{stage_name}] Starting generation...")
+ logger.info(f" Prompt: {prompt[:100]}...")
+ logger.info(f" Input images: {len(input_images)}")
+ logger.info(f" Aspect ratio: {aspect_ratio}, Temperature: {temperature}")
+
+ # Auto-apply default negative prompts for ComfyUI if not provided
+ if negative_prompt is None and backend == Settings.BACKEND_COMFYUI:
+ # Extract stage key from stage_name (e.g., "Stage 1/6: ..." -> "stage_1")
+ stage_key = stage_name.lower().split(":")[0].strip().replace(" ", "_").replace("/", "_")
+ negative_prompt = Settings.DEFAULT_NEGATIVE_PROMPTS.get(stage_key, "blurry, low quality, distorted, deformed")
+ logger.info(f" Auto-applied negative prompt: {negative_prompt[:80]}...")
+
+ # Track if we need to modify prompt for safety
+ modified_prompt = prompt
+ safety_block_detected = False
+
+ for attempt in range(max_retries):
+ try:
+ if attempt > 0:
+ # Use 30-second delays between retries to avoid API spam
+ wait_time = 30
+ logger.info(f"Retry attempt {attempt + 1}/{max_retries}, waiting {wait_time}s...")
+
+ # Show countdown to user
+ if progress_callback:
+ for remaining in range(wait_time, 0, -1):
+ progress_callback(
+ 0,
+ f"⏳ Retry {attempt + 1}/{max_retries} in {remaining}s... (API rate limit cooldown)"
+ )
+ time.sleep(1)
+ else:
+ time.sleep(wait_time)
+
+ # Build request (use modified prompt if safety block was detected)
+ request = GenerationRequest(
+ prompt=modified_prompt,
+ backend=backend,
+ aspect_ratio=aspect_ratio,
+ temperature=temperature,
+ input_images=input_images,
+ negative_prompt=negative_prompt
+ )
+
+ # Generate
+ result = self.router.generate(request)
+
+ if result.success:
+ # Rate limiting delay
+ delay = random.uniform(2.0, 3.0)
+ logger.info(f"Generation successful, waiting {delay:.1f}s...")
+ time.sleep(delay)
+ # Normalize to PIL Image in case backend returned a path-like
+ try:
+ normalized_image = ensure_pil_image(result.image, context=f"{stage_name}/result")
+ except Exception as e:
+ return None, f"Invalid image type from backend: {e}"
+ return normalized_image, result.message
+
+ # Detect safety/censorship blocks and modify prompt
+ error_msg_upper = result.message.upper()
+ if any(keyword in error_msg_upper for keyword in [
+ 'SAFETY', 'BLOCKED', 'PROHIBITED', 'CENSORED',
+ 'POLICY', 'NSFW', 'INAPPROPRIATE', 'IMAGE_OTHER'
+ ]):
+ safety_block_detected = True
+ logger.warning(f"⚠️ Safety/censorship filter detected: {result.message}")
+
+ # Modify prompt to explicitly add clothing (avoid NSFW assumptions)
+ if not any(clothing in modified_prompt.lower() for clothing in [
+ 'wearing', 'clothed', 'dressed', 'outfit', 'clothing',
+ 'shirt', 'pants', 'dress', 'bikini', 'shorts', 'attire'
+ ]):
+ # Add clothing description based on context
+ if 'portrait' in modified_prompt.lower() or 'face' in modified_prompt.lower():
+ clothing_addon = ", wearing appropriate clothing (casual shirt or top)"
+ elif 'body' in modified_prompt.lower() or 'full body' in modified_prompt.lower():
+ clothing_addon = ", fully clothed in casual wear (shirt and pants or shorts)"
+ else:
+ clothing_addon = ", wearing appropriate casual attire"
+
+ modified_prompt = prompt + clothing_addon
+ logger.info(f"🔄 Modified prompt to avoid safety filters: '{clothing_addon}'")
+
+ if progress_callback:
+ progress_callback(
+ 0,
+ f"⚠️ Safety filter triggered - adding clothing description to prompt..."
+ )
+ time.sleep(2) # Brief pause to show message
+
+ # Continue to retry with modified prompt
+ logger.warning(f"Attempt {attempt + 1}/{max_retries} failed, will retry with modified prompt")
+ else:
+ logger.warning(f"Attempt {attempt + 1}/{max_retries} failed: {result.message}")
+
+ except Exception as e:
+ logger.error(f"Attempt {attempt + 1}/{max_retries} exception: {e}")
+ if attempt == max_retries - 1:
+ return None, f"All {max_retries} attempts failed: {str(e)}"
+
+ return None, f"All {max_retries} attempts exhausted"
+
+ def composite_character_sheet(
+ self,
+ front_portrait: Image.Image,
+ side_portrait: Image.Image,
+ front_body: Image.Image,
+ side_body: Image.Image,
+ rear_body: Image.Image,
+ character_name: str = "Character",
+ save_debug: bool = False,
+ debug_dir: Optional[Path] = None
+ ) -> Image.Image:
+ """
+ Composite all views into character sheet.
+
+ Layout:
+ +-------------------+-------------------+
+ | Front Portrait | Side Portrait | (3:4 = 864x1184)
+ +-------------------+-------------------+
+ | Front Body | Side Body | Rear Body | (9:16 = 768x1344)
+ +-------------------+-------------------+
+
+ Args:
+ front_portrait: Front face view
+ side_portrait: Side profile face
+ front_body: Front full body
+ side_body: Side full body
+ rear_body: Rear full body
+ character_name: Character name
+ save_debug: If True, save source images to assembled/
+ debug_dir: Directory to save debug files
+
+ Returns:
+ Composited character sheet
+ """
+ from datetime import datetime
+
+ # Validate/normalize inputs to PIL Images and log their types/sizes if possible
+ inputs = {
+ 'front_portrait': front_portrait,
+ 'side_portrait': side_portrait,
+ 'front_body': front_body,
+ 'side_body': side_body,
+ 'rear_body': rear_body,
+ }
+
+ normalized_inputs = {}
+ for name, img in inputs.items():
+ normalized = ensure_pil_image(img, context=f"composite:{name}")
+ normalized_inputs[name] = normalized
+ try:
+ logger.info(f"[Composite] {name}: type={type(normalized).__name__}, size={normalized.size}")
+ except Exception:
+ logger.info(f"[Composite] {name}: type={type(normalized).__name__}")
+
+ front_portrait = normalized_inputs['front_portrait']
+ side_portrait = normalized_inputs['side_portrait']
+ front_body = normalized_inputs['front_body']
+ side_body = normalized_inputs['side_body']
+ rear_body = normalized_inputs['rear_body']
+
+ # Save source images before composition (if debugging enabled)
+ if save_debug and debug_dir:
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ safe_name = sanitize_filename(character_name)
+
+ assembled_dir = debug_dir / "assembled"
+ assembled_dir.mkdir(parents=True, exist_ok=True)
+
+ logger.info(f"[DEBUG] Saving source images to: {assembled_dir}")
+
+ source_images = {
+ 'front_portrait': front_portrait,
+ 'side_portrait': side_portrait,
+ 'front_body': front_body,
+ 'side_body': side_body,
+ 'rear_body': rear_body
+ }
+
+ for view_name, image in source_images.items():
+ save_path = assembled_dir / f"{safe_name}_{timestamp}_{view_name}.png"
+ image.save(save_path, format="PNG", compress_level=0)
+ logger.info(f"[DEBUG] Saved source: {save_path}")
+
+ spacing = 20
+
+ # Calculate canvas dimensions
+ canvas_width = front_body.width + side_body.width + rear_body.width
+ portrait_row_width = front_portrait.width + side_portrait.width
+ canvas_width = max(canvas_width, portrait_row_width)
+ canvas_height = front_portrait.height + spacing + front_body.height
+
+ # Create canvas
+ canvas = Image.new('RGB', (canvas_width, canvas_height), color='#2C2C2C')
+
+ # Upper row: Portraits
+ x_offset = 0
+ canvas.paste(front_portrait, (x_offset, 0))
+ x_offset += front_portrait.width
+ canvas.paste(side_portrait, (x_offset, 0))
+
+ # Lower row: Bodies
+ x_offset = 0
+ y_offset = front_portrait.height + spacing
+ canvas.paste(front_body, (x_offset, y_offset))
+ x_offset += front_body.width
+ canvas.paste(side_body, (x_offset, y_offset))
+ x_offset += side_body.width
+ canvas.paste(rear_body, (x_offset, y_offset))
+
+ return canvas
+
+ def extract_views_from_sheet(
+ self,
+ character_sheet: Image.Image,
+ save_debug: bool = False,
+ debug_dir: Optional[Path] = None,
+ character_name: str = "character"
+ ) -> Dict[str, Image.Image]:
+ """
+ Extract individual views from character sheet.
+
+ CRITICAL: This MUST be the EXACT mathematical inverse of composite_character_sheet().
+ Any deviation will cause corrupted images to be fed back into the AI pipeline.
+
+ Args:
+ character_sheet: Composited character sheet
+ save_debug: If True, save intermediate images and validation results
+ debug_dir: Directory to save debug files (uses output dir if None)
+ character_name: Name for debug files
+
+ Returns:
+ Dictionary with extracted views
+ """
+ import numpy as np
+ from datetime import datetime
+
+ sheet_width, sheet_height = character_sheet.size
+
+ # Get actual dimensions from the sheet
+ # We need to reverse-engineer the composition layout
+
+ # The composition uses:
+ # spacing = 20
+ # canvas_width = max(3 * body_width, 2 * portrait_width)
+ # canvas_height = portrait_height + spacing + body_height
+
+ # From this, we can deduce:
+ # portrait_height + spacing + body_height = sheet_height
+ # Since portraits are 3:4 (1008x1344) and bodies are 9:16 (768x1344)
+ # portrait_height = 1344, body_height = 1344, spacing = 20
+ # sheet_height should be 1344 + 20 + 1344 = 2708
+
+ spacing = 20
+
+ # Find the ACTUAL separator position by scanning for the dark horizontal bar
+ # The separator is a dark gray (#2C2C2C) 20px bar between portraits and bodies
+ # We scan in the middle third of the sheet where we expect to find it
+
+ scan_start = sheet_height // 3
+ scan_end = (2 * sheet_height) // 3
+
+ logger.debug(f"Scanning for separator between y={scan_start} and y={scan_end}")
+
+ # Find the darkest horizontal strip (this is the separator)
+ min_brightness = 255
+ separator_y = scan_start
+
+ for y in range(scan_start, scan_end):
+ # Sample a horizontal line across the width
+ line = character_sheet.crop((0, y, min(200, sheet_width), y + 1))
+ pixels = list(line.getdata())
+
+ # Calculate average brightness
+ avg_brightness = sum(
+ sum(p[:3]) / 3 if isinstance(p, tuple) else p
+ for p in pixels
+ ) / len(pixels)
+
+ if avg_brightness < min_brightness:
+ min_brightness = avg_brightness
+ separator_y = y
+
+ logger.info(f"Found separator at y={separator_y}, brightness={min_brightness:.1f}")
+
+ # The separator is 20px tall, portrait ends just before it
+ portrait_height = separator_y
+ body_start_y = separator_y + spacing
+ body_height = sheet_height - body_start_y
+
+ # Calculate widths using aspect ratios
+ # Portraits: 3:4 ratio
+ portrait_width = (portrait_height * 3) // 4
+
+ # Bodies: 9:16 ratio
+ body_width = (body_height * 9) // 16
+
+ logger.info(f"Sheet dimensions: {sheet_width}x{sheet_height}")
+ logger.info(f"Extracted dimensions: portrait={portrait_width}x{portrait_height}, body={body_width}x{body_height}, spacing={spacing}")
+
+ # EXACT INVERSE of composite_character_sheet():
+ # Upper row: Portraits
+ # canvas.paste(front_portrait, (0, 0))
+ front_portrait = character_sheet.crop((
+ 0, 0,
+ portrait_width, portrait_height
+ ))
+
+ # canvas.paste(side_portrait, (front_portrait.width, 0))
+ side_portrait = character_sheet.crop((
+ portrait_width, 0,
+ 2 * portrait_width, portrait_height
+ ))
+
+ # Lower row: Bodies
+ y_offset = body_start_y
+
+ # canvas.paste(front_body, (0, y_offset))
+ front_body = character_sheet.crop((
+ 0, y_offset,
+ body_width, y_offset + body_height
+ ))
+
+ # canvas.paste(side_body, (front_body.width, y_offset))
+ side_body = character_sheet.crop((
+ body_width, y_offset,
+ 2 * body_width, y_offset + body_height
+ ))
+
+ # canvas.paste(rear_body, (front_body.width + side_body.width, y_offset))
+ rear_body = character_sheet.crop((
+ 2 * body_width, y_offset,
+ 3 * body_width, y_offset + body_height
+ ))
+
+ views = {
+ 'front_portrait': front_portrait,
+ 'side_portrait': side_portrait,
+ 'front_body': front_body,
+ 'side_body': side_body,
+ 'rear_body': rear_body
+ }
+
+ # Debug: Save intermediate images and validate
+ if save_debug and debug_dir:
+ self._save_and_validate_extraction(
+ character_sheet=character_sheet,
+ extracted_views=views,
+ debug_dir=debug_dir,
+ character_name=character_name
+ )
+
+ return views
+
+ def _save_and_validate_extraction(
+ self,
+ character_sheet: Image.Image,
+ extracted_views: Dict[str, Image.Image],
+ debug_dir: Path,
+ character_name: str
+ ):
+ """
+ Save extracted views and validate that extraction is the perfect inverse of composition.
+
+ Creates two subdirectories:
+ - disassembled/: Extracted views from character sheet
+ - validation/: Recomposited sheet + pixel-perfect comparison results
+
+ Args:
+ character_sheet: Original character sheet
+ extracted_views: Dictionary of extracted views
+ debug_dir: Base directory for debug files
+ character_name: Character name for file naming
+ """
+ import numpy as np
+ from datetime import datetime
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ safe_name = sanitize_filename(character_name)
+
+ # Create subdirectories
+ disassembled_dir = debug_dir / "disassembled"
+ validation_dir = debug_dir / "validation"
+ disassembled_dir.mkdir(parents=True, exist_ok=True)
+ validation_dir.mkdir(parents=True, exist_ok=True)
+
+ logger.info(f"[DEBUG] Saving extracted views to: {disassembled_dir}")
+
+ # Save extracted views to disassembled/
+ for view_name, image in extracted_views.items():
+ save_path = disassembled_dir / f"{safe_name}_{timestamp}_{view_name}.png"
+ image.save(save_path, format="PNG", compress_level=0)
+ logger.info(f"[DEBUG] Saved: {save_path}")
+
+ # Recomposite the extracted views to validate extraction
+ logger.info(f"[DEBUG] Recompositing extracted views for validation...")
+
+ recomposited = self.composite_character_sheet(
+ front_portrait=extracted_views['front_portrait'],
+ side_portrait=extracted_views['side_portrait'],
+ front_body=extracted_views['front_body'],
+ side_body=extracted_views['side_body'],
+ rear_body=extracted_views['rear_body'],
+ character_name=character_name
+ )
+
+ # Save recomposited sheet
+ recomposited_path = validation_dir / f"{safe_name}_{timestamp}_recomposited.png"
+ recomposited.save(recomposited_path, format="PNG", compress_level=0)
+ logger.info(f"[DEBUG] Saved recomposited: {recomposited_path}")
+
+ # Pixel-perfect comparison
+ logger.info(f"[DEBUG] Performing pixel-perfect comparison...")
+
+ original_array = np.array(character_sheet)
+ recomposited_array = np.array(recomposited)
+
+ # Check dimensions match
+ if original_array.shape != recomposited_array.shape:
+ logger.error(f"[VALIDATION FAIL] Dimension mismatch! Original: {original_array.shape}, Recomposited: {recomposited_array.shape}")
+ return
+
+ # Pixel-by-pixel comparison
+ differences = np.abs(original_array.astype(int) - recomposited_array.astype(int))
+ max_diff = np.max(differences)
+ mean_diff = np.mean(differences)
+ num_different_pixels = np.count_nonzero(differences)
+
+ # Create difference heatmap (amplified for visibility)
+ diff_heatmap = np.max(differences, axis=2) * 10 # Amplify differences
+ diff_image = Image.fromarray(diff_heatmap.astype(np.uint8))
+ diff_path = validation_dir / f"{safe_name}_{timestamp}_diff_heatmap.png"
+ diff_image.save(diff_path, format="PNG", compress_level=0)
+
+ # Validation report
+ report = [
+ f"=== EXTRACTION VALIDATION REPORT ===",
+ f"Character: {character_name}",
+ f"Timestamp: {timestamp}",
+ f"",
+ f"Original dimensions: {original_array.shape}",
+ f"Recomposited dimensions: {recomposited_array.shape}",
+ f"",
+ f"Pixel-perfect comparison:",
+ f" Max difference: {max_diff} / 255",
+ f" Mean difference: {mean_diff:.4f} / 255",
+ f" Different pixels: {num_different_pixels} / {original_array.size}",
+ f"",
+ ]
+
+ if max_diff == 0:
+ report.append("✅ PERFECT MATCH - Extraction is pixel-perfect inverse of composition!")
+ logger.info("[VALIDATION SUCCESS] ✅ Pixel-perfect match!")
+ elif max_diff <= 1:
+ report.append("✅ EXCELLENT - Differences within rounding error (≤1)")
+ logger.info(f"[VALIDATION SUCCESS] ✅ Near-perfect (max diff: {max_diff})")
+ elif max_diff <= 5:
+ report.append(f"⚠️ MINOR DIFFERENCES - Max diff: {max_diff} (acceptable for JPEG artifacts)")
+ logger.warning(f"[VALIDATION WARN] ⚠️ Minor differences (max diff: {max_diff})")
+ else:
+ report.append(f"❌ SIGNIFICANT DIFFERENCES - Max diff: {max_diff} (EXTRACTION BUG!)")
+ logger.error(f"[VALIDATION FAIL] ❌ Significant differences (max diff: {max_diff})")
+
+ report.append("")
+ report.append(f"Files saved:")
+ report.append(f" Original: {character_sheet.size}")
+ report.append(f" Recomposited: {recomposited_path}")
+ report.append(f" Diff heatmap: {diff_path}")
+
+ # Save report
+ report_path = validation_dir / f"{safe_name}_{timestamp}_validation_report.txt"
+ with open(report_path, 'w') as f:
+ f.write('\n'.join(report))
+
+ logger.info(f"[DEBUG] Validation report: {report_path}")
+
+ # Log summary
+ for line in report:
+ if line.startswith('✅') or line.startswith('❌') or line.startswith('⚠️'):
+ logger.info(f"[VALIDATION] {line}")
+
+ def _save_character_sheet(
+ self,
+ character_name: str,
+ stages: dict,
+ initial_image: Image.Image,
+ initial_image_type: str,
+ costume_description: str,
+ costume_image: Optional[Image.Image],
+ metadata: dict,
+ face_image: Optional[Image.Image],
+ body_image: Optional[Image.Image],
+ output_dir: Path
+ ) -> Path:
+ """
+ Save character sheet and all stages to disk.
+
+ Args:
+ character_name: Character name
+ stages: Dictionary of generated images
+ initial_image: Initial input image
+ initial_image_type: Input type
+ costume_description: Costume description
+ costume_image: Costume reference
+ metadata: Generation metadata
+ face_image: Face image (if separate)
+ body_image: Body image (if separate)
+ output_dir: Output directory
+
+ Returns:
+ Path to saved directory
+ """
+ # Create character-specific directory
+ safe_name = sanitize_filename(character_name)
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ char_dir = output_dir / f"{safe_name}_{timestamp}"
+ ensure_directory_exists(char_dir)
+
+ logger.info(f"Saving character sheet to: {char_dir}")
+
+ # Save character sheet
+ sheet_path, _ = save_image(
+ image=stages['character_sheet'],
+ directory=char_dir,
+ base_name=f"{safe_name}_character_sheet",
+ metadata=metadata
+ )
+
+ # Save individual stages
+ for stage_name, image in stages.items():
+ if stage_name != 'character_sheet':
+ save_image(
+ image=image,
+ directory=char_dir / "stages",
+ base_name=f"{safe_name}_{stage_name}",
+ metadata=None
+ )
+
+ # Save input images
+ if initial_image:
+ save_image(
+ image=initial_image,
+ directory=char_dir / "inputs",
+ base_name=f"{safe_name}_initial_{initial_image_type.replace(' ', '_')}",
+ metadata=None
+ )
+
+ if costume_image:
+ save_image(
+ image=costume_image,
+ directory=char_dir / "inputs",
+ base_name=f"{safe_name}_costume_reference",
+ metadata=None
+ )
+
+ if face_image:
+ save_image(
+ image=face_image,
+ directory=char_dir / "inputs",
+ base_name=f"{safe_name}_face",
+ metadata=None
+ )
+
+ if body_image:
+ save_image(
+ image=body_image,
+ directory=char_dir / "inputs",
+ base_name=f"{safe_name}_body",
+ metadata=None
+ )
+
+ logger.info(f"All files saved to: {char_dir}")
+ return char_dir
diff --git a/character_forge_image/services/composition_service.md b/character_forge_image/services/composition_service.md
new file mode 100644
index 0000000000000000000000000000000000000000..12b5f88a85fea788dcf5957cad7ed6fbdc2a6c07
--- /dev/null
+++ b/character_forge_image/services/composition_service.md
@@ -0,0 +1,393 @@
+# composition_service.py
+
+## Purpose
+Business logic for smart multi-image composition. Builds intelligent prompts based on image types, camera angles, shot types, and lighting conditions. Implements Google's best practices for Gemini 2.5 Flash Image multi-image composition.
+
+## Responsibilities
+- Build intelligent composition prompts from user selections
+- Support multiple image types (Subject, Background, Style, etc.)
+- Apply camera angle and lighting best practices
+- Generate compositions with consistent perspective
+- Suggest appropriate aspect ratios for composition types
+- Validate composition inputs
+- Inherit generation capabilities from GenerationService
+
+## Dependencies
+- `services.generation_service.GenerationService` - Base class (inherits generation methods)
+- `models.generation_request.GenerationRequest` - Request dataclass
+- `models.generation_result.GenerationResult` - Result dataclass
+- `utils.logging_utils` - Logging
+- `config.settings.Settings` - Configuration
+- `PIL.Image` - Image handling
+
+## Source
+Extracted from `composition_assistant_addon.py` (Gradio implementation). Refactored to use new architecture and add generation capabilities.
+
+## Public Interface
+
+### `CompositionService(GenerationService)` class
+
+**Inheritance:**
+- Extends `GenerationService` to reuse generation methods
+- Adds composition-specific prompt building
+
+**Class Constants:**
+```python
+IMAGE_TYPES = [
+ "Subject/Character",
+ "Background/Environment",
+ "Style Reference",
+ "Product",
+ "Texture",
+ "Not Used"
+]
+
+SHOT_TYPES = [
+ "close-up shot",
+ "medium shot",
+ "full body shot",
+ "wide shot",
+ "extreme close-up",
+ "establishing shot"
+]
+
+CAMERA_ANGLES = [
+ "eye-level perspective",
+ "low-angle perspective",
+ "high-angle perspective",
+ "bird's-eye view",
+ "Dutch angle (tilted)",
+ "over-the-shoulder"
+]
+
+LIGHTING_OPTIONS = [
+ "Auto (match images)",
+ "natural daylight",
+ "golden hour sunlight",
+ "soft diffused light",
+ "dramatic side lighting",
+ "backlit silhouette",
+ "studio lighting",
+ "moody atmospheric lighting",
+ "neon/artificial lighting"
+]
+```
+
+**Constructor:**
+```python
+def __init__(self, api_key: Optional[str] = None)
+```
+- `api_key`: Optional Gemini API key
+- Initializes parent GenerationService
+
+### Key Methods
+
+#### `build_composition_prompt(image1_type="Subject/Character", image2_type="Background/Environment", image3_type="Not Used", camera_angles=None, lighting="Auto (match images)", shot_type="medium shot", custom_instructions="", is_character_sheet=False) -> str`
+
+Build intelligent composition prompt based on selections.
+
+**Based on Google's Best Practices:**
+- Narrative, descriptive language
+- Camera angles, lens types, lighting
+- Match perspectives and light direction
+- Specific about placement
+
+**Args:**
+- `image1_type`: Type of first image (default: "Subject/Character")
+- `image2_type`: Type of second image (default: "Background/Environment")
+- `image3_type`: Type of third image (default: "Not Used")
+- `camera_angles`: List of selected camera angles (optional)
+- `lighting`: Lighting description (default: "Auto (match images)")
+- `shot_type`: Type of shot (default: "medium shot")
+- `custom_instructions`: Additional instructions (default: "")
+- `is_character_sheet`: Character sheet mode (default: False)
+
+**Returns:**
+- Formatted prompt string
+
+**Usage:**
+```python
+service = CompositionService()
+
+# Subject into background
+prompt = service.build_composition_prompt(
+ image1_type="Subject/Character",
+ image2_type="Background/Environment",
+ camera_angles=["eye-level perspective"],
+ lighting="natural daylight",
+ shot_type="full body shot",
+ custom_instructions="Character is walking forward"
+)
+
+# Style transfer
+prompt = service.build_composition_prompt(
+ image1_type="Subject/Character",
+ image2_type="Style Reference",
+ shot_type="medium shot",
+ custom_instructions="Apply watercolor painting style"
+)
+
+# Character sheet
+prompt = service.build_composition_prompt(
+ image1_type="Subject/Character",
+ image2_type="Style Reference",
+ is_character_sheet=True
+)
+```
+
+#### `compose_images(images, image_types, camera_angles=None, lighting="Auto (match images)", shot_type="medium shot", custom_instructions="", is_character_sheet=False, aspect_ratio="16:9", temperature=0.7, backend=Settings.BACKEND_GEMINI) -> GenerationResult`
+
+Complete composition workflow: build prompt + generate.
+
+**Args:**
+- `images`: List of up to 3 images (None for unused slots)
+- `image_types`: List of image types corresponding to images
+- `camera_angles`: Selected camera angles (optional)
+- `lighting`: Lighting option (default: "Auto (match images)")
+- `shot_type`: Shot type (default: "medium shot")
+- `custom_instructions`: Custom instructions (default: "")
+- `is_character_sheet`: Character sheet mode (default: False)
+- `aspect_ratio`: Output aspect ratio (default: "16:9")
+- `temperature`: Generation temperature (default: 0.7)
+- `backend`: Backend to use (default: Gemini)
+
+**Returns:**
+- `GenerationResult` object
+
+**Usage:**
+```python
+service = CompositionService(api_key="your-key")
+
+# Load images
+subject = Image.open("character.png")
+background = Image.open("forest.png")
+
+# Compose
+result = service.compose_images(
+ images=[subject, background, None],
+ image_types=["Subject/Character", "Background/Environment", "Not Used"],
+ camera_angles=["eye-level perspective", "low-angle perspective"],
+ lighting="soft diffused light",
+ shot_type="full body shot",
+ custom_instructions="Character is exploring the forest",
+ aspect_ratio="16:9",
+ temperature=0.7,
+ backend="Gemini API (Cloud)"
+)
+
+if result.success:
+ result.image.show()
+ print(f"Generated in {result.generation_time:.1f}s")
+else:
+ print(f"Error: {result.message}")
+```
+
+#### `get_suggested_aspect_ratio(shot_type, is_character_sheet=False) -> str`
+
+Suggest aspect ratio based on composition type.
+
+**Logic:**
+- Character sheet → "16:9" (wide for multi-view)
+- Full body/wide shots → "16:9" (landscape)
+- Close-ups → "3:4" (portrait)
+- Balanced compositions → "1:1" (square)
+
+**Args:**
+- `shot_type`: Shot type
+- `is_character_sheet`: Character sheet mode (default: False)
+
+**Returns:**
+- Suggested aspect ratio string
+
+**Usage:**
+```python
+ratio = service.get_suggested_aspect_ratio(
+ shot_type="full body shot",
+ is_character_sheet=False
+) # Returns "16:9"
+
+ratio = service.get_suggested_aspect_ratio(
+ shot_type="close-up shot"
+) # Returns "3:4"
+```
+
+#### `validate_composition_inputs(images, image_types) -> tuple[bool, Optional[str]]`
+
+Validate composition inputs.
+
+**Checks:**
+- At least one image provided
+- Image types length matches images
+- Valid image types
+
+**Args:**
+- `images`: List of images
+- `image_types`: List of image types
+
+**Returns:**
+- Tuple of `(is_valid: bool, error_message: Optional[str])`
+
+**Usage:**
+```python
+is_valid, error = service.validate_composition_inputs(
+ images=[subject, background, None],
+ image_types=["Subject/Character", "Background/Environment", "Not Used"]
+)
+if not is_valid:
+ st.error(f"Invalid input: {error}")
+```
+
+## Prompt Building Logic
+
+### Subject + Background
+```python
+image1_type="Subject/Character"
+image2_type="Background/Environment"
+
+# Generates:
+"A photorealistic full body shot placing the subject from image one
+into the environment from image two. Shot from a eye-level perspective.
+The scene is illuminated by natural daylight, matching the lighting
+direction and quality across all elements. Maintain consistent perspective,
+scale, and depth. Create a natural, seamless composition with realistic
+shadows and reflections. Photorealistic, high quality, professional photography."
+```
+
+### Style Transfer
+```python
+image1_type="Subject/Character"
+image2_type="Style Reference"
+
+# Generates:
+"Transform the subject from image one into the artistic style shown
+in image two. Maintain consistent perspective, scale, and depth.
+Create a natural, seamless composition with realistic shadows and
+reflections. Photorealistic, high quality, professional photography."
+```
+
+### Character Sheet
+```python
+image1_type="Subject/Character"
+is_character_sheet=True
+
+# Generates:
+"Create a character sheet design with multiple views and poses of
+the same character. Based on the character from image one, Include
+front view, side view, back view, and detail shots. Maintain consistent
+character design, colors, and proportions across all views. Create a
+natural, seamless composition with realistic shadows and reflections.
+Photorealistic, high quality, professional photography."
+```
+
+## Best Practices Applied
+
+Based on Google's Gemini 2.5 Flash Image documentation:
+
+1. **Narrative Language**: Use descriptive, story-like prompts
+2. **Camera Specifics**: Include angle, perspective, lens type
+3. **Lighting Details**: Specify lighting type and direction
+4. **Perspective Matching**: Explicitly request consistent perspective
+5. **Realism Keywords**: Include "photorealistic", "professional photography"
+6. **Element Ordering**: Images before text in API calls
+
+## Usage Examples
+
+### Example 1: Character in Environment
+```python
+service = CompositionService(api_key="your-key")
+
+character = Image.open("hero.png")
+environment = Image.open("castle.png")
+
+result = service.compose_images(
+ images=[character, environment],
+ image_types=["Subject/Character", "Background/Environment"],
+ camera_angles=["low-angle perspective"],
+ lighting="dramatic side lighting",
+ shot_type="full body shot",
+ custom_instructions="Hero standing heroically in front of castle",
+ aspect_ratio="16:9"
+)
+```
+
+### Example 2: Product with Texture
+```python
+product = Image.open("watch.png")
+texture = Image.open("marble.png")
+
+result = service.compose_images(
+ images=[product, texture],
+ image_types=["Product", "Texture"],
+ camera_angles=["high-angle perspective"],
+ lighting="studio lighting",
+ shot_type="close-up shot",
+ aspect_ratio="1:1"
+)
+```
+
+### Example 3: Three-Image Composition
+```python
+character = Image.open("character.png")
+background = Image.open("background.png")
+style_ref = Image.open("style.png")
+
+result = service.compose_images(
+ images=[character, background, style_ref],
+ image_types=["Subject/Character", "Background/Environment", "Style Reference"],
+ camera_angles=["eye-level perspective"],
+ lighting="natural daylight",
+ shot_type="medium shot",
+ custom_instructions="Apply style from third image to the composition",
+ aspect_ratio="16:9"
+)
+```
+
+### Example 4: Auto Aspect Ratio
+```python
+# Get suggested aspect ratio
+aspect_ratio = service.get_suggested_aspect_ratio(
+ shot_type="full body shot"
+)
+
+result = service.compose_images(
+ images=[character, background],
+ image_types=["Subject/Character", "Background/Environment"],
+ shot_type="full body shot",
+ aspect_ratio=aspect_ratio # Uses "16:9"
+)
+```
+
+## Error Handling
+
+All methods return consistent error format:
+```python
+try:
+ result = service.compose_images(...)
+ if not result.success:
+ print(f"Composition failed: {result.message}")
+except Exception as e:
+ logger.exception(f"Composition error: {e}")
+ return GenerationResult.error_result(f"Composition error: {str(e)}")
+```
+
+## Inheritance from GenerationService
+
+Reuses these methods:
+- `router.generate()` - Backend generation
+- `check_backend_availability()` - Backend health
+- `get_all_backend_status()` - All backend status
+- All BackendRouter functionality
+
+Adds:
+- `build_composition_prompt()` - Intelligent prompt building
+- `compose_images()` - Complete composition workflow
+- `get_suggested_aspect_ratio()` - Aspect ratio suggestions
+- `validate_composition_inputs()` - Input validation
+
+## Related Files
+- `services/generation_service.py` - Parent class
+- `composition_assistant_addon.py` (old) - Original Gradio implementation source
+- `core/backend_router.py` - Backend routing
+- `models/generation_request.py` - Request structure
+- `models/generation_result.py` - Result structure
+- `ui/pages/02_🎬_Composition_Assistant.py` - UI that uses this service
diff --git a/character_forge_image/services/composition_service.py b/character_forge_image/services/composition_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bd962f5d695f3abbd6f1e2e4987b17629f34352
--- /dev/null
+++ b/character_forge_image/services/composition_service.py
@@ -0,0 +1,319 @@
+"""
+Composition Service
+===================
+
+Business logic for smart multi-image composition.
+Builds intelligent prompts based on image types, camera angles, and lighting.
+"""
+
+from typing import Optional, List
+from PIL import Image
+
+from services.generation_service import GenerationService
+from models.generation_request import GenerationRequest
+from models.generation_result import GenerationResult
+from utils.logging_utils import get_logger
+from config.settings import Settings
+
+
+logger = get_logger(__name__)
+
+
+class CompositionService(GenerationService):
+ """
+ Service for intelligent multi-image composition.
+
+ Builds prompts based on:
+ - Image types (Subject, Background, Style, etc.)
+ - Camera angles and shot types
+ - Lighting conditions
+ - Custom instructions
+
+ Inherits from GenerationService for generation capabilities.
+ """
+
+ # Image type options
+ IMAGE_TYPES = [
+ "Subject/Character",
+ "Background/Environment",
+ "Style Reference",
+ "Product",
+ "Texture",
+ "Not Used"
+ ]
+
+ # Shot type options
+ SHOT_TYPES = [
+ "close-up shot",
+ "medium shot",
+ "full body shot",
+ "wide shot",
+ "extreme close-up",
+ "establishing shot"
+ ]
+
+ # Camera angle options
+ CAMERA_ANGLES = [
+ "eye-level perspective",
+ "low-angle perspective",
+ "high-angle perspective",
+ "bird's-eye view",
+ "Dutch angle (tilted)",
+ "over-the-shoulder"
+ ]
+
+ # Lighting options
+ LIGHTING_OPTIONS = [
+ "Auto (match images)",
+ "natural daylight",
+ "golden hour sunlight",
+ "soft diffused light",
+ "dramatic side lighting",
+ "backlit silhouette",
+ "studio lighting",
+ "moody atmospheric lighting",
+ "neon/artificial lighting"
+ ]
+
+ def __init__(self, api_key: Optional[str] = None):
+ """
+ Initialize composition service.
+
+ Args:
+ api_key: Optional Gemini API key
+ """
+ super().__init__(api_key=api_key)
+ logger.info("CompositionService initialized")
+
+ def build_composition_prompt(
+ self,
+ image1_type: str = "Subject/Character",
+ image2_type: str = "Background/Environment",
+ image3_type: str = "Not Used",
+ camera_angles: Optional[List[str]] = None,
+ lighting: str = "Auto (match images)",
+ shot_type: str = "medium shot",
+ custom_instructions: str = "",
+ is_character_sheet: bool = False
+ ) -> str:
+ """
+ Build intelligent composition prompt.
+
+ Based on Google's best practices for Gemini 2.5 Flash Image:
+ - Narrative, descriptive language
+ - Camera angles, lens types, lighting
+ - Match perspectives and light direction
+ - Specific about placement
+
+ Args:
+ image1_type: Type of first image
+ image2_type: Type of second image
+ image3_type: Type of third image
+ camera_angles: List of selected camera angles
+ lighting: Lighting description
+ shot_type: Type of shot
+ custom_instructions: Additional instructions
+ is_character_sheet: Whether to generate character sheet
+
+ Returns:
+ Formatted prompt string
+ """
+ parts = []
+
+ # Character sheet specific handling
+ if is_character_sheet:
+ parts.append("Create a character sheet design with multiple views and poses of the same character. ")
+ if image1_type == "Subject/Character":
+ parts.append("Based on the character from image one, ")
+ parts.append("Include front view, side view, back view, and detail shots. ")
+ parts.append("Maintain consistent character design, colors, and proportions across all views. ")
+ if image2_type in ["Background/Environment", "Style Reference"]:
+ parts.append(f"Apply the {image2_type.lower()} from image two as context. ")
+ else:
+ # Determine main action based on image types
+ if image1_type == "Subject/Character" and image2_type == "Background/Environment":
+ parts.append(f"A photorealistic {shot_type} ")
+ parts.append(f"placing the subject from image one into the environment from image two. ")
+
+ elif image1_type == "Subject/Character" and image2_type == "Style Reference":
+ parts.append(f"Transform the subject from image one ")
+ parts.append(f"into the artistic style shown in image two. ")
+
+ elif image1_type == "Background/Environment" and image2_type == "Subject/Character":
+ parts.append(f"A photorealistic {shot_type} ")
+ parts.append(f"integrating the subject from image two into the environment from image one. ")
+
+ else:
+ # Generic multi-image composition
+ parts.append("Combine ")
+ if image1_type != "Not Used":
+ parts.append(f"the {image1_type.lower()} from image one")
+ if image2_type != "Not Used":
+ parts.append(f" with the {image2_type.lower()} from image two")
+ if image3_type != "Not Used":
+ parts.append(f" and the {image3_type.lower()} from image three")
+ parts.append(". ")
+
+ # Add camera angle specifics (not for character sheets)
+ if camera_angles and not is_character_sheet:
+ angles_text = ", ".join(camera_angles)
+ parts.append(f"Shot from a {angles_text}. ")
+
+ # Add lighting
+ if lighting and lighting != "Auto (match images)":
+ parts.append(f"The scene is illuminated by {lighting}, ")
+ parts.append("matching the lighting direction and quality across all elements. ")
+
+ # Add perspective matching (best practice)
+ if not is_character_sheet:
+ parts.append("Maintain consistent perspective, scale, and depth. ")
+
+ # Add realism keywords
+ parts.append("Create a natural, seamless composition with realistic shadows and reflections. ")
+ parts.append("Photorealistic, high quality, professional photography.")
+
+ # Add custom instructions
+ if custom_instructions:
+ parts.append(f" {custom_instructions}")
+
+ return "".join(parts)
+
+ def compose_images(
+ self,
+ images: List[Optional[Image.Image]],
+ image_types: List[str],
+ camera_angles: Optional[List[str]] = None,
+ lighting: str = "Auto (match images)",
+ shot_type: str = "medium shot",
+ custom_instructions: str = "",
+ is_character_sheet: bool = False,
+ aspect_ratio: str = "16:9",
+ temperature: float = 0.7,
+ backend: str = Settings.BACKEND_GEMINI
+ ) -> GenerationResult:
+ """
+ Compose images using intelligent prompt generation.
+
+ Args:
+ images: List of up to 3 images (None for unused slots)
+ image_types: List of image types corresponding to images
+ camera_angles: Selected camera angles
+ lighting: Lighting option
+ shot_type: Shot type
+ custom_instructions: Custom instructions
+ is_character_sheet: Character sheet mode
+ aspect_ratio: Output aspect ratio
+ temperature: Generation temperature
+ backend: Backend to use
+
+ Returns:
+ GenerationResult object
+ """
+ try:
+ # Filter out None images and corresponding types
+ valid_images = []
+ valid_types = []
+ for i, img in enumerate(images):
+ if img is not None and i < len(image_types):
+ valid_images.append(img)
+ valid_types.append(image_types[i])
+
+ if not valid_images:
+ logger.error("No valid images provided")
+ return GenerationResult.error_result("No images provided for composition")
+
+ # Pad types to 3 elements
+ while len(valid_types) < 3:
+ valid_types.append("Not Used")
+
+ # Build prompt
+ prompt = self.build_composition_prompt(
+ image1_type=valid_types[0],
+ image2_type=valid_types[1],
+ image3_type=valid_types[2],
+ camera_angles=camera_angles or [],
+ lighting=lighting,
+ shot_type=shot_type,
+ custom_instructions=custom_instructions,
+ is_character_sheet=is_character_sheet
+ )
+
+ logger.info(f"Composition prompt: {prompt[:200]}...")
+
+ # Create request
+ request = GenerationRequest(
+ prompt=prompt,
+ backend=backend,
+ aspect_ratio=aspect_ratio,
+ temperature=temperature,
+ input_images=valid_images
+ )
+
+ # Generate
+ result = self.router.generate(request)
+
+ if result.success:
+ logger.info("Composition generated successfully")
+ else:
+ logger.warning(f"Composition failed: {result.message}")
+
+ return result
+
+ except Exception as e:
+ logger.exception(f"Composition error: {e}")
+ return GenerationResult.error_result(f"Composition error: {str(e)}")
+
+ def get_suggested_aspect_ratio(
+ self,
+ shot_type: str,
+ is_character_sheet: bool = False
+ ) -> str:
+ """
+ Suggest aspect ratio based on composition type.
+
+ Args:
+ shot_type: Shot type
+ is_character_sheet: Character sheet mode
+
+ Returns:
+ Suggested aspect ratio string
+ """
+ if is_character_sheet:
+ return "16:9" # Wide format for multi-view layout
+
+ if shot_type in ["full body shot", "establishing shot", "wide shot"]:
+ return "16:9" # Landscape for wide shots
+ elif shot_type in ["close-up shot", "extreme close-up"]:
+ return "3:4" # Portrait for closeups
+ else:
+ return "1:1" # Square for balanced compositions
+
+ def validate_composition_inputs(
+ self,
+ images: List[Optional[Image.Image]],
+ image_types: List[str]
+ ) -> tuple[bool, Optional[str]]:
+ """
+ Validate composition inputs.
+
+ Args:
+ images: List of images
+ image_types: List of image types
+
+ Returns:
+ Tuple of (is_valid: bool, error_message: Optional[str])
+ """
+ # Check at least one image provided
+ if not any(img is not None for img in images):
+ return False, "At least one image is required"
+
+ # Check image types length matches
+ if len(image_types) < len(images):
+ return False, "Image types must be specified for all images"
+
+ # Check for valid image types
+ for img_type in image_types:
+ if img_type not in self.IMAGE_TYPES:
+ return False, f"Invalid image type: {img_type}"
+
+ return True, None
diff --git a/character_forge_image/services/generation_service.md b/character_forge_image/services/generation_service.md
new file mode 100644
index 0000000000000000000000000000000000000000..79944f504ec643e4f3e3ba6f53051582f0c40799
--- /dev/null
+++ b/character_forge_image/services/generation_service.md
@@ -0,0 +1,281 @@
+# generation_service.py
+
+## Purpose
+High-level orchestration service for image generation workflows. Coordinates backend routing, file saving, metadata management, and history tracking. Provides clean interface for generation operations throughout the application.
+
+## Responsibilities
+- Orchestrate complete generation workflows (generate → save → track)
+- Route requests to appropriate backends via BackendRouter
+- Manage file saving with metadata
+- Validate generation requests before execution
+- Support batch generation operations
+- Check backend availability and health
+- Provide convenience methods with built-in validation
+
+## Dependencies
+- `core.BackendRouter` - Routes to Gemini/OmniGen2 backends
+- `models.GenerationRequest` - Request dataclass
+- `models.GenerationResult` - Result dataclass
+- `utils.file_utils` - File I/O operations (save_image, create_generation_metadata, ensure_directory_exists)
+- `utils.validation` - Input validation (validate_generation_request)
+- `utils.logging_utils` - Logging
+- `config.settings.Settings` - Configuration
+
+## Source
+Original design - creates abstraction layer for generation operations.
+
+## Public Interface
+
+### `GenerationService` class
+
+**Constructor:**
+```python
+def __init__(self, api_key: Optional[str] = None)
+```
+- `api_key`: Optional Gemini API key (defaults to Settings)
+- Initializes BackendRouter for backend communication
+
+### Key Methods
+
+#### `generate_and_save(request, output_dir, base_filename, save_metadata=True) -> GenerationResult`
+Complete generation workflow with automatic file saving.
+
+**Workflow:**
+1. Ensure output directory exists
+2. Generate image via backend
+3. Save image to disk
+4. Save metadata JSON (optional)
+5. Update result with saved_path
+
+**Args:**
+- `request`: GenerationRequest object
+- `output_dir`: Directory to save image
+- `base_filename`: Base name for output file
+- `save_metadata`: Whether to save metadata JSON (default: True)
+
+**Returns:**
+- `GenerationResult` with `saved_path` populated
+
+**Usage:**
+```python
+service = GenerationService(api_key="your-key")
+request = GenerationRequest(
+ prompt="A magical forest",
+ backend="Gemini API (Cloud)",
+ aspect_ratio="16:9",
+ temperature=0.7
+)
+result = service.generate_and_save(
+ request=request,
+ output_dir=Path("outputs/standard"),
+ base_filename="magical_forest"
+)
+if result.success:
+ print(f"Saved to: {result.saved_path}")
+```
+
+#### `generate_only(request) -> GenerationResult`
+Generate image without saving to disk.
+
+**Use Cases:**
+- Previews
+- Temporary generations
+- When caller handles saving
+
+**Args:**
+- `request`: GenerationRequest object
+
+**Returns:**
+- `GenerationResult`
+
+**Usage:**
+```python
+result = service.generate_only(request)
+if result.success:
+ st.image(result.image)
+```
+
+#### `batch_generate(prompts, backend, aspect_ratio, temperature, output_dir, base_filename_template="batch_{index}") -> list[GenerationResult]`
+Generate multiple images from prompt list.
+
+**Args:**
+- `prompts`: List of prompts to generate
+- `backend`: Backend to use for all
+- `aspect_ratio`: Aspect ratio for all
+- `temperature`: Temperature for all
+- `output_dir`: Output directory
+- `base_filename_template`: Template with `{index}` placeholder
+
+**Returns:**
+- List of GenerationResult objects
+
+**Usage:**
+```python
+prompts = [
+ "A serene mountain landscape",
+ "A bustling city street",
+ "A quiet forest path"
+]
+results = service.batch_generate(
+ prompts=prompts,
+ backend="Gemini API (Cloud)",
+ aspect_ratio="16:9",
+ temperature=0.7,
+ output_dir=Path("outputs/batch"),
+ base_filename_template="scene_{index}"
+)
+success_count = sum(r.success for r in results)
+print(f"{success_count}/{len(results)} generated successfully")
+```
+
+#### `check_backend_availability(backend) -> tuple[bool, str]`
+Check if backend is available.
+
+**Args:**
+- `backend`: Backend name to check
+
+**Returns:**
+- Tuple of `(is_available: bool, status_message: str)`
+
+**Usage:**
+```python
+is_available, message = service.check_backend_availability("Gemini API (Cloud)")
+if is_available:
+ st.success(f"✅ {message}")
+else:
+ st.error(f"❌ {message}")
+```
+
+#### `get_all_backend_status() -> Dict[str, Dict[str, Any]]`
+Get status of all configured backends.
+
+**Returns:**
+- Dictionary mapping backend name to status dict with `healthy` and `message` keys
+
+**Usage:**
+```python
+status = service.get_all_backend_status()
+for backend, info in status.items():
+ if info['healthy']:
+ st.success(f"{backend}: {info['message']}")
+ else:
+ st.warning(f"{backend}: {info['message']}")
+```
+
+#### `validate_and_generate(prompt, backend, aspect_ratio, temperature, input_images=None, output_dir=None, base_filename=None) -> GenerationResult`
+Convenience method with built-in validation.
+
+**Features:**
+- Validates all inputs before generation
+- Auto-generates filename from prompt if not provided
+- Optionally saves to disk
+- Returns descriptive error messages
+
+**Args:**
+- `prompt`: Generation prompt
+- `backend`: Backend name
+- `aspect_ratio`: Aspect ratio
+- `temperature`: Temperature
+- `input_images`: Optional input images
+- `output_dir`: Optional output directory (if None, no save)
+- `base_filename`: Optional base filename (if None, use sanitized prompt)
+
+**Returns:**
+- `GenerationResult`
+
+**Usage:**
+```python
+result = service.validate_and_generate(
+ prompt="A fantasy castle",
+ backend="Gemini API (Cloud)",
+ aspect_ratio="16:9",
+ temperature=0.7,
+ output_dir=Path("outputs/standard")
+)
+```
+
+## Workflow Diagrams
+
+### `generate_and_save` Workflow
+```
+1. ensure_directory_exists(output_dir)
+ ↓
+2. router.generate(request)
+ ↓
+3. [if success] create_generation_metadata(...)
+ ↓
+4. save_image(image, directory, base_name, metadata)
+ ↓
+5. Update result.saved_path
+ ↓
+6. Return result
+```
+
+### `validate_and_generate` Workflow
+```
+1. validate_generation_request(...)
+ ↓
+2. [if invalid] Return error result
+ ↓
+3. Create GenerationRequest
+ ↓
+4. [if output_dir] generate_and_save()
+ [else] generate_only()
+ ↓
+5. Return result
+```
+
+## Error Handling
+
+All methods catch exceptions and return error results:
+```python
+except Exception as e:
+ logger.error(f"Generation and save failed: {e}", exc_info=True)
+ return GenerationResult.error_result(
+ message=f"Generation service error: {str(e)}"
+ )
+```
+
+This ensures:
+- No uncaught exceptions
+- Consistent error format
+- Full error logging with stack traces
+- User-friendly error messages
+
+## Usage in Application
+
+### From UI Components:
+```python
+from services import GenerationService
+
+service = GenerationService()
+result = service.generate_and_save(
+ request=GenerationRequest(...),
+ output_dir=st.session_state.output_dir,
+ base_filename=sanitize_filename(prompt)
+)
+
+if result.success:
+ st.image(result.image)
+ st.success(f"Saved to: {result.saved_path}")
+else:
+ st.error(result.message)
+```
+
+### From Other Services:
+```python
+class CharacterForgeService:
+ def __init__(self, api_key=None):
+ self.router = BackendRouter(api_key) # Direct router access
+ # Could also use: self.gen_service = GenerationService(api_key)
+```
+
+## Related Files
+- `core/backend_router.py` - Backend routing (used internally)
+- `models/generation_request.py` - Request structure
+- `models/generation_result.py` - Result structure
+- `utils/file_utils.py` - File operations
+- `utils/validation.py` - Input validation
+- `services/character_forge_service.py` - Extends for character generation
+- `services/composition_service.py` - Extends for composition
+- `ui/` - UI components use this service
diff --git a/character_forge_image/services/generation_service.py b/character_forge_image/services/generation_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..a24c3e7024476a5c1b162118bb83700bf869f53c
--- /dev/null
+++ b/character_forge_image/services/generation_service.py
@@ -0,0 +1,264 @@
+"""
+Generation Service
+==================
+
+High-level service for orchestrating image generation workflows.
+Coordinates backend routing, file saving, metadata management, and history tracking.
+"""
+
+from pathlib import Path
+from typing import Optional, Dict, Any
+from PIL import Image
+
+from core import BackendRouter
+from models.generation_request import GenerationRequest
+from models.generation_result import GenerationResult
+from utils.file_utils import (
+ save_image,
+ create_generation_metadata,
+ ensure_directory_exists
+)
+from utils.logging_utils import get_logger
+from config.settings import Settings
+
+
+logger = get_logger(__name__)
+
+
+class GenerationService:
+ """
+ High-level service for image generation operations.
+
+ Orchestrates the complete generation workflow:
+ 1. Validate request
+ 2. Route to backend
+ 3. Save results
+ 4. Update history
+ 5. Return result
+ """
+
+ def __init__(self, api_key: Optional[str] = None):
+ """
+ Initialize generation service.
+
+ Args:
+ api_key: Optional Gemini API key (defaults to Settings)
+ """
+ self.router = BackendRouter(api_key=api_key)
+ logger.info("GenerationService initialized")
+
+ def generate_and_save(
+ self,
+ request: GenerationRequest,
+ output_dir: Path,
+ base_filename: str,
+ save_metadata: bool = True
+ ) -> GenerationResult:
+ """
+ Generate image and save to disk.
+
+ Complete workflow:
+ 1. Generate image via backend
+ 2. Save image to output directory
+ 3. Save metadata JSON (optional)
+ 4. Update result with saved path
+
+ Args:
+ request: GenerationRequest object
+ output_dir: Directory to save image
+ base_filename: Base name for output file
+ save_metadata: Whether to save metadata JSON
+
+ Returns:
+ GenerationResult with saved_path populated
+ """
+ try:
+ logger.info(f"Starting generation: {request.prompt[:50]}...")
+
+ # Ensure output directory exists
+ ensure_directory_exists(output_dir)
+
+ # Generate image
+ result = self.router.generate(request)
+
+ if not result.success:
+ logger.warning(f"Generation failed: {result.message}")
+ return result
+
+ # Save image and metadata
+ metadata = None
+ if save_metadata:
+ metadata = create_generation_metadata(
+ prompt=request.prompt,
+ backend=request.backend,
+ aspect_ratio=request.aspect_ratio,
+ temperature=request.temperature,
+ generation_time=result.generation_time,
+ **request.metadata
+ )
+
+ image_path, metadata_path = save_image(
+ image=result.image,
+ directory=output_dir,
+ base_name=base_filename,
+ metadata=metadata
+ )
+
+ # Update result with saved paths
+ result.saved_path = image_path
+ if metadata_path:
+ result.metadata['metadata_path'] = metadata_path
+
+ logger.info(f"Image saved: {image_path}")
+ return result
+
+ except Exception as e:
+ logger.error(f"Generation and save failed: {e}", exc_info=True)
+ return GenerationResult.error_result(
+ message=f"Generation service error: {str(e)}"
+ )
+
+ def generate_only(self, request: GenerationRequest) -> GenerationResult:
+ """
+ Generate image without saving to disk.
+
+ Useful for previews or temporary generations.
+
+ Args:
+ request: GenerationRequest object
+
+ Returns:
+ GenerationResult
+ """
+ logger.info(f"Generating (no save): {request.prompt[:50]}...")
+ return self.router.generate(request)
+
+ def batch_generate(
+ self,
+ prompts: list[str],
+ backend: str,
+ aspect_ratio: str,
+ temperature: float,
+ output_dir: Path,
+ base_filename_template: str = "batch_{index}"
+ ) -> list[GenerationResult]:
+ """
+ Generate multiple images from prompt list.
+
+ Args:
+ prompts: List of prompts to generate
+ backend: Backend to use
+ aspect_ratio: Aspect ratio for all images
+ temperature: Temperature for all images
+ output_dir: Output directory
+ base_filename_template: Template for filenames (use {index} placeholder)
+
+ Returns:
+ List of GenerationResult objects
+ """
+ logger.info(f"Starting batch generation: {len(prompts)} prompts")
+ results = []
+
+ for i, prompt in enumerate(prompts):
+ request = GenerationRequest(
+ prompt=prompt,
+ backend=backend,
+ aspect_ratio=aspect_ratio,
+ temperature=temperature
+ )
+
+ base_filename = base_filename_template.format(index=i+1)
+ result = self.generate_and_save(
+ request=request,
+ output_dir=output_dir,
+ base_filename=base_filename
+ )
+ results.append(result)
+
+ logger.info(f"Batch {i+1}/{len(prompts)}: {'Success' if result.success else 'Failed'}")
+
+ logger.info(f"Batch complete: {sum(r.success for r in results)}/{len(results)} successful")
+ return results
+
+ def check_backend_availability(self, backend: str) -> tuple[bool, str]:
+ """
+ Check if backend is available.
+
+ Args:
+ backend: Backend name to check
+
+ Returns:
+ Tuple of (is_available, status_message)
+ """
+ return self.router.check_backend_health(backend)
+
+ def get_all_backend_status(self) -> Dict[str, Dict[str, Any]]:
+ """
+ Get status of all configured backends.
+
+ Returns:
+ Dictionary mapping backend name to status info
+ """
+ return self.router.get_all_backend_status()
+
+ def validate_and_generate(
+ self,
+ prompt: str,
+ backend: str,
+ aspect_ratio: str,
+ temperature: float,
+ input_images: Optional[list[Image.Image]] = None,
+ output_dir: Optional[Path] = None,
+ base_filename: Optional[str] = None
+ ) -> GenerationResult:
+ """
+ Convenience method with built-in validation.
+
+ Args:
+ prompt: Generation prompt
+ backend: Backend name
+ aspect_ratio: Aspect ratio
+ temperature: Temperature
+ input_images: Optional input images
+ output_dir: Optional output directory (if None, no save)
+ base_filename: Optional base filename (if None, use sanitized prompt)
+
+ Returns:
+ GenerationResult
+ """
+ from utils.validation import validate_generation_request
+ from utils.file_utils import sanitize_filename
+
+ # Validate request
+ is_valid, error = validate_generation_request(
+ prompt=prompt,
+ backend=backend,
+ aspect_ratio=aspect_ratio,
+ temperature=temperature,
+ input_images=input_images
+ )
+
+ if not is_valid:
+ logger.error(f"Validation failed: {error}")
+ return GenerationResult.error_result(message=f"Validation error: {error}")
+
+ # Build request
+ request = GenerationRequest(
+ prompt=prompt,
+ backend=backend,
+ aspect_ratio=aspect_ratio,
+ temperature=temperature,
+ input_images=input_images or []
+ )
+
+ # Generate with or without save
+ if output_dir is None:
+ return self.generate_only(request)
+ else:
+ if base_filename is None:
+ base_filename = sanitize_filename(prompt[:50])
+ return self.generate_and_save(
+ request=request,
+ output_dir=output_dir,
+ base_filename=base_filename
+ )
diff --git a/character_forge_image/services/wardrobe_service.md b/character_forge_image/services/wardrobe_service.md
new file mode 100644
index 0000000000000000000000000000000000000000..d40340a468d69ce600ffc2eb13206112c1c6b649
--- /dev/null
+++ b/character_forge_image/services/wardrobe_service.md
@@ -0,0 +1,292 @@
+# wardrobe_service.py
+
+## Purpose
+Business logic for wardrobe change generation. Modifies existing character sheets with new costumes while maintaining character consistency. Implements 3-step process to ensure costume consistency across all views.
+
+## Responsibilities
+- Change costumes on existing character sheets
+- Extract views from existing sheets
+- Generate new costume variations maintaining character identity
+- Ensure costume consistency across all views
+- Save wardrobe change results with comparisons
+- Inherit character generation capabilities from CharacterForgeService
+
+## Dependencies
+- `services.character_forge_service.CharacterForgeService` - Base class (inherits generation methods)
+- `utils.file_utils` - File operations
+- `utils.logging_utils` - Logging
+- `config.settings.Settings` - Configuration
+- `PIL.Image` - Image manipulation
+- `time` - Rate limiting
+
+## Source
+Extracted from `character_forge.py` lines 1739-2067 (Gradio implementation). Refactored to use new architecture.
+
+## Public Interface
+
+### `WardrobeService(CharacterForgeService)` class
+
+**Inheritance:**
+- Extends `CharacterForgeService` to reuse `_generate_stage()`, `composite_character_sheet()`, `extract_views_from_sheet()`
+- Adds wardrobe-specific workflow
+
+**Constructor:**
+```python
+def __init__(self, api_key: Optional[str] = None)
+```
+- `api_key`: Optional Gemini API key
+- Initializes parent CharacterForgeService
+
+### Key Methods
+
+#### `wardrobe_change(character_sheet, character_name, new_costume_description="", new_costume_image=None, backend=Settings.BACKEND_GEMINI, progress_callback=None, output_dir=None) -> Tuple[Optional[Image], str, dict]`
+
+Main entry point for wardrobe change.
+
+**3-Step Process:**
+1. Generate new full body with new costume from frontal portrait
+2. Create definitive face closeup (new costume + exact facial features)
+3. Generate all other views from steps 1 & 2
+
+**Why 3 Steps?**
+This ensures costume is defined ONCE and all views derive from that single source, eliminating costume variations.
+
+**Args:**
+- `character_sheet`: Existing character sheet to modify
+- `character_name`: Name for new wardrobe variant
+- `new_costume_description`: Text description of new costume
+- `new_costume_image`: Optional costume reference
+- `backend`: Backend to use
+- `progress_callback`: Optional callback(stage: int, message: str)
+- `output_dir`: Optional output directory (defaults to Settings.WARDROBE_CHANGES_DIR)
+
+**Returns:**
+- Tuple of `(new_character_sheet: Image, status_message: str, metadata: dict)`
+
+**Usage:**
+```python
+service = WardrobeService(api_key="your-key")
+
+# Load existing character sheet
+original_sheet = Image.open("hero_character_sheet.png")
+
+# Change costume
+new_sheet, message, metadata = service.wardrobe_change(
+ character_sheet=original_sheet,
+ character_name="Hero_Casual",
+ new_costume_description="casual modern clothing, jeans and t-shirt",
+ backend="Gemini API (Cloud)",
+ progress_callback=lambda stage, msg: print(f"[{stage}] {msg}"),
+ output_dir=Path("outputs/wardrobe_changes")
+)
+
+if new_sheet:
+ new_sheet.show()
+ print(f"Success: {message}")
+ print(f"Saved to: {metadata.get('saved_to')}")
+```
+
+## Private Methods
+
+### `_save_wardrobe_change(...) -> Path`
+
+Save wardrobe change results to disk.
+
+**Saves:**
+- New character sheet (with metadata JSON)
+- Original character sheet (for comparison)
+- All new views (in `new_views/` subdirectory)
+- Costume reference (if provided, in `inputs/`)
+
+**Directory Structure:**
+```
+output_dir/
+└── {character_name}_wardrobe_{timestamp}/
+ ├── {character_name}_new_character_sheet.png
+ ├── {character_name}_new_character_sheet.json
+ ├── {character_name}_original_character_sheet.png # For comparison
+ ├── new_views/
+ │ ├── {character_name}_front_portrait.png
+ │ ├── {character_name}_side_portrait.png
+ │ ├── {character_name}_front_body.png
+ │ ├── {character_name}_side_body.png
+ │ └── {character_name}_rear_body.png
+ └── inputs/
+ └── {character_name}_costume_reference.png
+```
+
+**Returns:**
+- Path to saved directory
+
+## Wardrobe Change Pipeline
+
+```
+Input: Existing character sheet + New costume description/reference
+ ↓
+Step 0: Extract views from original sheet
+ - front_portrait, side_portrait, front_body, side_body, rear_body
+ ↓
+STEP 1: Generate new full body with new costume
+ Input: [original front_portrait, original front_body, costume_ref]
+ Prompt: "Generate full body of this exact character NOW WEARING {costume}..."
+ Output: new_front_body
+ ↓
+STEP 2: Create definitive face closeup
+ Input: [new_front_body (costume source), original_front_portrait (face source)]
+ Prompt: "Extract costume from first, facial details from second..."
+ Output: new_front_portrait
+ Purpose: Merge new costume with exact facial features
+ ↓
+STEP 3: Generate all other views
+ ↓
+ STEP 3a: Side profile portrait
+ Input: [new_front_portrait, new_front_body]
+ Output: new_side_portrait
+ ↓
+ STEP 3b: Side profile full body
+ Input: [new_side_portrait, new_front_portrait, new_front_body]
+ Output: new_side_body
+ ↓
+ STEP 3c: Rear view
+ Input: [new_front_portrait, new_side_portrait, new_front_body]
+ Output: new_rear_body
+ ↓
+ STEP 3d: Composite new character sheet
+ Input: All new views
+ Output: new_character_sheet
+ ↓
+Output: New character sheet with costume change
+```
+
+## Why This Approach?
+
+### Problem with Naive Approach:
+If we simply regenerate all views with "wearing new costume", each generation might interpret the costume differently, causing inconsistency across views.
+
+### Solution - 3-Step Process:
+1. **Define costume once**: Generate single authoritative full body with new costume
+2. **Preserve face**: Merge new costume with exact facial features from original
+3. **Propagate consistently**: All subsequent views reference the same costume definition
+
+### Benefits:
+- Costume consistency across all views
+- Character identity preserved
+- Fewer costume variations
+- Higher quality results
+
+## Error Handling
+
+Each step can fail independently:
+```python
+if new_front_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Step 1 failed: {status}", {}
+```
+
+All exceptions caught at top level:
+```python
+except Exception as e:
+ logger.exception(f"Wardrobe change failed: {e}")
+ return None, f"Wardrobe change error: {str(e)}", {}
+```
+
+## Progress Tracking
+
+Optional progress callback for UI updates:
+```python
+def progress_callback(stage: int, message: str):
+ progress_bar.progress(stage / 6, text=message)
+
+new_sheet, msg, meta = service.wardrobe_change(
+ ...,
+ progress_callback=progress_callback
+)
+```
+
+Progress stages:
+- Stage 0: Extracting views from character sheet
+- Stage 1: STEP 1/3 - Wardrobe transformation
+- Stage 2: STEP 2/3 - Merging new costume with facial details
+- Stage 3: STEP 3a/4 - Side profile portrait
+- Stage 4: STEP 3b/4 - Side profile full body
+- Stage 5: STEP 3c/4 - Rear view
+- Stage 6: STEP 3d/4 - Compositing new character sheet
+
+## Metadata Format
+
+```python
+{
+ "character_name": "Hero_Casual",
+ "wardrobe_change": True,
+ "new_costume_description": "casual modern clothing",
+ "has_costume_image": False,
+ "backend": "Gemini API (Cloud)",
+ "timestamp": "2025-10-23T15:45:00",
+ "stages": {
+ "front_body": "regenerated",
+ "front_portrait": "regenerated",
+ "side_portrait": "regenerated",
+ "side_body": "regenerated",
+ "rear_body": "regenerated"
+ },
+ "saved_to": "/path/to/output/dir"
+}
+```
+
+## Usage Examples
+
+### Example 1: Text Description
+```python
+service = WardrobeService(api_key="your-key")
+original = Image.open("knight_sheet.png")
+
+new_sheet, msg, meta = service.wardrobe_change(
+ character_sheet=original,
+ character_name="Knight_Casual",
+ new_costume_description="modern casual wear, jeans and leather jacket",
+ backend="Gemini API (Cloud)"
+)
+```
+
+### Example 2: Reference Image
+```python
+costume_ref = Image.open("pirate_costume.png")
+new_sheet, msg, meta = service.wardrobe_change(
+ character_sheet=original,
+ character_name="Knight_Pirate",
+ new_costume_image=costume_ref,
+ backend="Gemini API (Cloud)"
+)
+```
+
+### Example 3: Both Description and Reference
+```python
+costume_ref = Image.open("futuristic_armor.png")
+new_sheet, msg, meta = service.wardrobe_change(
+ character_sheet=original,
+ character_name="Knight_SciFi",
+ new_costume_description="futuristic powered armor",
+ new_costume_image=costume_ref,
+ backend="Gemini API (Cloud)"
+)
+```
+
+## Inheritance from CharacterForgeService
+
+Reuses these methods:
+- `_generate_stage()` - Single stage generation with retry
+- `composite_character_sheet()` - Compositing views
+- `extract_views_from_sheet()` - Extracting views from sheet
+- All BackendRouter functionality
+
+Adds:
+- `wardrobe_change()` - 3-step wardrobe workflow
+- `_save_wardrobe_change()` - Wardrobe-specific saving
+
+## Related Files
+- `services/character_forge_service.py` - Parent class
+- `character_forge.py` (old) - Original Gradio implementation source
+- `core/backend_router.py` - Backend routing
+- `models/generation_request.py` - Request structure
+- `models/generation_result.py` - Result structure
+- `ui/pages/01_🔥_Character_Forge.py` - UI that uses this service (wardrobe tab)
diff --git a/character_forge_image/services/wardrobe_service.py b/character_forge_image/services/wardrobe_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..0e306a317294f6d99a1ab38a57169d6152718d37
--- /dev/null
+++ b/character_forge_image/services/wardrobe_service.py
@@ -0,0 +1,414 @@
+"""
+Wardrobe Service
+================
+
+Business logic for wardrobe change generation.
+Modifies existing character sheets with new costumes while maintaining character consistency.
+"""
+
+import time
+from pathlib import Path
+from typing import Optional, Tuple, Dict, Any, Callable
+from datetime import datetime
+from PIL import Image
+
+from services.character_forge_service import CharacterForgeService
+from utils.file_utils import (
+ save_image,
+ ensure_directory_exists,
+ sanitize_filename
+)
+from utils.logging_utils import get_logger
+from config.settings import Settings
+
+
+logger = get_logger(__name__)
+
+
+class WardrobeService(CharacterForgeService):
+ """
+ Service for wardrobe change on existing character sheets.
+
+ Implements 3-step process:
+ 1. Extract views → Generate new full body with new costume
+ 2. Merge new costume with exact facial features
+ 3. Generate all other views from steps 1 & 2
+
+ Inherits from CharacterForgeService to reuse generation methods.
+ """
+
+ def __init__(self, api_key: Optional[str] = None):
+ """
+ Initialize wardrobe service.
+
+ Args:
+ api_key: Optional Gemini API key
+ """
+ super().__init__(api_key=api_key)
+ logger.info("WardrobeService initialized")
+
+ def wardrobe_change(
+ self,
+ character_sheet: Image.Image,
+ character_name: str,
+ new_costume_description: str = "",
+ new_costume_image: Optional[Image.Image] = None,
+ backend: str = Settings.BACKEND_GEMINI,
+ progress_callback: Optional[Callable[[int, str], None]] = None,
+ output_dir: Optional[Path] = None,
+ debug_extraction: bool = False
+ ) -> Tuple[Optional[Image.Image], str, Dict[str, Any]]:
+ """
+ Change costume on existing character sheet.
+
+ 3-Step Process:
+ 1. Generate new full body with costume from frontal portrait
+ 2. Create definitive face closeup (new costume + exact facial features)
+ 3. Generate all other views from steps 1 & 2
+
+ Args:
+ character_sheet: Existing character sheet to modify
+ character_name: Name for new wardrobe variant
+ new_costume_description: Text description of new costume
+ new_costume_image: Optional costume reference
+ backend: Backend to use
+ progress_callback: Optional callback(stage: int, message: str)
+ output_dir: Optional output directory (defaults to Settings.WARDROBE_CHANGES_DIR)
+
+ Returns:
+ Tuple of (new_character_sheet: Image, status_message: str, metadata: dict)
+ """
+ try:
+ logger.info("="*80)
+ logger.info(f"STARTING WARDROBE CHANGE: {character_name}")
+ logger.info(f"New costume description: {new_costume_description or '(none)'}")
+ logger.info(f"New costume reference: {'Yes' if new_costume_image else 'No'}")
+ logger.info(f"Backend: {backend}")
+ logger.info("="*80)
+
+ current_stage = "Initialization"
+
+ # Step 0: Extract existing views
+ current_stage = "Step 0: Extracting views from character sheet"
+ if progress_callback:
+ progress_callback(0, current_stage)
+
+ logger.info("Extracting existing views from character sheet...")
+ views = self.extract_views_from_sheet(
+ character_sheet=character_sheet,
+ save_debug=debug_extraction,
+ debug_dir=output_dir or Settings.WARDROBE_CHANGES_DIR,
+ character_name=f"{character_name}_original"
+ )
+ logger.info(f"Successfully extracted {len(views)} views")
+
+ original_front_portrait = views['front_portrait']
+
+ # Build costume instruction
+ costume_instruction = ""
+ if new_costume_description:
+ costume_instruction = f" now wearing {new_costume_description}"
+ elif new_costume_image:
+ costume_instruction = " now wearing the costume shown in the reference image"
+
+ time.sleep(1)
+
+ # Storage for new views
+ new_views = {}
+
+ # =================================================================
+ # STEP 1: Generate full body with new costume
+ # =================================================================
+ current_stage = "STEP 1/3: Wardrobe transformation - generating full body with new costume"
+ if progress_callback:
+ progress_callback(1, current_stage)
+
+ logger.info("")
+ logger.info("="*80)
+ logger.info("STEP 1: Generate full body with new costume from frontal portrait")
+ logger.info("="*80)
+
+ current_prompt = f"Generate a full body view of this exact character{costume_instruction}, standing in a neutral pose in front of a grey background with professional photo studio lighting. Maintain the EXACT same face, features, proportions, and body type as shown in the reference. ONLY change the costume/clothing."
+
+ input_images = [views['front_portrait'], views['front_body']]
+ if new_costume_image:
+ input_images.append(new_costume_image)
+
+ new_front_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage
+ )
+
+ if new_front_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Step 1 failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {new_front_body.size}")
+ new_views['front_body'] = new_front_body
+ time.sleep(1)
+
+ # =================================================================
+ # STEP 2: Create definitive face closeup
+ # =================================================================
+ current_stage = "STEP 2/3: Merging new costume with exact facial details"
+ if progress_callback:
+ progress_callback(2, current_stage)
+
+ logger.info("")
+ logger.info("="*80)
+ logger.info("STEP 2: Create definitive face closeup (new costume + exact facial features)")
+ logger.info("="*80)
+
+ # IMAGE 1: Full body with new costume - use for costume reference (will show in neck/upper chest naturally)
+ # IMAGE 2: Original face closeup - use for facial features ONLY
+ current_prompt = """Generate a close-up frontal facial portrait filling the entire vertical space.
+
+INSTRUCTIONS:
+1. IMAGE 1 (full body): Look at the COSTUME/CLOTHING visible here. The portrait should show this same costume in the neck/upper chest area.
+2. IMAGE 2 (face closeup): This is your FACIAL FEATURES reference. Use ONLY the facial details, skin tone, facial structure, hair, and face proportions from this image.
+3. FRAMING: Close-up portrait with face filling the ENTIRE vertical space. Show head and upper chest area where costume is naturally visible.
+4. Do NOT zoom out far enough to show full torso or mid-body. The face should dominate the frame.
+5. Neutral grey background with professional photo studio lighting.
+
+Result: Close-up portrait where IMAGE 2's face wears IMAGE 1's costume."""
+
+ input_images = [new_front_body, original_front_portrait]
+
+ new_front_portrait, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="3:4",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage
+ )
+
+ if new_front_portrait is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Step 2 failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {new_front_portrait.size}")
+ new_views['front_portrait'] = new_front_portrait
+ time.sleep(1)
+
+ # =================================================================
+ # STEP 3: Generate all other views
+ # =================================================================
+ logger.info("")
+ logger.info("="*80)
+ logger.info("STEP 3: Generate all other views using Step 1 & 2 outputs as references")
+ logger.info("="*80)
+
+ # Step 3a: Side profile portrait
+ current_stage = "STEP 3a/4: Generating side profile portrait"
+ if progress_callback:
+ progress_callback(3, current_stage)
+
+ current_prompt = "Create a side profile view focusing on the face filling the entire available space. Show the person from the side (90 degree angle). The face should fill the frame with head and upper chest visible (where costume shows naturally). Maintain exact facial features and costume from the reference images. Professional studio lighting against a neutral grey background."
+
+ input_images = [new_front_portrait, new_front_body]
+
+ side_portrait, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="3:4",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage
+ )
+
+ if side_portrait is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Step 3a failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {side_portrait.size}")
+ new_views['side_portrait'] = side_portrait
+ time.sleep(1)
+
+ # Step 3b: Side profile full body
+ current_stage = "STEP 3b/4: Generating side profile full body"
+ if progress_callback:
+ progress_callback(4, current_stage)
+
+ current_prompt = f"Generate a side profile view of the full body of this character in front of a neutral grey background with professional studio lighting. The body should fill the entire vertical space available. The character should be shown from the side (90 degree angle) in a neutral standing pose. Maintain exact appearance and costume from reference images."
+
+ input_images = [side_portrait, new_front_portrait, new_front_body]
+
+ side_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage
+ )
+
+ if side_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Step 3b failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {side_body.size}")
+ new_views['side_body'] = side_body
+ time.sleep(1)
+
+ # Step 3c: Rear view
+ current_stage = "STEP 3c/4: Generating rear view"
+ if progress_callback:
+ progress_callback(5, current_stage)
+
+ current_prompt = f"Generate a rear view image of this character showing the back of the character in a neutral standing pose against a neutral grey background with professional studio lighting. The full body should fill the vertical space. Maintain exact proportions and costume from the reference images."
+
+ input_images = [new_front_portrait, side_portrait, new_front_body]
+
+ rear_body, status = self._generate_stage(
+ prompt=current_prompt,
+ input_images=input_images,
+ aspect_ratio="9:16",
+ temperature=0.35,
+ backend=backend,
+ stage_name=current_stage
+ )
+
+ if rear_body is None:
+ logger.error(f"{current_stage} failed: {status}")
+ return None, f"Step 3c failed: {status}", {}
+
+ logger.info(f"{current_stage} complete: {rear_body.size}")
+ new_views['rear_body'] = rear_body
+ time.sleep(1)
+
+ # Step 3d: Composite new character sheet
+ current_stage = "STEP 3d/4: Compositing new character sheet"
+ if progress_callback:
+ progress_callback(6, current_stage)
+
+ logger.info(f"[{current_stage}] Compositing all regenerated views...")
+
+ new_character_sheet = self.composite_character_sheet(
+ front_portrait=new_views['front_portrait'],
+ side_portrait=new_views['side_portrait'],
+ front_body=new_views['front_body'],
+ side_body=new_views['side_body'],
+ rear_body=new_views['rear_body'],
+ character_name=character_name,
+ save_debug=debug_extraction,
+ debug_dir=output_dir or Settings.WARDROBE_CHANGES_DIR
+ )
+
+ logger.info(f"{current_stage} complete: {new_character_sheet.size}")
+
+ # Build metadata
+ metadata = {
+ "character_name": character_name,
+ "wardrobe_change": True,
+ "new_costume_description": new_costume_description,
+ "has_costume_image": new_costume_image is not None,
+ "backend": backend,
+ "timestamp": datetime.now().isoformat(),
+ "stages": {
+ "front_body": "regenerated",
+ "front_portrait": "regenerated",
+ "side_portrait": "regenerated",
+ "side_body": "regenerated",
+ "rear_body": "regenerated"
+ }
+ }
+
+ success_msg = f"Wardrobe change complete! Generated new character sheet for {character_name} with updated costume."
+
+ # Save to disk if output directory provided
+ if output_dir:
+ save_dir = self._save_wardrobe_change(
+ character_name=character_name,
+ new_views=new_views,
+ new_character_sheet=new_character_sheet,
+ original_character_sheet=character_sheet,
+ new_costume_description=new_costume_description,
+ new_costume_image=new_costume_image,
+ metadata=metadata,
+ output_dir=output_dir
+ )
+ success_msg += f"\n\nFiles saved to: {save_dir}"
+ metadata['saved_to'] = str(save_dir)
+
+ return new_character_sheet, success_msg, metadata
+
+ except Exception as e:
+ logger.exception(f"Wardrobe change failed: {e}")
+ return None, f"Wardrobe change error: {str(e)}", {}
+
+ def _save_wardrobe_change(
+ self,
+ character_name: str,
+ new_views: dict,
+ new_character_sheet: Image.Image,
+ original_character_sheet: Image.Image,
+ new_costume_description: str,
+ new_costume_image: Optional[Image.Image],
+ metadata: dict,
+ output_dir: Path
+ ) -> Path:
+ """
+ Save wardrobe change results to disk.
+
+ Args:
+ character_name: Character name
+ new_views: Dictionary of regenerated views
+ new_character_sheet: New character sheet
+ original_character_sheet: Original character sheet
+ new_costume_description: Costume description
+ new_costume_image: Optional costume reference
+ metadata: Generation metadata
+ output_dir: Output directory
+
+ Returns:
+ Path to saved directory
+ """
+ # Create wardrobe-specific directory
+ safe_name = sanitize_filename(character_name)
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ wardrobe_dir = output_dir / f"{safe_name}_wardrobe_{timestamp}"
+ ensure_directory_exists(wardrobe_dir)
+
+ logger.info(f"Saving wardrobe change to: {wardrobe_dir}")
+
+ # Save new character sheet
+ save_image(
+ image=new_character_sheet,
+ directory=wardrobe_dir,
+ base_name=f"{safe_name}_new_character_sheet",
+ metadata=metadata
+ )
+
+ # Save original for comparison
+ save_image(
+ image=original_character_sheet,
+ directory=wardrobe_dir,
+ base_name=f"{safe_name}_original_character_sheet",
+ metadata=None
+ )
+
+ # Save new views
+ for view_name, image in new_views.items():
+ save_image(
+ image=image,
+ directory=wardrobe_dir / "new_views",
+ base_name=f"{safe_name}_{view_name}",
+ metadata=None
+ )
+
+ # Save costume reference if provided
+ if new_costume_image:
+ save_image(
+ image=new_costume_image,
+ directory=wardrobe_dir / "inputs",
+ base_name=f"{safe_name}_costume_reference",
+ metadata=None
+ )
+
+ logger.info(f"All files saved to: {wardrobe_dir}")
+ return wardrobe_dir
diff --git a/character_forge_image/ui/__init__.py b/character_forge_image/ui/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..cee6de806fcd9b79fef1dc5831d63bc4eca6c23f
--- /dev/null
+++ b/character_forge_image/ui/__init__.py
@@ -0,0 +1,4 @@
+"""UI package for Nano Banana Streamlit.
+
+User interface components and pages.
+"""
diff --git a/character_forge_image/ui/components/__init__.py b/character_forge_image/ui/components/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b416d08460a2c9cb981fd3f8a5cbbf44de23709
--- /dev/null
+++ b/character_forge_image/ui/components/__init__.py
@@ -0,0 +1,18 @@
+"""Reusable UI components for Nano Banana Streamlit.
+
+Streamlit components that can be used across multiple pages.
+"""
+
+from ui.components.backend_selector import render_backend_selector
+from ui.components.status_display import render_status_display, render_backend_health
+from ui.components.image_uploader import render_image_uploader, render_multi_image_uploader
+from ui.components.aspect_ratio_selector import render_aspect_ratio_selector
+
+__all__ = [
+ 'render_backend_selector',
+ 'render_status_display',
+ 'render_backend_health',
+ 'render_image_uploader',
+ 'render_multi_image_uploader',
+ 'render_aspect_ratio_selector'
+]
diff --git a/character_forge_image/ui/components/aspect_ratio_selector.py b/character_forge_image/ui/components/aspect_ratio_selector.py
new file mode 100644
index 0000000000000000000000000000000000000000..8da7ca1f5f6a03042b4e6f19d7b91ffa1ff476ab
--- /dev/null
+++ b/character_forge_image/ui/components/aspect_ratio_selector.py
@@ -0,0 +1,152 @@
+"""
+Aspect Ratio Selector Component
+=================================
+
+Reusable component for selecting image aspect ratios.
+"""
+
+import streamlit as st
+from typing import Optional
+from config.settings import Settings
+from utils.logging_utils import get_logger
+
+
+logger = get_logger(__name__)
+
+
+def render_aspect_ratio_selector(
+ key: str = "aspect_ratio",
+ label: str = "Aspect Ratio",
+ help_text: Optional[str] = None,
+ default: str = "16:9 (1344x768)"
+) -> str:
+ """
+ Render aspect ratio selection dropdown.
+
+ Updates st.session_state[key] with selected aspect ratio.
+
+ Args:
+ key: Session state key (default: "aspect_ratio")
+ label: Label for dropdown (default: "Aspect Ratio")
+ help_text: Optional help text
+ default: Default aspect ratio (default: "16:9 (1344x768)")
+
+ Returns:
+ Selected aspect ratio string
+ """
+ # Initialize session state if needed
+ if key not in st.session_state:
+ st.session_state[key] = default
+
+ # Get available aspect ratios
+ ratios = list(Settings.ASPECT_RATIOS.keys())
+
+ # Find current index
+ try:
+ current_index = ratios.index(st.session_state[key])
+ except ValueError:
+ current_index = 0
+ st.session_state[key] = ratios[0]
+
+ # Render dropdown
+ aspect_ratio = st.selectbox(
+ label=label,
+ options=ratios,
+ index=current_index,
+ key=f"{key}_selector",
+ help=help_text or "Select output image dimensions"
+ )
+
+ # Update session state
+ st.session_state[key] = aspect_ratio
+
+ logger.debug(f"Aspect ratio selected: {aspect_ratio}")
+ return aspect_ratio
+
+
+def render_temperature_slider(
+ key: str = "temperature",
+ label: str = "Temperature",
+ help_text: Optional[str] = None,
+ default: float = 0.7
+) -> float:
+ """
+ Render temperature slider.
+
+ Updates st.session_state[key] with selected temperature.
+
+ Args:
+ key: Session state key (default: "temperature")
+ label: Label for slider (default: "Temperature")
+ help_text: Optional help text
+ default: Default temperature (default: 0.7)
+
+ Returns:
+ Selected temperature value
+ """
+ # Initialize session state if needed
+ if key not in st.session_state:
+ st.session_state[key] = default
+
+ # Render slider
+ temperature = st.slider(
+ label=label,
+ min_value=Settings.MIN_TEMPERATURE,
+ max_value=Settings.MAX_TEMPERATURE,
+ value=st.session_state[key],
+ step=0.05,
+ key=f"{key}_slider",
+ help=help_text or "0.0 = deterministic, 1.0 = creative"
+ )
+
+ # Update session state
+ st.session_state[key] = temperature
+
+ logger.debug(f"Temperature selected: {temperature}")
+ return temperature
+
+
+def render_generation_controls(
+ show_aspect_ratio: bool = True,
+ show_temperature: bool = True,
+ show_backend: bool = True,
+ aspect_ratio_default: str = "16:9 (1344x768)",
+ temperature_default: float = 0.7
+) -> dict:
+ """
+ Render standard generation controls.
+
+ Combines aspect ratio, temperature, and optionally backend selection.
+
+ Args:
+ show_aspect_ratio: Show aspect ratio selector (default: True)
+ show_temperature: Show temperature slider (default: True)
+ show_backend: Show backend selector (default: True)
+ aspect_ratio_default: Default aspect ratio
+ temperature_default: Default temperature
+
+ Returns:
+ Dictionary with selected values
+ """
+ from ui.components.backend_selector import render_backend_selector
+
+ controls = {}
+
+ col1, col2 = st.columns(2)
+
+ with col1:
+ if show_aspect_ratio:
+ controls['aspect_ratio'] = render_aspect_ratio_selector(
+ default=aspect_ratio_default
+ )
+
+ if show_temperature:
+ controls['temperature'] = render_temperature_slider(
+ default=temperature_default
+ )
+
+ with col2:
+ if show_backend:
+ controls['backend'] = render_backend_selector()
+
+ return controls
diff --git a/character_forge_image/ui/components/backend_selector.py b/character_forge_image/ui/components/backend_selector.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb0f8ef6e333f02bd6a8411a77a34a1a9a4d4ee5
--- /dev/null
+++ b/character_forge_image/ui/components/backend_selector.py
@@ -0,0 +1,51 @@
+"""
+Backend Selector Component
+===========================
+
+Reusable component for selecting image generation backend.
+"""
+
+import streamlit as st
+from config.settings import Settings
+from utils.logging_utils import get_logger
+
+
+logger = get_logger(__name__)
+
+
+def render_backend_selector(
+ key: str = "backend",
+ label: str = "Backend",
+ help_text: str = "Choose between cloud (Gemini) or local (OmniGen2, ComfyUI) generation"
+) -> str:
+ """
+ Render backend selection dropdown.
+
+ Updates st.session_state[key] with selected backend.
+
+ Args:
+ key: Session state key for backend selection (default: "backend")
+ label: Label for dropdown (default: "Backend")
+ help_text: Help text for dropdown
+
+ Returns:
+ Selected backend string
+ """
+ # Initialize session state if needed
+ if key not in st.session_state:
+ st.session_state[key] = Settings.BACKEND_GEMINI
+
+ # Render dropdown
+ backend = st.selectbox(
+ label=label,
+ options=Settings.AVAILABLE_BACKENDS,
+ index=Settings.AVAILABLE_BACKENDS.index(st.session_state[key]),
+ key=f"{key}_selector",
+ help=help_text
+ )
+
+ # Update session state
+ st.session_state[key] = backend
+
+ logger.debug(f"Backend selected: {backend}")
+ return backend
diff --git a/character_forge_image/ui/components/image_uploader.py b/character_forge_image/ui/components/image_uploader.py
new file mode 100644
index 0000000000000000000000000000000000000000..6463cefeb62c278b8510dd9ff784dbe507b5bec9
--- /dev/null
+++ b/character_forge_image/ui/components/image_uploader.py
@@ -0,0 +1,200 @@
+"""
+Image Uploader Component
+=========================
+
+Reusable components for uploading and managing images.
+"""
+
+import streamlit as st
+from typing import Optional, List
+from PIL import Image
+from utils.logging_utils import get_logger
+
+
+logger = get_logger(__name__)
+
+
+def render_image_uploader(
+ label: str = "Upload Image",
+ key: str = "image_upload",
+ help_text: Optional[str] = None,
+ accept_multiple: bool = False,
+ show_preview: bool = True
+) -> Optional[Image.Image]:
+ """
+ Render single image uploader with preview.
+
+ Args:
+ label: Label for uploader
+ key: Session state key
+ help_text: Optional help text
+ accept_multiple: Allow multiple files (default: False)
+ show_preview: Show image preview after upload (default: True)
+
+ Returns:
+ PIL Image object or None
+ """
+ uploaded_file = st.file_uploader(
+ label=label,
+ type=["png", "jpg", "jpeg", "webp"],
+ key=key,
+ help=help_text,
+ accept_multiple_files=accept_multiple
+ )
+
+ if uploaded_file is not None:
+ try:
+ if accept_multiple:
+ # Handle list of files
+ return None # Use render_multi_image_uploader for this
+ else:
+ image = Image.open(uploaded_file)
+ logger.debug(f"Image uploaded: {uploaded_file.name}, size: {image.size}")
+
+ # Show preview immediately after upload (high quality, max 512px)
+ if show_preview:
+ # Calculate display size (clamp to 512px while maintaining aspect ratio)
+ max_size = 512
+ scale = min(max_size / image.width, max_size / image.height, 1.0)
+ display_width = int(image.width * scale)
+
+ st.image(
+ image,
+ width=display_width,
+ caption=f"✅ {uploaded_file.name} ({image.width}x{image.height})",
+ use_container_width=False # Prevents aggressive compression
+ )
+
+ return image
+ except Exception as e:
+ st.error(f"Failed to load image: {str(e)}")
+ logger.error(f"Image upload error: {e}", exc_info=True)
+ return None
+
+ return None
+
+
+def render_multi_image_uploader(
+ label: str = "Upload Images",
+ key: str = "multi_image_upload",
+ help_text: Optional[str] = None,
+ max_images: int = 3,
+ show_previews: bool = True
+) -> List[Optional[Image.Image]]:
+ """
+ Render multiple image uploader with previews.
+
+ Args:
+ label: Label for uploader
+ key: Session state key
+ help_text: Optional help text
+ max_images: Maximum number of images (default: 3)
+ show_previews: Show image previews (default: True)
+
+ Returns:
+ List of PIL Image objects (None for empty slots)
+ """
+ uploaded_files = st.file_uploader(
+ label=label,
+ type=["png", "jpg", "jpeg", "webp"],
+ key=key,
+ help=help_text or f"Upload up to {max_images} images",
+ accept_multiple_files=True
+ )
+
+ images = []
+
+ if uploaded_files:
+ # Limit to max_images
+ files_to_process = uploaded_files[:max_images]
+
+ for uploaded_file in files_to_process:
+ try:
+ image = Image.open(uploaded_file)
+ images.append(image)
+ logger.debug(f"Image uploaded: {uploaded_file.name}, size: {image.size}")
+ except Exception as e:
+ st.error(f"Failed to load {uploaded_file.name}: {str(e)}")
+ logger.error(f"Image upload error: {e}", exc_info=True)
+ images.append(None)
+
+ # Show previews if requested (high quality)
+ if show_previews and any(img is not None for img in images):
+ cols = st.columns(len(images))
+ for i, (col, img) in enumerate(zip(cols, images)):
+ with col:
+ if img is not None:
+ # Calculate display size for column (max 300px)
+ max_size = 300
+ scale = min(max_size / img.width, max_size / img.height, 1.0)
+ display_width = int(img.width * scale)
+
+ st.image(
+ img,
+ width=display_width,
+ caption=f"Image {i+1}",
+ use_container_width=False
+ )
+
+ # Warn if too many files
+ if len(uploaded_files) > max_images:
+ st.warning(f"Only the first {max_images} images will be used")
+
+ # Pad to max_images with None
+ while len(images) < max_images:
+ images.append(None)
+
+ return images[:max_images]
+
+
+def render_image_with_type_selector(
+ image_index: int,
+ image_types: List[str],
+ default_type: str = "Subject/Character",
+ key_prefix: str = "img"
+) -> tuple[Optional[Image.Image], str]:
+ """
+ Render image uploader with type selector.
+
+ Useful for composition assistant where each image has a type.
+
+ Args:
+ image_index: Index of this image (1, 2, 3, etc.)
+ image_types: List of available image types
+ default_type: Default image type
+ key_prefix: Prefix for session state keys
+
+ Returns:
+ Tuple of (image: Optional[Image], image_type: str)
+ """
+ col1, col2 = st.columns([2, 1])
+
+ with col1:
+ image = render_image_uploader(
+ label=f"Image {image_index}",
+ key=f"{key_prefix}{image_index}_upload"
+ )
+
+ with col2:
+ image_type = st.selectbox(
+ label=f"Image {image_index} Type",
+ options=image_types,
+ index=image_types.index(default_type) if default_type in image_types else 0,
+ key=f"{key_prefix}{image_index}_type",
+ help="What does this image contain?"
+ )
+
+ # Show preview if image loaded (high quality, max 400px)
+ if image is not None:
+ max_size = 400
+ scale = min(max_size / image.width, max_size / image.height, 1.0)
+ display_width = int(image.width * scale)
+
+ st.image(
+ image,
+ width=display_width,
+ caption=f"Image {image_index}: {image_type}",
+ use_container_width=False
+ )
+
+ return image, image_type
diff --git a/character_forge_image/ui/components/library_selector.py b/character_forge_image/ui/components/library_selector.py
new file mode 100644
index 0000000000000000000000000000000000000000..9f6125f1e450dc0a60274c04f237e5db99c0ba99
--- /dev/null
+++ b/character_forge_image/ui/components/library_selector.py
@@ -0,0 +1,397 @@
+"""
+Library Selector UI Components
+===============================
+
+UI components for selecting images from the library, including sidebar,
+modal selector, and library buttons.
+"""
+
+import streamlit as st
+from typing import Optional, List, Callable
+from PIL import Image
+
+from utils.library_manager import LibraryManager
+from utils.logging_utils import get_logger
+
+logger = get_logger(__name__)
+
+
+def render_library_sidebar(
+ on_select: Callable = None,
+ filter_type: str = None,
+ key_prefix: str = "lib_sidebar"
+):
+ """
+ Render persistent library sidebar.
+
+ Args:
+ on_select: Callback function when image is selected (receives entry_id)
+ filter_type: Filter to specific type (None = all types)
+ key_prefix: Unique key prefix for widgets
+ """
+ library = LibraryManager()
+
+ st.markdown("### 📚 Library")
+
+ # Search box
+ search_query = st.text_input(
+ "Search",
+ key=f"{key_prefix}_search",
+ placeholder="Search images...",
+ label_visibility="collapsed"
+ )
+
+ # Filter dropdown
+ filter_options = {
+ "All": None,
+ "Character Sheets": "character_sheet",
+ "Wardrobe Changes": "wardrobe",
+ "Compositions": "composition",
+ "Standard": "standard"
+ }
+
+ selected_filter = st.selectbox(
+ "Filter",
+ options=list(filter_options.keys()),
+ key=f"{key_prefix}_filter",
+ label_visibility="collapsed"
+ )
+
+ # Apply filter override if specified
+ if filter_type:
+ active_filter = filter_type
+ else:
+ active_filter = filter_options[selected_filter]
+
+ st.divider()
+
+ # Get entries
+ entries = library.get_entries(
+ filter_type=active_filter,
+ search=search_query if search_query else None,
+ limit=20,
+ sort_by="newest"
+ )
+
+ if not entries:
+ st.info("No images in library yet")
+ return
+
+ # Display entries
+ for entry in entries:
+ col1, col2 = st.columns([1, 2])
+
+ with col1:
+ # Load and display thumbnail
+ thumbnail = library.load_thumbnail(entry["id"])
+ if thumbnail:
+ st.image(thumbnail, use_container_width=True)
+
+ with col2:
+ # Display name and date
+ st.markdown(f"**{entry['name']}**")
+
+ # Format date
+ from datetime import datetime
+ created = datetime.fromisoformat(entry['created_at'])
+ today = datetime.now()
+ days_ago = (today - created).days
+
+ if days_ago == 0:
+ date_str = "Today"
+ elif days_ago == 1:
+ date_str = "Yesterday"
+ elif days_ago < 7:
+ date_str = f"{days_ago} days ago"
+ else:
+ date_str = created.strftime("%b %d")
+
+ st.caption(date_str)
+
+ # Select button
+ if st.button(
+ "Select",
+ key=f"{key_prefix}_select_{entry['id']}",
+ use_container_width=True
+ ):
+ if on_select:
+ on_select(entry["id"])
+ logger.info(f"Selected from sidebar: {entry['name']}")
+
+ st.divider()
+
+ # Load more button
+ if len(entries) >= 20:
+ if st.button("Load More...", key=f"{key_prefix}_load_more"):
+ # TODO: Implement pagination
+ st.info("Pagination coming soon")
+
+
+@st.dialog("Select from Library", width="large")
+def _library_modal_dialog(
+ library: LibraryManager,
+ filter_type: str = None,
+ allow_multiple: bool = False,
+ key_prefix: str = "lib_modal"
+):
+ """
+ Internal function for library modal dialog content.
+
+ Args:
+ library: LibraryManager instance
+ filter_type: Filter to specific type
+ allow_multiple: Allow selecting multiple images
+ key_prefix: Unique key prefix
+ """
+ # Search and filter controls
+ col1, col2 = st.columns([3, 1])
+
+ with col1:
+ search_query = st.text_input(
+ "Search",
+ placeholder="Search by name, tags, or prompt...",
+ key=f"{key_prefix}_search"
+ )
+
+ with col2:
+ filter_options = {
+ "All": None,
+ "Characters": "character_sheet",
+ "Wardrobe": "wardrobe",
+ "Compositions": "composition",
+ "Standard": "standard"
+ }
+
+ selected_filter = st.selectbox(
+ "Filter",
+ options=list(filter_options.keys()),
+ key=f"{key_prefix}_filter"
+ )
+
+ active_filter = filter_type if filter_type else filter_options[selected_filter]
+
+ # Get entries
+ entries = library.get_entries(
+ filter_type=active_filter,
+ search=search_query if search_query else None,
+ limit=100,
+ sort_by="newest"
+ )
+
+ if not entries:
+ st.warning("No images found")
+ if st.button("Close"):
+ st.session_state[f"{key_prefix}_selected"] = None
+ st.rerun()
+ return
+
+ # Initialize selection state
+ if f"{key_prefix}_selection" not in st.session_state:
+ st.session_state[f"{key_prefix}_selection"] = []
+
+ # Display grid of thumbnails
+ st.markdown(f"**Found {len(entries)} images**")
+
+ # Grid layout (4 columns)
+ cols_per_row = 4
+ for i in range(0, len(entries), cols_per_row):
+ cols = st.columns(cols_per_row)
+
+ for col_idx, col in enumerate(cols):
+ entry_idx = i + col_idx
+ if entry_idx >= len(entries):
+ break
+
+ entry = entries[entry_idx]
+
+ with col:
+ # Load thumbnail
+ thumbnail = library.load_thumbnail(entry["id"])
+ if thumbnail:
+ st.image(thumbnail, use_container_width=True)
+
+ # Entry name
+ st.caption(entry["name"])
+
+ # Select button or checkbox
+ if allow_multiple:
+ # Checkbox for multiple selection
+ is_selected = entry["id"] in st.session_state[f"{key_prefix}_selection"]
+ if st.checkbox(
+ "Select",
+ value=is_selected,
+ key=f"{key_prefix}_check_{entry['id']}"
+ ):
+ if entry["id"] not in st.session_state[f"{key_prefix}_selection"]:
+ st.session_state[f"{key_prefix}_selection"].append(entry["id"])
+ else:
+ if entry["id"] in st.session_state[f"{key_prefix}_selection"]:
+ st.session_state[f"{key_prefix}_selection"].remove(entry["id"])
+ else:
+ # Single selection button
+ if st.button(
+ "Select",
+ key=f"{key_prefix}_btn_{entry['id']}",
+ use_container_width=True
+ ):
+ st.session_state[f"{key_prefix}_selected"] = [entry["id"]]
+ st.rerun()
+
+ st.divider()
+
+ # Bottom action buttons
+ col1, col2 = st.columns(2)
+
+ with col1:
+ if allow_multiple:
+ selected_count = len(st.session_state[f"{key_prefix}_selection"])
+ if st.button(
+ f"Select ({selected_count})",
+ type="primary",
+ use_container_width=True,
+ disabled=selected_count == 0
+ ):
+ st.session_state[f"{key_prefix}_selected"] = st.session_state[f"{key_prefix}_selection"].copy()
+ st.session_state[f"{key_prefix}_selection"] = []
+ st.rerun()
+
+ with col2:
+ if st.button("Cancel", use_container_width=True):
+ st.session_state[f"{key_prefix}_selected"] = None
+ st.session_state[f"{key_prefix}_selection"] = []
+ st.rerun()
+
+
+def render_library_modal(
+ target_key: str,
+ filter_type: str = None,
+ allow_multiple: bool = False
+) -> Optional[List[str]]:
+ """
+ Render library selection modal.
+
+ Call this function when user clicks "From Library" button.
+ Returns selected entry IDs when selection is made.
+
+ Args:
+ target_key: Unique key for this modal instance
+ filter_type: Filter to specific type (None = all types)
+ allow_multiple: Allow selecting multiple images
+
+ Returns:
+ List of selected entry IDs, or None if no selection
+ """
+ library = LibraryManager()
+ key_prefix = f"lib_modal_{target_key}"
+
+ # Check if modal should be shown
+ if st.session_state.get(f"{key_prefix}_show", False):
+ # Clear the show flag immediately to prevent reopening on other UI interactions
+ st.session_state[f"{key_prefix}_show"] = False
+
+ _library_modal_dialog(
+ library=library,
+ filter_type=filter_type,
+ allow_multiple=allow_multiple,
+ key_prefix=key_prefix
+ )
+
+ # Check for selection
+ selected = st.session_state.get(f"{key_prefix}_selected")
+ if selected:
+ # Clear modal state
+ st.session_state[f"{key_prefix}_show"] = False
+ st.session_state[f"{key_prefix}_selected"] = None
+
+ logger.info(f"Library selection made: {selected}")
+ return selected
+
+ return None
+
+
+def render_library_button(
+ target_key: str,
+ label: str = "📚 From Library",
+ help_text: str = None
+) -> bool:
+ """
+ Render button that opens library modal.
+
+ Use alongside file uploader to allow selecting from library.
+
+ Args:
+ target_key: Unique key for this button/modal pair
+ label: Button label
+ help_text: Optional help tooltip
+
+ Returns:
+ True if button was clicked
+
+ Example:
+ col1, col2 = st.columns([3, 1])
+ with col1:
+ uploaded = st.file_uploader("Upload Image")
+ with col2:
+ if render_library_button("my_input"):
+ selected = render_library_modal("my_input")
+ if selected:
+ image = library.load_image(selected[0])
+ """
+ key_prefix = f"lib_modal_{target_key}"
+
+ clicked = st.button(
+ label,
+ key=f"{key_prefix}_btn",
+ help=help_text or "Select image from library",
+ use_container_width=True
+ )
+
+ if clicked:
+ st.session_state[f"{key_prefix}_show"] = True
+ st.rerun()
+
+ return clicked
+
+
+def render_library_stats():
+ """
+ Render library statistics (for Library management page).
+ """
+ library = LibraryManager()
+ stats = library.get_stats()
+
+ col1, col2, col3, col4 = st.columns(4)
+
+ with col1:
+ st.metric("Total Images", stats["total_entries"])
+
+ with col2:
+ st.metric("Total Size", f"{stats['total_size_mb']} MB")
+
+ with col3:
+ st.metric("Favorites", stats["favorites_count"])
+
+ with col4:
+ # Most common type
+ by_type = stats.get("by_type", {})
+ if by_type:
+ most_common = max(by_type.items(), key=lambda x: x[1])
+ st.metric("Most Common", f"{most_common[0]} ({most_common[1]})")
+ else:
+ st.metric("Most Common", "N/A")
+
+ # Breakdown by type
+ if by_type:
+ st.markdown("### By Type")
+ type_names = {
+ "character_sheet": "Character Sheets",
+ "wardrobe": "Wardrobe Changes",
+ "composition": "Compositions",
+ "standard": "Standard"
+ }
+
+ cols = st.columns(len(by_type))
+ for col, (type_key, count) in zip(cols, by_type.items()):
+ with col:
+ display_name = type_names.get(type_key, type_key)
+ st.metric(display_name, count)
diff --git a/character_forge_image/ui/components/status_display.py b/character_forge_image/ui/components/status_display.py
new file mode 100644
index 0000000000000000000000000000000000000000..92c47e30e8ce38480bdb22c81da98171f11acb1c
--- /dev/null
+++ b/character_forge_image/ui/components/status_display.py
@@ -0,0 +1,215 @@
+"""
+Status Display Component
+=========================
+
+Reusable components for displaying status, progress, and logs.
+"""
+
+import streamlit as st
+from typing import Optional, Dict, Any
+from io import BytesIO
+from PIL import Image
+from services import GenerationService
+from utils.logging_utils import get_recent_logs
+from utils.logging_utils import get_logger
+
+
+logger = get_logger(__name__)
+
+
+def render_image_with_download(
+ image: Image.Image,
+ filename: str = "generated_image.png",
+ max_display_size: int = 512,
+ show_fullscreen_button: bool = True
+):
+ """
+ Render image with responsive sizing and download button.
+
+ Args:
+ image: PIL Image to display
+ filename: Default filename for download
+ max_display_size: Maximum display dimension in pixels (default: 512)
+ show_fullscreen_button: Show button to view fullscreen (default: True)
+ """
+ if image is None:
+ return
+
+ width, height = image.size
+
+ # Calculate display size (clamp to max_display_size while maintaining aspect ratio)
+ scale = min(max_display_size / width, max_display_size / height, 1.0)
+ display_width = int(width * scale)
+
+ # Display image with clamped size
+ st.image(
+ image,
+ width=display_width,
+ caption=f"Generated Image ({width}×{height})",
+ use_container_width=False
+ )
+
+ # Action buttons
+ col1, col2 = st.columns(2)
+
+ with col1:
+ # Download button (uncompressed PNG for maximum quality)
+ buf = BytesIO()
+ image.save(buf, format='PNG', compress_level=0)
+ byte_data = buf.getvalue()
+
+ st.download_button(
+ label="⬇️ Download PNG",
+ data=byte_data,
+ file_name=filename,
+ mime="image/png",
+ use_container_width=True
+ )
+
+ with col2:
+ # Fullscreen button (creates modal)
+ if show_fullscreen_button:
+ if st.button("🔍 View Fullscreen", use_container_width=True, key=f"fullscreen_{id(image)}"):
+ # Store in session state to trigger modal
+ st.session_state['fullscreen_image'] = image
+ st.rerun()
+
+
+def render_fullscreen_modal():
+ """
+ Render fullscreen image modal if image is set in session state.
+ Call this at the top level of your page.
+ """
+ if 'fullscreen_image' in st.session_state and st.session_state['fullscreen_image'] is not None:
+ image = st.session_state['fullscreen_image']
+
+ # Create modal using dialog
+ @st.dialog("🖼️ Fullscreen View", width="large")
+ def show_fullscreen():
+ st.image(image, use_container_width=True)
+
+ if st.button("✕ Close", use_container_width=True):
+ st.session_state['fullscreen_image'] = None
+ st.rerun()
+
+ show_fullscreen()
+
+
+def render_status_display(
+ result: Optional[Any] = None,
+ show_logs: bool = False,
+ log_limit: int = 50
+):
+ """
+ Render status display for generation results.
+
+ Args:
+ result: GenerationResult object to display
+ show_logs: Whether to show recent logs (default: False)
+ log_limit: Number of log lines to show (default: 50)
+ """
+ if result is None:
+ return
+
+ # Display result status
+ if result.success:
+ st.success(result.message)
+
+ # Show generation time if available
+ if result.generation_time is not None:
+ st.info(f"⏱️ Generation time: {result.generation_time:.1f}s")
+
+ # Show saved path if available
+ if result.saved_path:
+ st.info(f"💾 Saved to: {result.saved_path}")
+
+ # Display image if present (with download and fullscreen options)
+ if result.image:
+ # Generate filename from saved_path or use default
+ if result.saved_path:
+ from pathlib import Path
+ filename = Path(result.saved_path).name
+ else:
+ from datetime import datetime
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"generated_{timestamp}.png"
+
+ render_image_with_download(
+ image=result.image,
+ filename=filename,
+ max_display_size=512,
+ show_fullscreen_button=True
+ )
+ else:
+ st.error(f"❌ Generation failed: {result.message}")
+
+ # Show logs if requested
+ if show_logs:
+ render_logs(limit=log_limit)
+
+
+def render_logs(limit: int = 50):
+ """
+ Render recent logs in expander.
+
+ Args:
+ limit: Number of log lines to show (default: 50)
+ """
+ with st.expander("📋 View Logs", expanded=False):
+ logs = get_recent_logs(limit=limit)
+ if logs:
+ st.code("\n".join(logs), language="log")
+ else:
+ st.info("No logs available")
+
+
+def render_backend_health(
+ service: Optional[GenerationService] = None,
+ show_all: bool = True
+):
+ """
+ Render backend health status.
+
+ Args:
+ service: GenerationService instance (creates new if None)
+ show_all: Show all backends or only current (default: True)
+ """
+ if service is None:
+ service = GenerationService()
+
+ if show_all:
+ st.subheader("🏥 Backend Health")
+
+ status = service.get_all_backend_status()
+
+ for backend, info in status.items():
+ if info['healthy']:
+ st.success(f"✅ **{backend}**: {info['message']}")
+ else:
+ st.warning(f"⚠️ **{backend}**: {info['message']}")
+ else:
+ # Show only current backend
+ current_backend = st.session_state.get('backend', 'Gemini API (Cloud)')
+ is_healthy, message = service.check_backend_availability(current_backend)
+
+ if is_healthy:
+ st.success(f"✅ {current_backend}: {message}")
+ else:
+ st.warning(f"⚠️ {current_backend}: {message}")
+
+
+def render_progress_tracker(
+ current_stage: int,
+ total_stages: int,
+ stage_message: str
+):
+ """
+ Render progress tracker for multi-stage operations.
+
+ Args:
+ current_stage: Current stage number (0-indexed)
+ total_stages: Total number of stages
+ stage_message: Message describing current stage
+ """
+ progress = (current_stage + 1) / total_stages
+ st.progress(progress, text=f"Stage {current_stage + 1}/{total_stages}: {stage_message}")
diff --git a/character_forge_image/ui/pages/__init__.py b/character_forge_image/ui/pages/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9173f2497c42bb99810f3dc8f1648573cc8614db
--- /dev/null
+++ b/character_forge_image/ui/pages/__init__.py
@@ -0,0 +1,4 @@
+"""Pages package for Nano Banana Streamlit.
+
+Streamlit pages for the application.
+"""
diff --git a/character_forge_image/utils/__init__.py b/character_forge_image/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9faac33388f7a2cbf8bc136c35034e685516ec9a
--- /dev/null
+++ b/character_forge_image/utils/__init__.py
@@ -0,0 +1 @@
+"""Utilities package for Nano Banana Streamlit."""
diff --git a/character_forge_image/utils/file_utils.md b/character_forge_image/utils/file_utils.md
new file mode 100644
index 0000000000000000000000000000000000000000..b6445d9737de80dd86af6ed8b77cfca08fbe5bec
--- /dev/null
+++ b/character_forge_image/utils/file_utils.md
@@ -0,0 +1,375 @@
+# file_utils.py
+
+## Purpose
+File I/O operations for Nano Banana Streamlit. Centralized handling of image saving/loading, metadata management, filename generation, and directory operations.
+
+## Responsibilities
+- Generate safe, unique filenames with timestamps
+- Save/load images to/from disk
+- Save/load metadata as JSON
+- Create standardized metadata dictionaries
+- Compute image hashes for change detection
+- Manage output directory structure
+- List recent generations
+
+## Dependencies
+
+### Imports
+- `json` - JSON serialization
+- `hashlib` - Image hashing (SHA-256)
+- `re` - Filename sanitization (regex)
+- `datetime` - Timestamps
+- `pathlib.Path` - Path operations
+- `PIL.Image` - Image handling
+- `config.settings.Settings` - Directory paths
+- `utils.logging_utils.get_logger` - Logging
+
+### Used By
+- All services - Save generation results
+- All pages - Load/display images
+- Backend clients - Save API responses
+- `models/generation_result.py` - Metadata creation
+
+## Public Interface
+
+### Filename Utilities
+
+#### `sanitize_filename(name: str) -> str`
+Remove unsafe characters from filename.
+
+**Rules:**
+- Removes: `< > : " / \ | ? *`
+- Replaces with underscore
+- Strips leading/trailing spaces and dots
+- Limits to 100 characters
+- Falls back to "generated" if empty
+
+**Example:**
+```python
+safe = sanitize_filename("My Character: v2.0")
+# Returns: "My_Character__v2_0"
+```
+
+#### `generate_timestamp_filename(base_name: str, extension: str = "png") -> str`
+Generate filename with timestamp.
+
+**Format:** `{base_name}_{YYYYMMDD_HHMMSS}.{extension}`
+
+**Example:**
+```python
+filename = generate_timestamp_filename("character", "png")
+# Returns: "character_20251023_143052.png"
+```
+
+#### `get_unique_filename(directory: Path, base_name: str, extension: str = "png") -> Path`
+Generate unique filename that doesn't exist in directory.
+
+If file exists, appends counter: `_1`, `_2`, etc.
+
+**Example:**
+```python
+path = get_unique_filename(Settings.CHARACTER_SHEETS_DIR, "hero", "png")
+# Returns: Path("outputs/character_sheets/hero_20251023_143052.png")
+# If exists: Path("outputs/character_sheets/hero_20251023_143052_1.png")
+```
+
+### Image Operations
+
+#### `save_image(image: Image, directory: Path, base_name: str, metadata: dict = None) -> Tuple[Path, Path]`
+Save image and optional metadata.
+
+**Parameters:**
+- `image`: PIL Image to save
+- `directory`: Target directory (created if doesn't exist)
+- `base_name`: Base filename (will add timestamp)
+- `metadata`: Optional metadata dict (saved as JSON)
+
+**Returns:** `(image_path, metadata_path)` tuple
+
+**Example:**
+```python
+metadata = {"prompt": "sunset", "backend": "Gemini"}
+img_path, meta_path = save_image(
+ image=generated_image,
+ directory=Settings.CHARACTER_SHEETS_DIR,
+ base_name="hero",
+ metadata=metadata
+)
+# Saves:
+# outputs/character_sheets/hero_20251023_143052.png
+# outputs/character_sheets/hero_20251023_143052.json
+```
+
+#### `load_image(file_path: Path) -> Image`
+Load image from disk.
+
+**Raises:**
+- `FileNotFoundError`: If file doesn't exist
+- `IOError`: If file can't be read as image
+
+**Example:**
+```python
+image = load_image(Path("outputs/character_sheets/hero_20251023_143052.png"))
+```
+
+### Metadata Operations
+
+#### `save_metadata(file_path: Path, metadata: dict)`
+Save metadata dictionary as JSON.
+
+**Format:** Indented JSON with UTF-8 encoding
+
+**Raises:** `IOError` if write fails
+
+#### `load_metadata(file_path: Path) -> dict`
+Load metadata from JSON file.
+
+**Raises:**
+- `FileNotFoundError`: If file doesn't exist
+- `json.JSONDecodeError`: If invalid JSON
+
+**Example:**
+```python
+meta = load_metadata(Path("outputs/character_sheets/hero_20251023_143052.json"))
+prompt = meta["prompt"]
+```
+
+#### `create_generation_metadata(...) -> dict`
+Create standardized metadata dictionary.
+
+**Parameters:**
+- `prompt`: Generation prompt (required)
+- `backend`: Backend used (required)
+- `aspect_ratio`: Aspect ratio (required)
+- `temperature`: Temperature value (required)
+- `input_images`: List of input image paths (optional)
+- `generation_time`: Time taken in seconds (optional)
+- `**kwargs`: Additional custom fields
+
+**Returns:** Metadata dictionary with standard fields
+
+**Standard Fields:**
+- `timestamp`: ISO format timestamp
+- `prompt`: Generation prompt
+- `backend`: Backend name
+- `aspect_ratio`: Aspect ratio string
+- `temperature`: Temperature value
+- `version`: Application version ("2.0.0-streamlit")
+- `input_images`: List of input paths (if provided)
+- `generation_time_seconds`: Time taken (if provided)
+
+**Example:**
+```python
+metadata = create_generation_metadata(
+ prompt="sunset over mountains",
+ backend="Gemini API (Cloud)",
+ aspect_ratio="16:9",
+ temperature=0.4,
+ generation_time=3.5,
+ character_name="Hero", # Custom field
+ stage="front_portrait" # Custom field
+)
+```
+
+### Image Hashing
+
+#### `compute_image_hash(image: Image) -> str`
+Compute SHA-256 hash of image data.
+
+Useful for detecting if input images have changed.
+
+**Returns:** Hex string (64 characters)
+
+**Example:**
+```python
+hash1 = compute_image_hash(image1)
+hash2 = compute_image_hash(image2)
+if hash1 == hash2:
+ print("Images are identical")
+```
+
+### Directory Operations
+
+#### `ensure_output_directories()`
+Ensure all output directories exist.
+
+Creates all directories defined in Settings if they don't exist.
+Called on startup.
+
+#### `get_output_directory_for_type(generation_type: str) -> Path`
+Get appropriate output directory for generation type.
+
+**Types:**
+- `"character_sheet"` → `Settings.CHARACTER_SHEETS_DIR`
+- `"wardrobe"` → `Settings.WARDROBE_CHANGES_DIR`
+- `"composition"` → `Settings.COMPOSITIONS_DIR`
+- `"standard"` → `Settings.STANDARD_DIR`
+
+**Raises:** `ValueError` if unknown type
+
+**Example:**
+```python
+output_dir = get_output_directory_for_type("character_sheet")
+# Returns: Path("outputs/character_sheets")
+```
+
+#### `list_recent_generations(generation_type: str, count: int = 10) -> list`
+List recent generation files in a directory.
+
+**Returns:** List of `(image_path, metadata_path)` tuples, newest first
+
+Metadata path is `None` if JSON file doesn't exist.
+
+**Example:**
+```python
+recent = list_recent_generations("character_sheet", count=5)
+for img_path, meta_path in recent:
+ image = load_image(img_path)
+ if meta_path:
+ metadata = load_metadata(meta_path)
+```
+
+## Usage Examples
+
+### Service Saving Output
+```python
+from utils.file_utils import save_image, create_generation_metadata, get_output_directory_for_type
+
+class CharacterForgeService:
+ def generate(self, prompt, backend, ...):
+ # ... generation code ...
+
+ # Create metadata
+ metadata = create_generation_metadata(
+ prompt=prompt,
+ backend=backend,
+ aspect_ratio="3:4",
+ temperature=0.35,
+ generation_time=elapsed_time,
+ character_name=character_name,
+ stage="front_portrait"
+ )
+
+ # Save image and metadata
+ output_dir = get_output_directory_for_type("character_sheet")
+ img_path, meta_path = save_image(
+ image=generated_image,
+ directory=output_dir,
+ base_name=character_name,
+ metadata=metadata
+ )
+
+ return img_path
+```
+
+### Page Displaying Recent Generations
+```python
+import streamlit as st
+from utils.file_utils import list_recent_generations, load_image
+
+st.subheader("Recent Character Sheets")
+
+recent = list_recent_generations("character_sheet", count=4)
+
+cols = st.columns(4)
+for idx, (img_path, meta_path) in enumerate(recent):
+ with cols[idx]:
+ image = load_image(img_path)
+ st.image(image, caption=img_path.stem, use_container_width=True)
+```
+
+### Loading Previous Generation
+```python
+from utils.file_utils import load_image, load_metadata
+
+# User selects a previous generation
+image_path = st.selectbox("Load previous", [...])
+
+if image_path:
+ # Load image
+ image = load_image(Path(image_path))
+ st.image(image)
+
+ # Load metadata (if exists)
+ meta_path = Path(image_path).with_suffix(".json")
+ if meta_path.exists():
+ metadata = load_metadata(meta_path)
+ st.json(metadata)
+
+ # Restore settings
+ st.session_state.prompt = metadata["prompt"]
+ st.session_state.backend = metadata["backend"]
+```
+
+## Error Handling
+
+### File Operations
+All functions raise appropriate exceptions:
+- `FileNotFoundError`: File doesn't exist
+- `IOError`: Read/write error
+- `json.JSONDecodeError`: Invalid JSON
+- `ValueError`: Invalid parameters
+
+Errors are logged before raising.
+
+### Automatic Recovery
+- Directories created automatically if they don't exist
+- Filename conflicts resolved with counter suffix
+- Missing metadata handled gracefully (returns None)
+
+## Known Limitations
+- Filename length limit: 100 characters (base name)
+- No image format conversion (saves as PNG only)
+- No image compression options
+- No batch operations
+- No cloud storage integration
+- Hash only detects exact pixel matches (not perceptual similarity)
+
+## Future Improvements
+- Support multiple image formats (JPEG, WEBP)
+- Add image compression/quality options
+- Add batch save/load operations
+- Add cloud storage backends (S3, GCS)
+- Add perceptual image hashing (pHash)
+- Add image metadata embedding (EXIF)
+- Add file cleanup/archiving utilities
+- Add generation statistics tracking
+
+## Testing
+- Test sanitize_filename() with various unsafe characters
+- Test generate_timestamp_filename() format
+- Test get_unique_filename() collision handling
+- Test save_image() creates files correctly
+- Test load_image() with valid/invalid files
+- Test save/load_metadata() round-trip
+- Test create_generation_metadata() includes all fields
+- Test compute_image_hash() consistency
+- Test list_recent_generations() sorting
+
+## Related Files
+- `config/settings.py` - Directory path constants
+- `utils/logging_utils.py` - Logging functions
+- All services - Save generation results
+- All pages - Load and display files
+- `models/generation_result.py` - Uses metadata creation
+
+## Security Considerations
+- Filename sanitization prevents directory traversal
+- No arbitrary file paths allowed (always in Settings directories)
+- JSON encoding ensures no code injection
+- File permissions inherited from parent directory
+
+## Performance Considerations
+- Image hashing loads full image into memory
+- Large images may be slow to hash
+- list_recent_generations() sorts by modification time (fast)
+- JSON serialization is fast for typical metadata size
+
+## Change History
+- 2025-10-23: Initial creation for Streamlit migration
+ - Centralized all file I/O operations
+ - Added comprehensive filename handling
+ - Added metadata standardization
+ - Added directory management
+ - Added recent generations listing
+ - Integrated with Settings and logging
diff --git a/character_forge_image/utils/file_utils.py b/character_forge_image/utils/file_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..f864944aa737a69b107e9040565a3357eb8f448b
--- /dev/null
+++ b/character_forge_image/utils/file_utils.py
@@ -0,0 +1,446 @@
+"""
+File Utilities
+==============
+
+File I/O operations for Nano Banana Streamlit.
+Handles image saving/loading, metadata management, and filename generation.
+"""
+
+import json
+import hashlib
+import re
+from datetime import datetime
+from pathlib import Path
+from typing import Optional, Dict, Any, Tuple, Union
+from PIL import Image
+
+from config.settings import Settings
+from utils.logging_utils import get_logger
+
+
+logger = get_logger(__name__)
+
+
+# =============================================================================
+# FILENAME UTILITIES
+# =============================================================================
+
+def sanitize_filename(name: str) -> str:
+ """
+ Sanitize a string to be safe for use as a filename.
+
+ Removes or replaces unsafe characters.
+
+ Args:
+ name: Raw filename string
+
+ Returns:
+ Sanitized filename safe for all operating systems
+ """
+ # Remove/replace unsafe characters
+ safe = re.sub(r'[<>:"/\\|?*]', '_', name)
+
+ # Remove leading/trailing spaces and dots
+ safe = safe.strip('. ')
+
+ # Limit length (leave room for timestamp and extension)
+ max_len = 100
+ if len(safe) > max_len:
+ safe = safe[:max_len]
+
+ # If empty after sanitization, use default
+ if not safe:
+ safe = "generated"
+
+ return safe
+
+
+def generate_timestamp_filename(
+ base_name: str,
+ extension: str = "png"
+) -> str:
+ """
+ Generate a filename with timestamp.
+
+ Format: {base_name}_{YYYYMMDD_HHMMSS}.{extension}
+
+ Args:
+ base_name: Base name for file (will be sanitized)
+ extension: File extension (default: "png")
+
+ Returns:
+ Filename string with timestamp
+ """
+ safe_name = sanitize_filename(base_name)
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ return f"{safe_name}_{timestamp}.{extension}"
+
+
+def get_unique_filename(directory: Path, base_name: str, extension: str = "png") -> Path:
+ """
+ Generate a unique filename in a directory.
+
+ If file exists, appends a number: _1, _2, etc.
+
+ Args:
+ directory: Directory where file will be saved
+ base_name: Base name for file
+ extension: File extension
+
+ Returns:
+ Path object with unique filename
+ """
+ safe_name = sanitize_filename(base_name)
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+ # Try without counter first
+ filename = f"{safe_name}_{timestamp}.{extension}"
+ path = directory / filename
+
+ if not path.exists():
+ return path
+
+ # Add counter if file exists
+ counter = 1
+ while True:
+ filename = f"{safe_name}_{timestamp}_{counter}.{extension}"
+ path = directory / filename
+ if not path.exists():
+ return path
+ counter += 1
+
+
+# =============================================================================
+# IMAGE SAVE/LOAD
+# =============================================================================
+
+def ensure_pil_image(obj: Union[Image.Image, str, Path], context: str = "") -> Image.Image:
+ """
+ Ensure the provided object is a PIL Image.
+
+ Accepts a PIL Image directly, or a string/Path pointing to an image file.
+
+ Args:
+ obj: PIL Image, file path string, or Path
+ context: Optional context string for clearer error messages
+
+ Returns:
+ PIL Image object
+
+ Raises:
+ TypeError: If the object cannot be converted to an Image
+ FileNotFoundError: If a provided path does not exist
+ IOError: If the path cannot be opened as an image
+ """
+ if isinstance(obj, Image.Image):
+ return obj
+
+ # Handle path-like inputs
+ if isinstance(obj, (str, Path)):
+ p = Path(obj)
+ if not p.exists():
+ raise FileNotFoundError(f"Image path not found: {p} {('['+context+']') if context else ''}")
+ try:
+ image = Image.open(p)
+ image.load() # Validate/load into memory
+ return image
+ except Exception as e:
+ raise IOError(f"Cannot open image at {p}: {e} {('['+context+']') if context else ''}")
+
+ raise TypeError(
+ f"Expected PIL Image or path-like, got {type(obj).__name__} {('['+context+']') if context else ''}"
+ )
+
+def save_image(
+ image: Image.Image,
+ directory: Path,
+ base_name: str,
+ metadata: Optional[Dict[str, Any]] = None
+) -> Tuple[Path, Optional[Path]]:
+ """
+ Save an image and optionally its metadata.
+
+ Args:
+ image: PIL Image to save
+ directory: Directory to save in
+ base_name: Base name for files
+ metadata: Optional metadata dictionary to save as JSON
+
+ Returns:
+ Tuple of (image_path, metadata_path)
+ metadata_path is None if metadata not provided
+ """
+ # Ensure directory exists
+ directory.mkdir(parents=True, exist_ok=True)
+
+ # Generate unique filename
+ image_path = get_unique_filename(directory, base_name, "png")
+
+ # Save image (uncompressed PNG for maximum quality)
+ try:
+ # Normalize/validate input to avoid 'str' object errors
+ image = ensure_pil_image(image, context="save_image")
+ image.save(image_path, format="PNG", compress_level=0)
+ logger.info(f"Saved image: {image_path}")
+ except Exception as e:
+ logger.error(f"Failed to save image (type={type(image).__name__}): {e}")
+ raise
+
+ # Save metadata if provided
+ metadata_path = None
+ if metadata is not None:
+ metadata_path = image_path.with_suffix(".json")
+ try:
+ save_metadata(metadata_path, metadata)
+ logger.info(f"Saved metadata: {metadata_path}")
+ except Exception as e:
+ logger.error(f"Failed to save metadata: {e}")
+ # Don't raise - image is saved, metadata is optional
+
+ return image_path, metadata_path
+
+
+def load_image(file_path: Path) -> Image.Image:
+ """
+ Load an image from disk.
+
+ Args:
+ file_path: Path to image file
+
+ Returns:
+ PIL Image object
+
+ Raises:
+ FileNotFoundError: If file doesn't exist
+ IOError: If file can't be read as image
+ """
+ if not file_path.exists():
+ raise FileNotFoundError(f"Image not found: {file_path}")
+
+ try:
+ image = Image.open(file_path)
+ logger.debug(f"Loaded image: {file_path}")
+ return image
+ except Exception as e:
+ logger.error(f"Failed to load image {file_path}: {e}")
+ raise IOError(f"Cannot read image: {e}")
+
+
+# =============================================================================
+# METADATA MANAGEMENT
+# =============================================================================
+
+def save_metadata(file_path: Path, metadata: Dict[str, Any]):
+ """
+ Save metadata dictionary as JSON.
+
+ Args:
+ file_path: Path for JSON file
+ metadata: Dictionary to save
+
+ Raises:
+ IOError: If write fails
+ """
+ try:
+ with open(file_path, 'w', encoding='utf-8') as f:
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
+ except Exception as e:
+ logger.error(f"Failed to save metadata to {file_path}: {e}")
+ raise IOError(f"Cannot write metadata: {e}")
+
+
+def load_metadata(file_path: Path) -> Dict[str, Any]:
+ """
+ Load metadata from JSON file.
+
+ Args:
+ file_path: Path to JSON file
+
+ Returns:
+ Metadata dictionary
+
+ Raises:
+ FileNotFoundError: If file doesn't exist
+ json.JSONDecodeError: If file is not valid JSON
+ """
+ if not file_path.exists():
+ raise FileNotFoundError(f"Metadata file not found: {file_path}")
+
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ metadata = json.load(f)
+ logger.debug(f"Loaded metadata: {file_path}")
+ return metadata
+ except json.JSONDecodeError as e:
+ logger.error(f"Invalid JSON in {file_path}: {e}")
+ raise
+ except Exception as e:
+ logger.error(f"Failed to load metadata from {file_path}: {e}")
+ raise IOError(f"Cannot read metadata: {e}")
+
+
+def create_generation_metadata(
+ prompt: str,
+ backend: str,
+ aspect_ratio: str,
+ temperature: float,
+ input_images: Optional[list] = None,
+ generation_time: Optional[float] = None,
+ **kwargs
+) -> Dict[str, Any]:
+ """
+ Create a standard metadata dictionary for a generation.
+
+ Args:
+ prompt: Generation prompt
+ backend: Backend used
+ aspect_ratio: Aspect ratio used
+ temperature: Temperature used
+ input_images: Optional list of input image paths
+ generation_time: Optional time taken (seconds)
+ **kwargs: Additional custom fields
+
+ Returns:
+ Metadata dictionary
+ """
+ metadata = {
+ "timestamp": datetime.now().isoformat(),
+ "prompt": prompt,
+ "backend": backend,
+ "aspect_ratio": aspect_ratio,
+ "temperature": temperature,
+ "version": "2.0.0-streamlit"
+ }
+
+ if input_images:
+ metadata["input_images"] = input_images
+
+ if generation_time is not None:
+ metadata["generation_time_seconds"] = round(generation_time, 2)
+
+ # Add any custom fields
+ metadata.update(kwargs)
+
+ return metadata
+
+
+# =============================================================================
+# IMAGE HASHING (for metadata)
+# =============================================================================
+
+def compute_image_hash(image: Image.Image) -> str:
+ """
+ Compute SHA-256 hash of image data.
+
+ Useful for detecting if input images have changed.
+
+ Args:
+ image: PIL Image
+
+ Returns:
+ Hex string of SHA-256 hash
+ """
+ # Convert to bytes
+ img_bytes = image.tobytes()
+
+ # Compute hash
+ hash_obj = hashlib.sha256(img_bytes)
+ return hash_obj.hexdigest()
+
+
+# =============================================================================
+# DIRECTORY UTILITIES
+# =============================================================================
+
+def ensure_directory_exists(directory: Path):
+ """
+ Ensure a single directory exists.
+
+ Creates the directory (and any parent directories) if it doesn't exist.
+
+ Args:
+ directory: Path to directory to ensure exists
+ """
+ directory.mkdir(parents=True, exist_ok=True)
+ logger.debug(f"Ensured directory exists: {directory}")
+
+
+def ensure_output_directories():
+ """
+ Ensure all output directories exist.
+
+ Creates directories defined in Settings if they don't exist.
+ """
+ directories = [
+ Settings.OUTPUT_DIR,
+ Settings.CHARACTER_SHEETS_DIR,
+ Settings.WARDROBE_CHANGES_DIR,
+ Settings.COMPOSITIONS_DIR,
+ Settings.STANDARD_DIR
+ ]
+
+ for directory in directories:
+ directory.mkdir(parents=True, exist_ok=True)
+ logger.debug(f"Ensured directory exists: {directory}")
+
+
+def get_output_directory_for_type(generation_type: str) -> Path:
+ """
+ Get the appropriate output directory for a generation type.
+
+ Args:
+ generation_type: Type of generation
+ ("character_sheet", "wardrobe", "composition", "standard")
+
+ Returns:
+ Path to output directory
+
+ Raises:
+ ValueError: If generation_type is unknown
+ """
+ mapping = {
+ "character_sheet": Settings.CHARACTER_SHEETS_DIR,
+ "wardrobe": Settings.WARDROBE_CHANGES_DIR,
+ "composition": Settings.COMPOSITIONS_DIR,
+ "standard": Settings.STANDARD_DIR
+ }
+
+ if generation_type not in mapping:
+ raise ValueError(f"Unknown generation type: {generation_type}")
+
+ return mapping[generation_type]
+
+
+def list_recent_generations(
+ generation_type: str,
+ count: int = 10
+) -> list:
+ """
+ List recent generation files in a directory.
+
+ Args:
+ generation_type: Type of generation
+ count: Number of recent files to return
+
+ Returns:
+ List of (image_path, metadata_path) tuples, newest first
+ """
+ directory = get_output_directory_for_type(generation_type)
+
+ # Get all PNG files
+ png_files = sorted(
+ directory.glob("*.png"),
+ key=lambda p: p.stat().st_mtime,
+ reverse=True
+ )
+
+ # Limit to count
+ png_files = png_files[:count]
+
+ # Pair with metadata files
+ results = []
+ for png_path in png_files:
+ json_path = png_path.with_suffix(".json")
+ results.append((png_path, json_path if json_path.exists() else None))
+
+ return results
diff --git a/character_forge_image/utils/library_manager.py b/character_forge_image/utils/library_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6437914b15d13d09af3940205a150248550c7b4
--- /dev/null
+++ b/character_forge_image/utils/library_manager.py
@@ -0,0 +1,620 @@
+"""
+Image Library Manager
+=====================
+
+Central manager for the image library system. Handles registration,
+retrieval, search, and management of all generated images.
+"""
+
+import json
+import shutil
+from pathlib import Path
+from datetime import datetime
+from typing import Optional, List, Dict, Any, Tuple
+from PIL import Image
+import hashlib
+
+from utils.logging_utils import get_logger
+from config.settings import Settings
+
+logger = get_logger(__name__)
+
+
+class LibraryManager:
+ """
+ Central manager for image library.
+
+ Manages a JSON-based registry of all generated images with metadata,
+ thumbnails, search, and filtering capabilities.
+ """
+
+ def __init__(self, library_path: Path = None):
+ """
+ Initialize library manager.
+
+ Args:
+ library_path: Path to library directory (default: outputs/.library)
+ """
+ self.library_path = library_path or (Settings.OUTPUT_DIR / ".library")
+ self.index_file = self.library_path / "index.json"
+ self.thumbnails_dir = Settings.OUTPUT_DIR / ".thumbnails"
+
+ self.ensure_directories()
+ self._index_cache = None
+ self._cache_dirty = False
+
+ def ensure_directories(self):
+ """Ensure library and thumbnail directories exist."""
+ self.library_path.mkdir(parents=True, exist_ok=True)
+ self.thumbnails_dir.mkdir(parents=True, exist_ok=True)
+ logger.debug(f"Library directories ensured: {self.library_path}, {self.thumbnails_dir}")
+
+ def _load_index(self) -> Dict[str, Any]:
+ """
+ Load library index from disk.
+
+ Returns:
+ Library index dictionary
+ """
+ if self._index_cache is not None and not self._cache_dirty:
+ return self._index_cache
+
+ if not self.index_file.exists():
+ # Create new index
+ index = {
+ "version": "1.0",
+ "last_updated": datetime.now().isoformat(),
+ "entries": []
+ }
+ self._save_index(index)
+ return index
+
+ try:
+ with open(self.index_file, 'r', encoding='utf-8') as f:
+ index = json.load(f)
+ self._index_cache = index
+ self._cache_dirty = False
+ logger.debug(f"Loaded library index with {len(index.get('entries', []))} entries")
+ return index
+ except Exception as e:
+ logger.error(f"Failed to load library index: {e}")
+ # Return empty index on error
+ return {
+ "version": "1.0",
+ "last_updated": datetime.now().isoformat(),
+ "entries": []
+ }
+
+ def _save_index(self, index: Dict[str, Any]):
+ """
+ Save library index to disk (atomic write).
+
+ Args:
+ index: Library index dictionary
+ """
+ try:
+ # Update timestamp
+ index["last_updated"] = datetime.now().isoformat()
+
+ # Atomic write: write to temp file, then rename
+ temp_file = self.index_file.with_suffix('.tmp')
+ with open(temp_file, 'w', encoding='utf-8') as f:
+ json.dump(index, f, indent=2, ensure_ascii=False)
+
+ # Rename to actual file (atomic on most systems)
+ temp_file.replace(self.index_file)
+
+ self._index_cache = index
+ self._cache_dirty = False
+ logger.debug(f"Saved library index with {len(index['entries'])} entries")
+ except Exception as e:
+ logger.error(f"Failed to save library index: {e}")
+ raise
+
+ def create_thumbnail(
+ self,
+ image: Image.Image,
+ size: int = 256,
+ quality: int = 85
+ ) -> Image.Image:
+ """
+ Generate thumbnail for library display.
+
+ Args:
+ image: Source PIL Image
+ size: Maximum dimension in pixels (default: 256)
+ quality: JPEG quality (default: 85)
+
+ Returns:
+ Thumbnail PIL Image
+ """
+ # Calculate thumbnail size maintaining aspect ratio
+ img_width, img_height = image.size
+ ratio = min(size / img_width, size / img_height)
+ new_width = int(img_width * ratio)
+ new_height = int(img_height * ratio)
+
+ # Create thumbnail
+ thumbnail = image.copy()
+ thumbnail.thumbnail((new_width, new_height), Image.Resampling.LANCZOS)
+
+ logger.debug(f"Created thumbnail: {image.size} -> {thumbnail.size}")
+ return thumbnail
+
+ def _generate_entry_id(self, name: str) -> str:
+ """
+ Generate unique entry ID.
+
+ Args:
+ name: Entry name
+
+ Returns:
+ Unique ID string (timestamp + hash)
+ """
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ # Add hash of name + timestamp for uniqueness
+ hash_input = f"{name}_{timestamp}_{datetime.now().microsecond}"
+ hash_suffix = hashlib.md5(hash_input.encode()).hexdigest()[:6]
+ return f"{timestamp}_{hash_suffix}"
+
+ def register_image(
+ self,
+ image: Image.Image,
+ name: str,
+ type: str,
+ metadata: Dict[str, Any],
+ description: str = "",
+ tags: List[str] = None
+ ) -> str:
+ """
+ Register generated image in library.
+
+ Args:
+ image: PIL Image to register
+ name: User-facing name
+ type: Generation type ("character_sheet", "wardrobe", "composition", "standard")
+ metadata: Generation metadata dict (includes prompt, backend, etc.)
+ description: Optional user description
+ tags: Optional list of tags
+
+ Returns:
+ Entry ID of registered image
+ """
+ try:
+ # Generate entry ID
+ entry_id = self._generate_entry_id(name)
+
+ # Determine paths
+ image_filename = f"{name}_{entry_id}.png"
+ thumbnail_filename = f"{name}_{entry_id}_thumb.png"
+ metadata_filename = f"{name}_{entry_id}.json"
+
+ # Determine output directory based on type
+ type_dir_map = {
+ "character_sheet": Settings.CHARACTER_SHEETS_DIR,
+ "wardrobe": Settings.WARDROBE_CHANGES_DIR,
+ "composition": Settings.COMPOSITIONS_DIR,
+ "standard": Settings.STANDARD_DIR
+ }
+ output_dir = type_dir_map.get(type, Settings.STANDARD_DIR)
+
+ image_path = output_dir / image_filename
+ thumbnail_path = self.thumbnails_dir / thumbnail_filename
+ metadata_path = output_dir / metadata_filename
+
+ # Save full image (uncompressed PNG for maximum quality)
+ image.save(image_path, format='PNG', compress_level=0)
+ logger.info(f"Saved library image: {image_path}")
+
+ # Generate and save thumbnail
+ thumbnail = self.create_thumbnail(image)
+ thumbnail.save(thumbnail_path, format='PNG')
+ logger.info(f"Saved thumbnail: {thumbnail_path}")
+
+ # Save metadata
+ with open(metadata_path, 'w', encoding='utf-8') as f:
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
+
+ # Create library entry
+ entry = {
+ "id": entry_id,
+ "name": name,
+ "description": description,
+ "tags": tags or [],
+ "type": type,
+ "backend": metadata.get("backend", "Unknown"),
+ "created_at": datetime.now().isoformat(),
+ "image_path": str(image_path.relative_to(Settings.PROJECT_ROOT)),
+ "thumbnail_path": str(thumbnail_path.relative_to(Settings.PROJECT_ROOT)),
+ "metadata_path": str(metadata_path.relative_to(Settings.PROJECT_ROOT)),
+ "width": image.width,
+ "height": image.height,
+ "aspect_ratio": f"{image.width}:{image.height}",
+ "file_size_bytes": image_path.stat().st_size,
+ "prompt": metadata.get("prompt", ""),
+ "temperature": metadata.get("temperature", 0.4),
+ "input_images_count": metadata.get("input_images_count", 0),
+ "times_used": 0,
+ "last_used": None,
+ "favorite": False
+ }
+
+ # Add to index
+ index = self._load_index()
+ index["entries"].append(entry)
+ self._save_index(index)
+
+ logger.info(f"✅ Registered image in library: {name} (ID: {entry_id})")
+ return entry_id
+
+ except Exception as e:
+ logger.error(f"Failed to register image in library: {e}")
+ raise
+
+ def get_entries(
+ self,
+ filter_type: str = None,
+ search: str = None,
+ tags: List[str] = None,
+ favorites_only: bool = False,
+ sort_by: str = "newest",
+ limit: int = 100,
+ offset: int = 0
+ ) -> List[Dict[str, Any]]:
+ """
+ Get library entries with optional filtering.
+
+ Args:
+ filter_type: Filter by type ("character_sheet", "wardrobe", etc.)
+ search: Search query (matches name, description, prompt)
+ tags: Filter by tags (must have all tags)
+ favorites_only: Only return favorites
+ sort_by: Sort method ("newest", "oldest", "most_used", "name")
+ limit: Maximum number of entries to return
+ offset: Offset for pagination
+
+ Returns:
+ List of entry dictionaries
+ """
+ index = self._load_index()
+ entries = index.get("entries", [])
+
+ # Filter by type
+ if filter_type:
+ entries = [e for e in entries if e.get("type") == filter_type]
+
+ # Filter by favorites
+ if favorites_only:
+ entries = [e for e in entries if e.get("favorite", False)]
+
+ # Filter by tags
+ if tags:
+ entries = [
+ e for e in entries
+ if all(tag in e.get("tags", []) for tag in tags)
+ ]
+
+ # Search
+ if search:
+ search_lower = search.lower()
+ entries = [
+ e for e in entries
+ if search_lower in e.get("name", "").lower()
+ or search_lower in e.get("description", "").lower()
+ or search_lower in e.get("prompt", "").lower()
+ or any(search_lower in tag.lower() for tag in e.get("tags", []))
+ ]
+
+ # Sort
+ if sort_by == "newest":
+ entries.sort(key=lambda e: e.get("created_at", ""), reverse=True)
+ elif sort_by == "oldest":
+ entries.sort(key=lambda e: e.get("created_at", ""))
+ elif sort_by == "most_used":
+ entries.sort(key=lambda e: e.get("times_used", 0), reverse=True)
+ elif sort_by == "name":
+ entries.sort(key=lambda e: e.get("name", "").lower())
+
+ # Pagination
+ total = len(entries)
+ entries = entries[offset:offset + limit]
+
+ logger.debug(f"Retrieved {len(entries)} entries (total: {total}, filters: type={filter_type}, search={search})")
+ return entries
+
+ def get_entry(self, entry_id: str) -> Optional[Dict[str, Any]]:
+ """
+ Get single entry by ID.
+
+ Args:
+ entry_id: Entry ID
+
+ Returns:
+ Entry dictionary or None if not found
+ """
+ index = self._load_index()
+ entries = index.get("entries", [])
+
+ for entry in entries:
+ if entry.get("id") == entry_id:
+ logger.debug(f"Retrieved entry: {entry_id}")
+ return entry
+
+ logger.warning(f"Entry not found: {entry_id}")
+ return None
+
+ def load_image(self, entry_id: str) -> Optional[Image.Image]:
+ """
+ Load image from library entry.
+
+ Args:
+ entry_id: Entry ID
+
+ Returns:
+ PIL Image or None if not found
+ """
+ entry = self.get_entry(entry_id)
+ if not entry:
+ return None
+
+ try:
+ image_path = Settings.PROJECT_ROOT / entry["image_path"]
+ if not image_path.exists():
+ logger.error(f"Image file not found: {image_path}")
+ return None
+
+ image = Image.open(image_path)
+
+ # Update usage stats
+ self.update_entry(entry_id, {
+ "times_used": entry.get("times_used", 0) + 1,
+ "last_used": datetime.now().isoformat()
+ })
+
+ logger.info(f"Loaded image from library: {entry['name']} ({image.size})")
+ return image
+
+ except Exception as e:
+ logger.error(f"Failed to load image: {e}")
+ return None
+
+ def load_thumbnail(self, entry_id: str) -> Optional[Image.Image]:
+ """
+ Load thumbnail from library entry.
+
+ Args:
+ entry_id: Entry ID
+
+ Returns:
+ PIL Image (thumbnail) or None if not found
+ """
+ entry = self.get_entry(entry_id)
+ if not entry:
+ return None
+
+ try:
+ thumbnail_path = Settings.PROJECT_ROOT / entry["thumbnail_path"]
+ if not thumbnail_path.exists():
+ logger.warning(f"Thumbnail not found: {thumbnail_path}")
+ return None
+
+ thumbnail = Image.open(thumbnail_path)
+ return thumbnail
+
+ except Exception as e:
+ logger.error(f"Failed to load thumbnail: {e}")
+ return None
+
+ def update_entry(self, entry_id: str, updates: Dict[str, Any]):
+ """
+ Update entry metadata.
+
+ Args:
+ entry_id: Entry ID
+ updates: Dictionary of fields to update
+ """
+ try:
+ index = self._load_index()
+ entries = index.get("entries", [])
+
+ for i, entry in enumerate(entries):
+ if entry.get("id") == entry_id:
+ # Update fields
+ entry.update(updates)
+ entries[i] = entry
+
+ # Save
+ self._save_index(index)
+ logger.info(f"Updated library entry: {entry_id}")
+ return
+
+ logger.warning(f"Entry not found for update: {entry_id}")
+
+ except Exception as e:
+ logger.error(f"Failed to update entry: {e}")
+ raise
+
+ def delete_entry(self, entry_id: str, delete_files: bool = False):
+ """
+ Remove entry from library.
+
+ Args:
+ entry_id: Entry ID to delete
+ delete_files: If True, also delete image, thumbnail, and metadata files
+ """
+ try:
+ index = self._load_index()
+ entries = index.get("entries", [])
+
+ # Find and remove entry
+ entry_to_delete = None
+ new_entries = []
+ for entry in entries:
+ if entry.get("id") == entry_id:
+ entry_to_delete = entry
+ else:
+ new_entries.append(entry)
+
+ if entry_to_delete is None:
+ logger.warning(f"Entry not found for deletion: {entry_id}")
+ return
+
+ # Delete files if requested
+ if delete_files and entry_to_delete:
+ try:
+ # Delete image
+ image_path = Settings.PROJECT_ROOT / entry_to_delete["image_path"]
+ if image_path.exists():
+ image_path.unlink()
+ logger.info(f"Deleted image file: {image_path}")
+
+ # Delete thumbnail
+ thumbnail_path = Settings.PROJECT_ROOT / entry_to_delete["thumbnail_path"]
+ if thumbnail_path.exists():
+ thumbnail_path.unlink()
+ logger.info(f"Deleted thumbnail file: {thumbnail_path}")
+
+ # Delete metadata
+ metadata_path = Settings.PROJECT_ROOT / entry_to_delete["metadata_path"]
+ if metadata_path.exists():
+ metadata_path.unlink()
+ logger.info(f"Deleted metadata file: {metadata_path}")
+
+ except Exception as e:
+ logger.error(f"Error deleting files: {e}")
+
+ # Update index
+ index["entries"] = new_entries
+ self._save_index(index)
+
+ logger.info(f"✅ Deleted library entry: {entry_id} (files deleted: {delete_files})")
+
+ except Exception as e:
+ logger.error(f"Failed to delete entry: {e}")
+ raise
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ Get library statistics.
+
+ Returns:
+ Dictionary with stats (total entries, by type, total size, etc.)
+ """
+ index = self._load_index()
+ entries = index.get("entries", [])
+
+ # Count by type
+ type_counts = {}
+ for entry in entries:
+ type_name = entry.get("type", "unknown")
+ type_counts[type_name] = type_counts.get(type_name, 0) + 1
+
+ # Total file size
+ total_size = sum(entry.get("file_size_bytes", 0) for entry in entries)
+
+ # Favorites
+ favorites_count = sum(1 for entry in entries if entry.get("favorite", False))
+
+ return {
+ "total_entries": len(entries),
+ "by_type": type_counts,
+ "total_size_bytes": total_size,
+ "total_size_mb": round(total_size / (1024 * 1024), 2),
+ "favorites_count": favorites_count,
+ "last_updated": index.get("last_updated")
+ }
+
+ def rebuild_index(self):
+ """
+ Rebuild library index from file system.
+ Useful for recovery if index becomes corrupted.
+ """
+ logger.info("Rebuilding library index from file system...")
+
+ new_entries = []
+
+ # Scan all output directories
+ for type_name, directory in [
+ ("character_sheet", Settings.CHARACTER_SHEETS_DIR),
+ ("wardrobe", Settings.WARDROBE_CHANGES_DIR),
+ ("composition", Settings.COMPOSITIONS_DIR),
+ ("standard", Settings.STANDARD_DIR)
+ ]:
+ if not directory.exists():
+ continue
+
+ # Find all PNG files
+ for image_path in directory.glob("*.png"):
+ metadata_path = image_path.with_suffix('.json')
+
+ # Skip if no metadata
+ if not metadata_path.exists():
+ logger.warning(f"No metadata for image: {image_path}")
+ continue
+
+ try:
+ # Load metadata
+ with open(metadata_path, 'r', encoding='utf-8') as f:
+ metadata = json.load(f)
+
+ # Load image for size
+ image = Image.open(image_path)
+
+ # Check for existing thumbnail or create new
+ thumbnail_pattern = f"{image_path.stem}_thumb.png"
+ thumbnail_path = self.thumbnails_dir / thumbnail_pattern
+
+ if not thumbnail_path.exists():
+ thumbnail = self.create_thumbnail(image)
+ thumbnail.save(thumbnail_path, format='PNG')
+
+ # Extract or generate entry ID
+ # Try to get from filename or generate new
+ parts = image_path.stem.split('_')
+ if len(parts) >= 3:
+ entry_id = f"{parts[-3]}_{parts[-2]}_{parts[-1]}"
+ else:
+ entry_id = self._generate_entry_id(image_path.stem)
+
+ # Create entry
+ entry = {
+ "id": entry_id,
+ "name": metadata.get("name", image_path.stem),
+ "description": "",
+ "tags": [],
+ "type": type_name,
+ "backend": metadata.get("backend", "Unknown"),
+ "created_at": metadata.get("timestamp", datetime.fromtimestamp(image_path.stat().st_mtime).isoformat()),
+ "image_path": str(image_path.relative_to(Settings.PROJECT_ROOT)),
+ "thumbnail_path": str(thumbnail_path.relative_to(Settings.PROJECT_ROOT)),
+ "metadata_path": str(metadata_path.relative_to(Settings.PROJECT_ROOT)),
+ "width": image.width,
+ "height": image.height,
+ "aspect_ratio": f"{image.width}:{image.height}",
+ "file_size_bytes": image_path.stat().st_size,
+ "prompt": metadata.get("prompt", ""),
+ "temperature": metadata.get("temperature", 0.4),
+ "input_images_count": metadata.get("input_images_count", 0),
+ "times_used": 0,
+ "last_used": None,
+ "favorite": False
+ }
+
+ new_entries.append(entry)
+ logger.debug(f"Rebuilt entry: {entry['name']}")
+
+ except Exception as e:
+ logger.error(f"Failed to rebuild entry for {image_path}: {e}")
+ continue
+
+ # Create new index
+ index = {
+ "version": "1.0",
+ "last_updated": datetime.now().isoformat(),
+ "entries": new_entries
+ }
+
+ self._save_index(index)
+ logger.info(f"✅ Rebuilt library index with {len(new_entries)} entries")
+
+ return len(new_entries)
diff --git a/character_forge_image/utils/logging_utils.md b/character_forge_image/utils/logging_utils.md
new file mode 100644
index 0000000000000000000000000000000000000000..8827534b2f7ade9ec7b1cb85166c38c911499f37
--- /dev/null
+++ b/character_forge_image/utils/logging_utils.md
@@ -0,0 +1,268 @@
+# logging_utils.py
+
+## Purpose
+Centralized logging system for Nano Banana Streamlit. Provides both file-based logging (persistent) and memory-based logging (for UI display).
+
+## Responsibilities
+- Set up loggers with consistent configuration
+- Write logs to rotating file on disk
+- Store recent logs in memory for UI display
+- Provide utilities for structured logging
+- Thread-safe log storage and retrieval
+- Context managers for temporary log level changes
+
+## Dependencies
+
+### Imports
+- `logging` - Python standard logging
+- `threading` - Thread synchronization
+- `collections.deque` - Thread-safe queue for log storage
+- `logging.handlers.RotatingFileHandler` - Rotating log files
+- `config.settings.Settings` - Log configuration
+
+### Used By
+- All services - For logging generation progress
+- All pages - For displaying logs in UI
+- Backend clients - For logging API calls
+- Utilities - For logging validation/file operations
+
+## Public Interface
+
+### Logger Setup
+
+#### `setup_logger(name: str, level: str = None) -> logging.Logger`
+Creates a fully configured logger with file and memory handlers.
+
+**Parameters:**
+- `name`: Logger name (usually module name or `__name__`)
+- `level`: Optional log level override (default: from Settings)
+
+**Returns:** Configured logger instance
+
+**Example:**
+```python
+from utils.logging_utils import setup_logger
+
+logger = setup_logger('my_module')
+logger.info("This logs to file and memory")
+```
+
+#### `get_logger(name: str) -> logging.Logger`
+Get or create a logger (convenience function).
+
+If logger doesn't exist, creates it with `setup_logger()`.
+If it exists, returns existing instance.
+
+**Example:**
+```python
+from utils.logging_utils import get_logger
+
+logger = get_logger(__name__)
+logger.info("Logging from this module")
+```
+
+### Log Retrieval (for UI)
+
+#### `get_recent_logs(count: int = 100) -> List[str]`
+Returns recent log messages as a list.
+
+**Parameters:**
+- `count`: Maximum number of messages (default: 100)
+
+**Returns:** List of formatted log strings
+
+**Example:**
+```python
+logs = get_recent_logs(50)
+for log in logs:
+ st.text(log)
+```
+
+#### `get_recent_logs_as_string(count: int = 100) -> str`
+Returns recent log messages as a single string (one line per message).
+
+**Example:**
+```python
+logs_text = get_recent_logs_as_string(100)
+st.text_area("Logs", logs_text)
+```
+
+#### `clear_log_memory()`
+Clears the in-memory log queue.
+
+Use when starting a new generation session.
+
+#### `get_log_count() -> int`
+Returns current number of messages in memory queue.
+
+### Utility Functions
+
+#### `log_function_call(logger, func_name: str, **kwargs)`
+Structured logging for function calls.
+
+**Example:**
+```python
+log_function_call(logger, "generate_image",
+ prompt="sunset", aspect_ratio="16:9")
+# Logs: "Calling generate_image(prompt=sunset, aspect_ratio=16:9)"
+```
+
+#### `log_stage(logger, stage: str, message: str)`
+Log a pipeline stage with separators.
+
+**Example:**
+```python
+log_stage(logger, "Stage 1/6", "Generating front portrait")
+# Logs with ==== separators for visibility
+```
+
+#### `log_error_with_context(logger, error: Exception, context: dict)`
+Log an error with additional context.
+
+**Example:**
+```python
+try:
+ generate_image(...)
+except Exception as e:
+ log_error_with_context(logger, e, {
+ 'prompt': prompt,
+ 'backend': backend,
+ 'attempt': 3
+ })
+```
+
+### Context Managers
+
+#### `LoggingContext(logger_name: str, level: str)`
+Temporarily change log level.
+
+**Example:**
+```python
+from utils.logging_utils import LoggingContext
+
+with LoggingContext('my_module', 'DEBUG'):
+ # Temporarily log at DEBUG level
+ logger.debug("This will be logged")
+# Returns to original level
+```
+
+## Internal Components
+
+### `MemoryLogHandler`
+Custom logging handler that stores messages in thread-safe queue.
+
+Inherits from `logging.Handler` and overrides `emit()` method.
+
+### `_log_queue`
+Global `deque(maxlen=1000)` storing recent messages.
+Thread-safe with `_log_lock`.
+
+### `_log_lock`
+Threading lock for synchronizing queue access.
+
+## Configuration
+
+All configuration from `Settings`:
+- `LOG_LEVEL`: Default log level ("INFO")
+- `LOG_FORMAT`: Message format string
+- `LOG_DATE_FORMAT`: Date format string
+- `LOG_FILE`: Path to log file
+- `LOG_MAX_BYTES`: Max file size before rotation (10MB)
+- `LOG_BACKUP_COUNT`: Number of backup files (5)
+
+## Thread Safety
+
+All log queue operations protected by `_log_lock`:
+- `_log_queue.append()` - Adding messages
+- `list(_log_queue)` - Reading messages
+- `_log_queue.clear()` - Clearing queue
+
+Safe to use from multiple threads (Streamlit reruns, background tasks).
+
+## Known Limitations
+- Memory queue limited to 1000 messages (older messages discarded)
+- File logs kept on disk (5 files × 10MB = 50MB max)
+- No remote logging (syslog, cloud)
+- No structured logging (JSON format)
+- No log filtering by level in UI retrieval
+
+## Future Improvements
+- Add structured logging (JSON Lines format)
+- Add log level filtering for UI display
+- Add log search/filtering capabilities
+- Add remote logging option
+- Add log analytics dashboard
+- Add log export functionality
+- Add colored console output for development
+
+## Usage Examples
+
+### Service Logging
+```python
+from utils.logging_utils import get_logger
+
+class CharacterForgeService:
+ def __init__(self):
+ self.logger = get_logger(__name__)
+
+ def generate(self, prompt):
+ self.logger.info(f"Starting generation: {prompt}")
+ try:
+ result = self._generate(prompt)
+ self.logger.info("Generation successful")
+ return result
+ except Exception as e:
+ self.logger.error(f"Generation failed: {e}")
+ raise
+```
+
+### UI Log Display
+```python
+import streamlit as st
+from utils.logging_utils import get_recent_logs_as_string
+
+st.subheader("Generation Log")
+logs = get_recent_logs_as_string(100)
+st.text_area("Logs", logs, height=300)
+
+if st.button("Clear Logs"):
+ clear_log_memory()
+ st.rerun()
+```
+
+### Pipeline Stage Logging
+```python
+from utils.logging_utils import get_logger, log_stage
+
+logger = get_logger(__name__)
+
+for stage_num in range(1, 7):
+ log_stage(logger, f"Stage {stage_num}/6",
+ f"Generating {view_name}")
+ # ... generation code ...
+```
+
+## Testing
+- Test logger creation and configuration
+- Test file handler writes to correct file
+- Test memory handler stores messages
+- Test log retrieval with various counts
+- Test clear_log_memory()
+- Test thread safety (concurrent access)
+- Test context manager log level changes
+- Test utility functions format correctly
+
+## Related Files
+- `config/settings.py` - Log configuration constants
+- All services - Use get_logger(__name__)
+- All pages - Display logs with get_recent_logs()
+- `tests/test_logging_utils.py` - Unit tests
+
+## Change History
+- 2025-10-23: Initial creation for Streamlit migration
+ - Extracted from character_forge.py (lines 44-143)
+ - Generalized for all modules (not just Character Forge)
+ - Added utility functions for structured logging
+ - Added context manager for temporary log levels
+ - Integrated with Settings configuration
+ - Added comprehensive documentation
diff --git a/character_forge_image/utils/logging_utils.py b/character_forge_image/utils/logging_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c434da9b2f01c1e18e5bb4a1a7d60b642eb4619
--- /dev/null
+++ b/character_forge_image/utils/logging_utils.py
@@ -0,0 +1,271 @@
+"""
+Logging Utilities
+=================
+
+Centralized logging setup for Nano Banana Streamlit.
+Provides both file logging and in-memory log storage for UI display.
+"""
+
+import logging
+import threading
+from collections import deque
+from logging.handlers import RotatingFileHandler
+from pathlib import Path
+from typing import List
+
+from config.settings import Settings
+
+
+# =============================================================================
+# IN-MEMORY LOG STORAGE FOR UI DISPLAY
+# =============================================================================
+
+# Global log storage queue (thread-safe)
+_log_queue = deque(maxlen=1000) # Keep last 1000 messages
+_log_lock = threading.Lock()
+
+
+class MemoryLogHandler(logging.Handler):
+ """
+ Custom logging handler that stores log messages in memory.
+
+ Stores messages in a thread-safe queue that can be retrieved
+ for display in the Streamlit UI.
+ """
+
+ def emit(self, record):
+ """
+ Handle a log record by storing it in the queue.
+
+ Args:
+ record: LogRecord to process
+ """
+ try:
+ msg = self.format(record)
+ with _log_lock:
+ _log_queue.append(msg)
+ except Exception:
+ self.handleError(record)
+
+
+# =============================================================================
+# LOGGER SETUP FUNCTIONS
+# =============================================================================
+
+def setup_logger(name: str, level: str = None) -> logging.Logger:
+ """
+ Set up a logger with both file and memory handlers.
+
+ Creates a logger that writes to:
+ - Rotating log file (configured in Settings)
+ - In-memory queue (for UI display)
+
+ Args:
+ name: Logger name (usually module name)
+ level: Logging level (default: from Settings.LOG_LEVEL)
+
+ Returns:
+ Configured logger instance
+ """
+ # Get or create logger
+ logger = logging.getLogger(name)
+
+ # Set level
+ log_level = level or Settings.LOG_LEVEL
+ logger.setLevel(getattr(logging, log_level))
+
+ # Clear any existing handlers (avoid duplicates)
+ logger.handlers.clear()
+
+ # Create formatter
+ formatter = logging.Formatter(
+ Settings.LOG_FORMAT,
+ datefmt=Settings.LOG_DATE_FORMAT
+ )
+
+ # File handler (rotating)
+ file_handler = RotatingFileHandler(
+ Settings.LOG_FILE,
+ maxBytes=Settings.LOG_MAX_BYTES,
+ backupCount=Settings.LOG_BACKUP_COUNT,
+ encoding='utf-8'
+ )
+ file_handler.setLevel(getattr(logging, log_level))
+ file_handler.setFormatter(formatter)
+
+ # Memory handler (for UI display)
+ memory_handler = MemoryLogHandler()
+ memory_handler.setLevel(getattr(logging, log_level))
+ memory_handler.setFormatter(formatter)
+
+ # Add handlers
+ logger.addHandler(file_handler)
+ logger.addHandler(memory_handler)
+
+ # Prevent propagation to root logger
+ logger.propagate = False
+
+ return logger
+
+
+def get_logger(name: str) -> logging.Logger:
+ """
+ Get or create a logger for a module.
+
+ If the logger hasn't been set up yet, it will be initialized
+ with default settings.
+
+ Args:
+ name: Logger name (usually __name__ from calling module)
+
+ Returns:
+ Logger instance
+ """
+ logger = logging.getLogger(name)
+
+ # If logger has no handlers, set it up
+ if not logger.handlers:
+ return setup_logger(name)
+
+ return logger
+
+
+# =============================================================================
+# LOG RETRIEVAL FUNCTIONS
+# =============================================================================
+
+def get_recent_logs(count: int = None, limit: int = None) -> List[str]:
+ """
+ Retrieve recent log messages.
+
+ Args:
+ count: Maximum number of recent messages to retrieve (deprecated, use limit)
+ limit: Maximum number of recent messages to retrieve
+
+ Returns:
+ List of log message strings
+ """
+ # Support both 'count' and 'limit' parameter names
+ n = limit if limit is not None else (count if count is not None else 100)
+
+ with _log_lock:
+ messages = list(_log_queue)
+
+ # Return last N messages
+ recent = messages[-n:] if len(messages) > n else messages
+ return recent
+
+
+def get_recent_logs_as_string(count: int = 100) -> str:
+ """
+ Retrieve recent log messages as a formatted string.
+
+ Args:
+ count: Maximum number of recent messages to retrieve
+
+ Returns:
+ Formatted string with log messages (one per line)
+ """
+ messages = get_recent_logs(count)
+ return '\n'.join(messages)
+
+
+def clear_log_memory():
+ """Clear the in-memory log queue."""
+ with _log_lock:
+ _log_queue.clear()
+
+
+def get_log_count() -> int:
+ """
+ Get the current number of messages in the log queue.
+
+ Returns:
+ Number of messages currently stored
+ """
+ with _log_lock:
+ return len(_log_queue)
+
+
+# =============================================================================
+# LOGGING CONTEXT MANAGERS
+# =============================================================================
+
+class LoggingContext:
+ """
+ Context manager for temporarily changing log level.
+
+ Usage:
+ with LoggingContext('my_module', 'DEBUG'):
+ # Code here will log at DEBUG level
+ pass
+ """
+
+ def __init__(self, logger_name: str, level: str):
+ """
+ Initialize logging context.
+
+ Args:
+ logger_name: Name of logger to modify
+ level: Temporary log level ('DEBUG', 'INFO', 'WARNING', 'ERROR')
+ """
+ self.logger = logging.getLogger(logger_name)
+ self.original_level = self.logger.level
+ self.new_level = getattr(logging, level)
+
+ def __enter__(self):
+ """Enter context - set new log level."""
+ self.logger.setLevel(self.new_level)
+ return self.logger
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """Exit context - restore original log level."""
+ self.logger.setLevel(self.original_level)
+ return False # Don't suppress exceptions
+
+
+# =============================================================================
+# UTILITY FUNCTIONS
+# =============================================================================
+
+def log_function_call(logger: logging.Logger, func_name: str, **kwargs):
+ """
+ Log a function call with its parameters.
+
+ Args:
+ logger: Logger instance to use
+ func_name: Name of function being called
+ **kwargs: Function parameters to log
+ """
+ params = ', '.join(f'{k}={v}' for k, v in kwargs.items())
+ logger.info(f"Calling {func_name}({params})")
+
+
+def log_stage(logger: logging.Logger, stage: str, message: str):
+ """
+ Log a pipeline stage.
+
+ Args:
+ logger: Logger instance to use
+ stage: Stage identifier (e.g., "Stage 1/6")
+ message: Stage description
+ """
+ separator = "=" * 60
+ logger.info(separator)
+ logger.info(f"{stage}: {message}")
+ logger.info(separator)
+
+
+def log_error_with_context(logger: logging.Logger, error: Exception, context: dict):
+ """
+ Log an error with additional context.
+
+ Args:
+ logger: Logger instance to use
+ error: Exception that occurred
+ context: Dictionary of contextual information
+ """
+ logger.error(f"Error: {str(error)}")
+ logger.error(f"Error type: {type(error).__name__}")
+ for key, value in context.items():
+ logger.error(f" {key}: {value}")
diff --git a/character_forge_image/utils/validation.md b/character_forge_image/utils/validation.md
new file mode 100644
index 0000000000000000000000000000000000000000..9989a760a2eec2c55858698d19ce502e4e9513e8
--- /dev/null
+++ b/character_forge_image/utils/validation.md
@@ -0,0 +1,407 @@
+# validation.py
+
+## Purpose
+Input validation utilities for Nano Banana Streamlit. Validates user inputs, parameters, and system state to ensure data integrity and provide clear error messages.
+
+## Responsibilities
+- Validate generation parameters (temperature, aspect ratio, backend)
+- Validate text inputs (prompts, character names)
+- Validate images (format, dimensions, file size)
+- Validate complete generation requests
+- Check backend availability
+- Provide clear, user-friendly error messages
+
+## Dependencies
+
+### Imports
+- `pathlib.Path` - File path operations
+- `PIL.Image` - Image validation
+- `config.settings.Settings` - Validation constraints
+- `utils.logging_utils.get_logger` - Logging validation errors
+
+### Used By
+- All UI pages - Validate user inputs before submission
+- All services - Validate parameters before generation
+- Backend clients - Validate configuration
+- `models/generation_request.py` - Validate request objects
+
+## Public Interface
+
+All validation functions return `Tuple[bool, Optional[str]]`:
+- `(True, None)` if valid
+- `(False, error_message)` if invalid
+
+### Parameter Validation
+
+#### `validate_temperature(temperature: float) -> Tuple[bool, Optional[str]]`
+Validates temperature is in valid range [0.0, 1.0].
+
+**Example:**
+```python
+valid, error = validate_temperature(0.5)
+if not valid:
+ st.error(error)
+```
+
+#### `validate_aspect_ratio(aspect_ratio: str) -> Tuple[bool, Optional[str]]`
+Validates aspect ratio is in Settings.ASPECT_RATIOS.
+
+Accepts both display names ("16:9 (1344x768)") and values ("16:9").
+
+**Example:**
+```python
+valid, error = validate_aspect_ratio("16:9")
+if valid:
+ # Use aspect ratio
+ pass
+```
+
+#### `validate_backend(backend: str) -> Tuple[bool, Optional[str]]`
+Validates backend is in Settings.AVAILABLE_BACKENDS.
+
+**Example:**
+```python
+valid, error = validate_backend("Gemini API (Cloud)")
+```
+
+#### `validate_prompt(prompt: str, min_length: int = 1, max_length: int = 5000) -> Tuple[bool, Optional[str]]`
+Validates text prompt length.
+
+**Parameters:**
+- `prompt`: Text to validate
+- `min_length`: Minimum length (default: 1)
+- `max_length`: Maximum length (default: 5000)
+
+**Example:**
+```python
+valid, error = validate_prompt(user_input, min_length=10)
+if not valid:
+ st.warning(error)
+```
+
+#### `validate_character_name(name: str) -> Tuple[bool, Optional[str]]`
+Validates character name (1-100 characters).
+
+**Example:**
+```python
+valid, error = validate_character_name(character_name)
+if not valid:
+ st.error(error)
+ return
+```
+
+### Image Validation
+
+#### `validate_image(image: Image.Image) -> Tuple[bool, Optional[str]]`
+Validates PIL Image object.
+
+**Checks:**
+- Is valid Image instance
+- Dimensions > 0
+- Dimensions < 8192x8192 (reasonable limit)
+- Mode is supported (RGB, RGBA, L, P)
+
+**Example:**
+```python
+valid, error = validate_image(uploaded_image)
+if not valid:
+ st.error(f"Invalid image: {error}")
+```
+
+#### `validate_image_file(file_path: Path) -> Tuple[bool, Optional[str]]`
+Validates image file on disk.
+
+**Checks:**
+- File exists
+- Is a file (not directory)
+- Has valid extension (.png, .jpg, .jpeg, .webp, .bmp)
+- Can be opened as image
+- Passes validate_image() checks
+
+**Example:**
+```python
+valid, error = validate_image_file(Path("character.png"))
+if valid:
+ image = Image.open("character.png")
+```
+
+#### `validate_image_upload_size(file_size_bytes: int) -> Tuple[bool, Optional[str]]`
+Validates uploaded file size against Settings.MAX_IMAGE_UPLOAD_SIZE.
+
+**Example:**
+```python
+if uploaded_file:
+ valid, error = validate_image_upload_size(uploaded_file.size)
+ if not valid:
+ st.error(error)
+```
+
+### Request Validation
+
+#### `validate_generation_request(...) -> Tuple[bool, Optional[str]]`
+Validates complete generation request.
+
+**Parameters:**
+- `prompt`: Text prompt
+- `backend`: Backend name
+- `aspect_ratio`: Aspect ratio
+- `temperature`: Temperature value
+- `input_images`: Optional list of input images
+
+**Validates:**
+- All individual parameters
+- Input images (if provided, max 3)
+
+**Example:**
+```python
+valid, error = validate_generation_request(
+ prompt=prompt,
+ backend=backend,
+ aspect_ratio=aspect_ratio,
+ temperature=temperature,
+ input_images=[img1, img2]
+)
+
+if not valid:
+ st.error(error)
+ return
+
+# Proceed with generation
+result = generate(...)
+```
+
+#### `validate_character_forge_request(...) -> Tuple[bool, Optional[str]]`
+Validates Character Forge-specific request.
+
+**Parameters:**
+- `character_name`: Character name
+- `initial_image`: Initial image (Face Only / Full Body modes)
+- `face_image`: Face image (Face+Body Separate)
+- `body_image`: Body image (Face+Body Separate)
+- `image_type`: Input mode type
+- `backend`: Backend name
+
+**Validates:**
+- Character name
+- Backend
+- Correct images for selected mode
+
+**Example:**
+```python
+valid, error = validate_character_forge_request(
+ character_name="Hero",
+ initial_image=None,
+ face_image=face_img,
+ body_image=body_img,
+ image_type="Face + Body (Separate)",
+ backend="Gemini API (Cloud)"
+)
+
+if not valid:
+ st.error(error)
+ return
+```
+
+### Backend Availability
+
+#### `validate_backend_available(backend: str, api_key: Optional[str] = None) -> Tuple[bool, Optional[str]]`
+Check if backend is available and configured.
+
+**For Gemini:**
+- Checks if API key is provided
+
+**For OmniGen2:**
+- Makes HTTP request to /health endpoint
+- Checks server is responding and healthy
+
+**Example:**
+```python
+valid, error = validate_backend_available(
+ backend=st.session_state.backend,
+ api_key=st.session_state.gemini_api_key
+)
+
+if not valid:
+ st.warning(error)
+ st.stop()
+```
+
+### Helper Functions
+
+#### `raise_if_invalid(is_valid: bool, error_message: Optional[str], exception_type=ValueError)`
+Convert validation result to exception.
+
+**Example:**
+```python
+valid, error = validate_temperature(temp)
+raise_if_invalid(valid, error, ValueError)
+# Raises ValueError if invalid
+```
+
+#### `log_validation_error(validation_result: Tuple[bool, Optional[str]], context: str = "")`
+Log validation error if validation failed.
+
+**Example:**
+```python
+result = validate_prompt(prompt)
+log_validation_error(result, context="user_input")
+# Logs: "Validation failed [user_input]: Prompt must be at least 1 character(s)"
+```
+
+## Usage Examples
+
+### Page Input Validation
+```python
+import streamlit as st
+from utils.validation import (
+ validate_prompt,
+ validate_backend_available,
+ validate_generation_request
+)
+
+# Get user inputs
+prompt = st.text_area("Prompt")
+backend = st.session_state.backend
+
+if st.button("Generate"):
+ # Validate prompt
+ valid, error = validate_prompt(prompt, min_length=5)
+ if not valid:
+ st.error(error)
+ st.stop()
+
+ # Check backend available
+ valid, error = validate_backend_available(backend, api_key)
+ if not valid:
+ st.warning(error)
+ st.stop()
+
+ # Validate complete request
+ valid, error = validate_generation_request(
+ prompt=prompt,
+ backend=backend,
+ aspect_ratio=aspect_ratio,
+ temperature=temperature
+ )
+ if not valid:
+ st.error(error)
+ st.stop()
+
+ # All valid - proceed
+ result = generate_image(...)
+```
+
+### Service Parameter Validation
+```python
+from utils.validation import validate_generation_request, raise_if_invalid
+
+class GenerationService:
+ def generate(self, prompt, backend, aspect_ratio, temperature, ...):
+ # Validate inputs
+ valid, error = validate_generation_request(
+ prompt, backend, aspect_ratio, temperature
+ )
+ raise_if_invalid(valid, error, ValueError)
+
+ # Proceed with generation
+ ...
+```
+
+### Backend Status Check
+```python
+import streamlit as st
+from utils.validation import validate_backend_available
+
+def render_backend_status(backend, api_key):
+ valid, error = validate_backend_available(backend, api_key)
+
+ if valid:
+ st.success(f"✅ {backend}: Ready")
+ else:
+ st.error(f"❌ {backend}: {error}")
+```
+
+## Error Messages
+
+All error messages are user-friendly and actionable:
+
+**Good Examples:**
+- ❌ "Prompt must be at least 5 character(s)" (specific, clear)
+- ❌ "File too large: 25.3MB (max: 20MB)" (includes values)
+- ❌ "OmniGen2 server not responding. Start it with: omnigen2_plugin/server.bat start" (includes solution)
+
+**Not Used:**
+- ❌ "Invalid input" (too vague)
+- ❌ "Error" (no information)
+- ❌ "NoneType has no attribute..." (technical, not user-friendly)
+
+## Validation Strategy
+
+### When to Validate
+
+1. **Before submission** (UI layer)
+ - Validate on button click
+ - Show errors immediately
+ - Prevent submission if invalid
+
+2. **In service layer** (redundant validation)
+ - Validate again for safety
+ - Raise exceptions if invalid
+ - Protects against programmatic calls
+
+3. **Backend availability** (startup + on demand)
+ - Check on app startup
+ - Check when user switches backend
+ - Check before expensive operations
+
+### What NOT to Validate
+
+- Don't validate Streamlit widget outputs (they enforce types)
+- Don't validate internal function calls between modules
+- Don't validate data from trusted sources (Settings constants)
+
+## Known Limitations
+- Backend availability check makes network request (slow)
+- Image validation loads entire image into memory
+- No async validation support
+- No batch validation support
+- No custom validation rules (extension mechanism)
+
+## Future Improvements
+- Add async validation for slow checks
+- Add batch validation functions
+- Add validation caching (avoid redundant checks)
+- Add custom validation rule registration
+- Add validation result serialization
+- Add more granular image checks (color space, DPI, etc.)
+- Add prompt content validation (detect harmful content)
+
+## Testing
+- Test all parameter validators with valid/invalid inputs
+- Test boundary conditions (min/max values)
+- Test image validators with various formats
+- Test backend availability with server running/stopped
+- Test request validators with complete/incomplete data
+- Test error message clarity and helpfulness
+
+## Related Files
+- `config/settings.py` - Validation constraints
+- `utils/logging_utils.py` - Error logging
+- All UI pages - Input validation
+- All services - Parameter validation
+- `models/generation_request.py` - Request validation
+
+## Performance Considerations
+- validate_backend_available() makes network request (~100ms)
+- validate_image() loads image into memory
+- validate_image_file() opens file (I/O)
+- All other validators are fast (<1ms)
+
+## Change History
+- 2025-10-23: Initial creation for Streamlit migration
+ - Comprehensive parameter validation
+ - Image validation utilities
+ - Request validation functions
+ - Backend availability checks
+ - User-friendly error messages
+ - Helper functions for error handling
diff --git a/character_forge_image/utils/validation.py b/character_forge_image/utils/validation.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c5a19119cdd322a68166fb8d4422c8edcb66686
--- /dev/null
+++ b/character_forge_image/utils/validation.py
@@ -0,0 +1,435 @@
+"""
+Input Validation Utilities
+===========================
+
+Validation functions for user inputs in Nano Banana Streamlit.
+Ensures data integrity and provides clear error messages.
+"""
+
+from typing import Optional, List, Tuple
+from pathlib import Path
+from PIL import Image
+
+from config.settings import Settings
+from utils.logging_utils import get_logger
+
+
+logger = get_logger(__name__)
+
+
+# =============================================================================
+# PARAMETER VALIDATION
+# =============================================================================
+
+def validate_temperature(temperature: float) -> Tuple[bool, Optional[str]]:
+ """
+ Validate temperature parameter.
+
+ Args:
+ temperature: Temperature value to validate
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ error_message is None if valid
+ """
+ if not isinstance(temperature, (int, float)):
+ return False, "Temperature must be a number"
+
+ if temperature < Settings.MIN_TEMPERATURE or temperature > Settings.MAX_TEMPERATURE:
+ return False, f"Temperature must be between {Settings.MIN_TEMPERATURE} and {Settings.MAX_TEMPERATURE}"
+
+ return True, None
+
+
+def validate_aspect_ratio(aspect_ratio: str) -> Tuple[bool, Optional[str]]:
+ """
+ Validate aspect ratio parameter.
+
+ Args:
+ aspect_ratio: Aspect ratio string (display name or value)
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ if not isinstance(aspect_ratio, str):
+ return False, "Aspect ratio must be a string"
+
+ # Check if it's a display name
+ if aspect_ratio in Settings.ASPECT_RATIOS:
+ return True, None
+
+ # Check if it's a ratio value
+ if aspect_ratio in Settings.ASPECT_RATIOS.values():
+ return True, None
+
+ return False, f"Invalid aspect ratio: {aspect_ratio}"
+
+
+def validate_backend(backend: str) -> Tuple[bool, Optional[str]]:
+ """
+ Validate backend parameter.
+
+ Args:
+ backend: Backend name
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ if not isinstance(backend, str):
+ return False, "Backend must be a string"
+
+ if backend not in Settings.AVAILABLE_BACKENDS:
+ return False, f"Invalid backend: {backend}. Must be one of {Settings.AVAILABLE_BACKENDS}"
+
+ return True, None
+
+
+def validate_prompt(prompt: str, min_length: int = 1, max_length: int = 5000) -> Tuple[bool, Optional[str]]:
+ """
+ Validate text prompt.
+
+ Args:
+ prompt: Text prompt
+ min_length: Minimum required length (default: 1)
+ max_length: Maximum allowed length (default: 5000)
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ if not isinstance(prompt, str):
+ return False, "Prompt must be a string"
+
+ prompt = prompt.strip()
+
+ if len(prompt) < min_length:
+ return False, f"Prompt must be at least {min_length} character(s)"
+
+ if len(prompt) > max_length:
+ return False, f"Prompt must be at most {max_length} characters"
+
+ return True, None
+
+
+def validate_character_name(name: str) -> Tuple[bool, Optional[str]]:
+ """
+ Validate character name.
+
+ Args:
+ name: Character name
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ if not isinstance(name, str):
+ return False, "Character name must be a string"
+
+ name = name.strip()
+
+ if len(name) < 1:
+ return False, "Character name cannot be empty"
+
+ if len(name) > 100:
+ return False, "Character name must be at most 100 characters"
+
+ return True, None
+
+
+# =============================================================================
+# IMAGE VALIDATION
+# =============================================================================
+
+def validate_image(image: Image.Image) -> Tuple[bool, Optional[str]]:
+ """
+ Validate PIL Image object.
+
+ Checks:
+ - Is valid Image instance
+ - Has reasonable dimensions
+ - Is in supported format
+
+ Args:
+ image: PIL Image to validate
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ if not isinstance(image, Image.Image):
+ return False, "Invalid image object"
+
+ # Check dimensions
+ width, height = image.size
+
+ if width < 1 or height < 1:
+ return False, "Image has invalid dimensions"
+
+ if width > 8192 or height > 8192:
+ return False, "Image is too large (max 8192x8192 pixels)"
+
+ # Check mode (format)
+ if image.mode not in ['RGB', 'RGBA', 'L', 'P']:
+ return False, f"Unsupported image mode: {image.mode}"
+
+ return True, None
+
+
+def validate_image_file(file_path: Path) -> Tuple[bool, Optional[str]]:
+ """
+ Validate image file path and format.
+
+ Args:
+ file_path: Path to image file
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ if not isinstance(file_path, Path):
+ try:
+ file_path = Path(file_path)
+ except Exception:
+ return False, "Invalid file path"
+
+ # Check exists
+ if not file_path.exists():
+ return False, f"File not found: {file_path}"
+
+ # Check is file (not directory)
+ if not file_path.is_file():
+ return False, f"Not a file: {file_path}"
+
+ # Check extension
+ valid_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.bmp'}
+ if file_path.suffix.lower() not in valid_extensions:
+ return False, f"Unsupported file format: {file_path.suffix}"
+
+ # Try to open as image
+ try:
+ with Image.open(file_path) as img:
+ return validate_image(img)
+ except Exception as e:
+ return False, f"Cannot open as image: {e}"
+
+
+def validate_image_upload_size(file_size_bytes: int) -> Tuple[bool, Optional[str]]:
+ """
+ Validate uploaded file size.
+
+ Args:
+ file_size_bytes: File size in bytes
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ max_bytes = Settings.MAX_IMAGE_UPLOAD_SIZE * 1024 * 1024 # Convert MB to bytes
+
+ if file_size_bytes > max_bytes:
+ max_mb = Settings.MAX_IMAGE_UPLOAD_SIZE
+ actual_mb = file_size_bytes / (1024 * 1024)
+ return False, f"File too large: {actual_mb:.1f}MB (max: {max_mb}MB)"
+
+ return True, None
+
+
+# =============================================================================
+# GENERATION REQUEST VALIDATION
+# =============================================================================
+
+def validate_generation_request(
+ prompt: str,
+ backend: str,
+ aspect_ratio: str,
+ temperature: float,
+ input_images: Optional[List[Image.Image]] = None
+) -> Tuple[bool, Optional[str]]:
+ """
+ Validate a complete generation request.
+
+ Validates all parameters required for image generation.
+
+ Args:
+ prompt: Text prompt
+ backend: Backend name
+ aspect_ratio: Aspect ratio
+ temperature: Temperature value
+ input_images: Optional list of input images
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ error_message is None if valid
+ """
+ # Validate prompt
+ valid, error = validate_prompt(prompt)
+ if not valid:
+ return False, f"Invalid prompt: {error}"
+
+ # Validate backend
+ valid, error = validate_backend(backend)
+ if not valid:
+ return False, f"Invalid backend: {error}"
+
+ # Validate aspect ratio
+ valid, error = validate_aspect_ratio(aspect_ratio)
+ if not valid:
+ return False, f"Invalid aspect ratio: {error}"
+
+ # Validate temperature
+ valid, error = validate_temperature(temperature)
+ if not valid:
+ return False, f"Invalid temperature: {error}"
+
+ # Validate input images if provided
+ if input_images:
+ if not isinstance(input_images, list):
+ return False, "Input images must be a list"
+
+ if len(input_images) > 3:
+ return False, "Maximum 3 input images allowed"
+
+ for idx, img in enumerate(input_images, 1):
+ valid, error = validate_image(img)
+ if not valid:
+ return False, f"Invalid input image {idx}: {error}"
+
+ return True, None
+
+
+def validate_character_forge_request(
+ character_name: str,
+ initial_image: Optional[Image.Image],
+ face_image: Optional[Image.Image],
+ body_image: Optional[Image.Image],
+ image_type: str,
+ backend: str
+) -> Tuple[bool, Optional[str]]:
+ """
+ Validate a Character Forge generation request.
+
+ Args:
+ character_name: Name for character
+ initial_image: Initial image (for Face Only / Full Body modes)
+ face_image: Face image (for Face+Body Separate mode)
+ body_image: Body image (for Face+Body Separate mode)
+ image_type: Type of input ("Face Only", "Full Body", "Face + Body (Separate)")
+ backend: Backend to use
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ # Validate character name
+ valid, error = validate_character_name(character_name)
+ if not valid:
+ return False, error
+
+ # Validate backend
+ valid, error = validate_backend(backend)
+ if not valid:
+ return False, error
+
+ # Validate images based on mode
+ if image_type == "Face + Body (Separate)":
+ if face_image is None:
+ return False, "Face image is required for Face+Body Separate mode"
+ if body_image is None:
+ return False, "Body image is required for Face+Body Separate mode"
+
+ valid, error = validate_image(face_image)
+ if not valid:
+ return False, f"Invalid face image: {error}"
+
+ valid, error = validate_image(body_image)
+ if not valid:
+ return False, f"Invalid body image: {error}"
+
+ else: # Face Only or Full Body
+ if initial_image is None:
+ return False, f"Initial image is required for {image_type} mode"
+
+ valid, error = validate_image(initial_image)
+ if not valid:
+ return False, f"Invalid initial image: {error}"
+
+ return True, None
+
+
+# =============================================================================
+# BACKEND AVAILABILITY VALIDATION
+# =============================================================================
+
+def validate_backend_available(backend: str, api_key: Optional[str] = None) -> Tuple[bool, Optional[str]]:
+ """
+ Check if a backend is available and properly configured.
+
+ Args:
+ backend: Backend name
+ api_key: API key (for Gemini backend)
+
+ Returns:
+ Tuple of (is_available, error_message)
+ """
+ # Validate backend name first
+ valid, error = validate_backend(backend)
+ if not valid:
+ return False, error
+
+ # Check Gemini API
+ if backend == Settings.BACKEND_GEMINI:
+ if not api_key:
+ return False, "Gemini API key not configured. Please set GEMINI_API_KEY or enter it in settings."
+ return True, None
+
+ # Check OmniGen2
+ if backend == Settings.BACKEND_OMNIGEN2:
+ # Try to check if server is running
+ try:
+ import requests
+ response = requests.get(f"{Settings.OMNIGEN2_BASE_URL}/health", timeout=2)
+ if response.ok:
+ data = response.json()
+ if data.get('status') == 'healthy':
+ return True, None
+ else:
+ return False, "OmniGen2 server is not healthy. Check server.log for details."
+ else:
+ return False, f"OmniGen2 server returned error: {response.status_code}"
+ except Exception as e:
+ return False, f"OmniGen2 server not responding. Start it with: omnigen2_plugin/server.bat start"
+
+ return False, f"Unknown backend: {backend}"
+
+
+# =============================================================================
+# HELPER FUNCTIONS
+# =============================================================================
+
+def raise_if_invalid(is_valid: bool, error_message: Optional[str], exception_type=ValueError):
+ """
+ Raise an exception if validation failed.
+
+ Helper function for turning validation results into exceptions.
+
+ Args:
+ is_valid: Validation result
+ error_message: Error message (if invalid)
+ exception_type: Exception class to raise (default: ValueError)
+
+ Raises:
+ exception_type: If is_valid is False
+ """
+ if not is_valid:
+ logger.error(f"Validation failed: {error_message}")
+ raise exception_type(error_message)
+
+
+def log_validation_error(validation_result: Tuple[bool, Optional[str]], context: str = ""):
+ """
+ Log a validation error if validation failed.
+
+ Args:
+ validation_result: Result tuple from validation function
+ context: Optional context string for the log message
+ """
+ is_valid, error_message = validation_result
+ if not is_valid:
+ if context:
+ logger.warning(f"Validation failed [{context}]: {error_message}")
+ else:
+ logger.warning(f"Validation failed: {error_message}")
diff --git a/cleanup_for_deployment.py b/cleanup_for_deployment.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ffb1ac43e1735beda2596706846a85a53b9f4fe
--- /dev/null
+++ b/cleanup_for_deployment.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""
+Cleanup Script for Character Forge Deployment
+==============================================
+Licensed under GNU AGPL v3.0
+
+This script removes all generated content, test files, and temporary data
+before deploying to HuggingFace or committing to Git.
+
+It will DELETE:
+- All generated images
+- Test outputs
+- Log files
+- Cache directories
+- Temporary files
+
+SAFE TO RUN: Only removes generated content, never source code.
+"""
+
+import os
+import shutil
+from pathlib import Path
+
+def get_base_dir():
+ """Get the base directory of the project."""
+ return Path(__file__).parent.absolute()
+
+def remove_directory(path):
+ """Safely remove a directory and all its contents."""
+ if path.exists() and path.is_dir():
+ try:
+ shutil.rmtree(path)
+ print(f"[DELETED] {path}")
+ return True
+ except Exception as e:
+ print(f"[ERROR] Failed to delete {path}: {e}")
+ return False
+ return False
+
+def remove_file(path):
+ """Safely remove a file."""
+ if path.exists() and path.is_file():
+ try:
+ path.unlink()
+ print(f"[DELETED] {path}")
+ return True
+ except Exception as e:
+ print(f"[ERROR] Failed to delete {path}: {e}")
+ return False
+ return False
+
+def find_and_remove_pattern(base_dir, pattern, file_type="file"):
+ """Find and remove files or directories matching a pattern."""
+ count = 0
+ if file_type == "file":
+ for path in base_dir.rglob(pattern):
+ if path.is_file():
+ if remove_file(path):
+ count += 1
+ else: # directory
+ for path in base_dir.rglob(pattern):
+ if path.is_dir():
+ if remove_directory(path):
+ count += 1
+ return count
+
+def cleanup_outputs(base_dir):
+ """Remove all output directories."""
+ print("\n" + "="*70)
+ print("CLEANING OUTPUT DIRECTORIES")
+ print("="*70)
+
+ output_dirs = [
+ base_dir / "outputs",
+ base_dir / "output",
+ base_dir / "character_forge_image" / "outputs",
+ ]
+
+ count = 0
+ for output_dir in output_dirs:
+ if remove_directory(output_dir):
+ count += 1
+
+ print(f"\n[OK] Removed {count} output directories")
+ return count
+
+def cleanup_images(base_dir):
+ """Remove all generated images."""
+ print("\n" + "="*70)
+ print("CLEANING GENERATED IMAGES")
+ print("="*70)
+
+ image_extensions = ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif"]
+
+ # Directories to preserve (docs, assets, etc.)
+ preserve_dirs = ["docs", "assets", ".git"]
+
+ total_count = 0
+ for ext in image_extensions:
+ for img_path in base_dir.rglob(ext):
+ # Skip if in preserved directories
+ if any(preserve in str(img_path) for preserve in preserve_dirs):
+ print(f"[SKIP] Preserving {img_path}")
+ continue
+
+ if remove_file(img_path):
+ total_count += 1
+
+ print(f"\n[OK] Removed {total_count} image files")
+ return total_count
+
+def cleanup_logs(base_dir):
+ """Remove all log files."""
+ print("\n" + "="*70)
+ print("CLEANING LOG FILES")
+ print("="*70)
+
+ count = find_and_remove_pattern(base_dir, "*.log", "file")
+ print(f"\n[OK] Removed {count} log files")
+ return count
+
+def cleanup_cache(base_dir):
+ """Remove cache directories."""
+ print("\n" + "="*70)
+ print("CLEANING CACHE DIRECTORIES")
+ print("="*70)
+
+ cache_patterns = ["__pycache__", ".library", ".cache"]
+
+ total_count = 0
+ for pattern in cache_patterns:
+ count = find_and_remove_pattern(base_dir, pattern, "directory")
+ total_count += count
+
+ print(f"\n[OK] Removed {total_count} cache directories")
+ return total_count
+
+def cleanup_temp(base_dir):
+ """Remove temporary files and directories."""
+ print("\n" + "="*70)
+ print("CLEANING TEMPORARY FILES")
+ print("="*70)
+
+ temp_patterns = ["*.tmp", "*.temp", "tmp", "temp"]
+
+ total_count = 0
+ for pattern in temp_patterns:
+ if pattern.startswith("*"):
+ count = find_and_remove_pattern(base_dir, pattern, "file")
+ else:
+ count = find_and_remove_pattern(base_dir, pattern, "directory")
+ total_count += count
+
+ print(f"\n[OK] Removed {total_count} temporary items")
+ return total_count
+
+def cleanup_test_files(base_dir):
+ """Remove test output files and directories."""
+ print("\n" + "="*70)
+ print("CLEANING TEST FILES")
+ print("="*70)
+
+ # Remove test output directories
+ test_dirs = [
+ base_dir / "character_forge_image" / "outputs" / "test_female_tattoos",
+ base_dir / "character_forge_image" / "outputs" / "test_flux_pipeline",
+ ]
+
+ count = 0
+ for test_dir in test_dirs:
+ if remove_directory(test_dir):
+ count += 1
+
+ print(f"\n[OK] Removed {count} test directories")
+ return count
+
+def get_directory_size(path):
+ """Calculate total size of a directory in MB."""
+ total_size = 0
+ try:
+ for dirpath, dirnames, filenames in os.walk(path):
+ for filename in filenames:
+ filepath = os.path.join(dirpath, filename)
+ if os.path.exists(filepath):
+ total_size += os.path.getsize(filepath)
+ except Exception as e:
+ print(f"[ERROR] Could not calculate size: {e}")
+ return 0
+
+ return total_size / (1024 * 1024) # Convert to MB
+
+def main():
+ """Main cleanup function."""
+ print("="*70)
+ print("CHARACTER FORGE - DEPLOYMENT CLEANUP")
+ print("="*70)
+ print("\nThis will remove all generated content, test files, and logs.")
+ print("Source code will NOT be touched.")
+
+ base_dir = get_base_dir()
+ print(f"\nBase directory: {base_dir}")
+
+ # Calculate initial size
+ initial_size = get_directory_size(base_dir)
+ print(f"Initial size: {initial_size:.2f} MB")
+
+ # Perform cleanup
+ stats = {
+ "outputs": cleanup_outputs(base_dir),
+ "images": cleanup_images(base_dir),
+ "logs": cleanup_logs(base_dir),
+ "cache": cleanup_cache(base_dir),
+ "temp": cleanup_temp(base_dir),
+ "tests": cleanup_test_files(base_dir),
+ }
+
+ # Calculate final size
+ final_size = get_directory_size(base_dir)
+ saved_size = initial_size - final_size
+
+ # Summary
+ print("\n" + "="*70)
+ print("CLEANUP SUMMARY")
+ print("="*70)
+ print(f"Output directories removed: {stats['outputs']}")
+ print(f"Image files removed: {stats['images']}")
+ print(f"Log files removed: {stats['logs']}")
+ print(f"Cache directories removed: {stats['cache']}")
+ print(f"Temporary items removed: {stats['temp']}")
+ print(f"Test directories removed: {stats['tests']}")
+ print(f"\nInitial size: {initial_size:.2f} MB")
+ print(f"Final size: {final_size:.2f} MB")
+ print(f"Space saved: {saved_size:.2f} MB")
+
+ print("\n" + "="*70)
+ print("[SUCCESS] CLEANUP COMPLETE!")
+ print("="*70)
+ print("\nYour project is now clean and ready for:")
+ print(" - Git commit")
+ print(" - HuggingFace deployment")
+ print(" - GitHub upload")
+ print("\nThe .gitignore file will prevent these files from being")
+ print("added again in the future.")
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/API_KEY_SETUP.md b/docs/API_KEY_SETUP.md
new file mode 100644
index 0000000000000000000000000000000000000000..5413d2e2f0e2c1f4c5db9da231ea66809b1d7263
--- /dev/null
+++ b/docs/API_KEY_SETUP.md
@@ -0,0 +1,301 @@
+# Google Gemini API Key Setup
+
+Complete guide to getting and using your free Google Gemini API key.
+
+## Why Do You Need an API Key?
+
+Character Forge uses Google's Gemini 2.5 Flash Image API for AI image generation. You need an API key to:
+
+- Authenticate your requests
+- Track usage and costs
+- Access the service
+
+## Step 1: Create Your API Key
+
+### Visit Google AI Studio
+
+1. Go to https://aistudio.google.com/app/apikey
+2. Sign in with your Google account
+3. Accept the terms of service (if prompted)
+
+### Create API Key
+
+1. Click **"Create API Key"**
+2. Choose **"Create API key in new project"**
+ - Or select an existing Google Cloud project
+3. Wait a few seconds for creation
+4. Your key will appear (starts with `AIzaSy...`)
+
+### Copy Your Key
+
+1. Click the copy icon next to your key
+2. Store it somewhere safe
+3. **IMPORTANT**: Never share this key publicly!
+
+## Step 2: Use Your API Key
+
+You have several options:
+
+### Option A: Environment Variable (Recommended for Local)
+
+**Windows (Command Prompt):**
+```cmd
+set GEMINI_API_KEY=AIzaSy...your_key_here
+```
+
+**Windows (PowerShell):**
+```powershell
+$env:GEMINI_API_KEY="AIzaSy...your_key_here"
+```
+
+**Linux/Mac:**
+```bash
+export GEMINI_API_KEY=AIzaSy...your_key_here
+```
+
+**Make it permanent:**
+
+**Windows:**
+1. System Properties → Environment Variables
+2. Add new user variable
+3. Name: `GEMINI_API_KEY`
+4. Value: Your key
+
+**Linux/Mac:**
+Add to `~/.bashrc` or `~/.zshrc`:
+```bash
+export GEMINI_API_KEY=AIzaSy...your_key_here
+```
+
+### Option B: In the UI
+
+1. Start the app
+2. Look in the sidebar
+3. Find "Gemini API Key" input
+4. Paste your key
+5. Continue using the app
+
+**Note**: This is temporary, key won't persist after restart
+
+### Option C: HuggingFace Secrets (For HF Spaces)
+
+1. Go to your Space settings
+2. Click "Repository secrets"
+3. Add new secret:
+ - Name: `GEMINI_API_KEY`
+ - Value: Your API key
+4. Save
+
+**The app will automatically use this secret**
+
+## Step 3: Verify It Works
+
+### Test in the App
+
+1. Start Character Forge
+2. Backend selector should show "Gemini API (Cloud)" with a ✅
+3. Try generating a simple image
+4. If it works, you're all set!
+
+### Check Status
+
+In the app:
+- Green checkmark (✅) = API key is valid
+- Red X (❌) = API key missing or invalid
+- Yellow warning (⚠️) = API key not tested
+
+## Understanding Costs
+
+### Pricing (as of January 2025)
+
+**Gemini 2.5 Flash Image:**
+- ~$0.03 per image generation
+- Exact pricing at: https://ai.google.dev/pricing
+
+**Free Tier:**
+- 15 requests per minute
+- 1,500 requests per day
+- Good for testing and personal use
+
+### Cost Examples
+
+| Task | Images | Cost |
+|------|--------|------|
+| Single image | 1 | ~$0.03 |
+| Character sheet | 5 | ~$0.15 |
+| Composition | 1-2 | ~$0.03-0.06 |
+| 100 images | 100 | ~$3.00 |
+
+### Monitoring Usage
+
+**Check your usage:**
+1. Go to https://aistudio.google.com/
+2. Click on your project
+3. View quotas and usage
+
+**Set up billing alerts:**
+1. Go to Google Cloud Console
+2. Set up budget alerts
+3. Get notified when approaching limits
+
+## Security Best Practices
+
+### ✅ DO
+
+- Store key in environment variables
+- Use Secrets for HuggingFace Spaces
+- Keep key private and secret
+- Rotate key if compromised
+- Monitor usage regularly
+
+### ❌ DON'T
+
+- Commit key to Git repositories
+- Share key publicly
+- Hardcode key in your code
+- Upload key in screenshots
+- Email key in plain text
+
+### If Key is Compromised
+
+1. Go to https://aistudio.google.com/app/apikey
+2. Find your compromised key
+3. Click delete/revoke
+4. Create a new key
+5. Update your applications
+
+## Troubleshooting
+
+### "API Key Not Set"
+
+**Check:**
+- Environment variable is set correctly
+- Key is spelled exactly right (case-sensitive)
+- No extra spaces before/after key
+- You've restarted terminal/app after setting
+
+**Try:**
+- Set it directly in the UI
+- Verify key at https://aistudio.google.com/app/apikey
+
+### "Invalid API Key"
+
+**Causes:**
+- Key was revoked
+- Key has typo
+- Project was deleted
+- Billing not set up (for paid features)
+
+**Solution:**
+1. Verify key at Google AI Studio
+2. Create new key if needed
+3. Update your configuration
+
+### "Quota Exceeded"
+
+**Free tier limits:**
+- 15 requests/minute
+- 1,500 requests/day
+
+**Solutions:**
+- Wait for quota to reset
+- Reduce generation frequency
+- Upgrade to paid tier
+
+**Check quotas:**
+https://aistudio.google.com/ → Your project → Quotas
+
+### "Billing Required"
+
+**If you hit free tier limits:**
+1. Go to Google Cloud Console
+2. Enable billing for your project
+3. Set up payment method
+4. Set budget alerts
+
+**Don't worry**: You can set spending limits!
+
+## Advanced: Multiple Keys
+
+### For Team Use
+
+Create separate keys for:
+- Development
+- Testing
+- Production
+- Different team members
+
+**Benefits:**
+- Track usage separately
+- Revoke individual keys
+- Better security
+
+### Rotating Keys
+
+**Best practice:**
+1. Create new key
+2. Update applications
+3. Test everything works
+4. Delete old key
+
+**Do this:**
+- Periodically (every 90 days)
+- If team members leave
+- If key might be compromised
+
+## API Limits Reference
+
+### Rate Limits (Free Tier)
+
+- **Requests per minute**: 15
+- **Requests per day**: 1,500
+- **Concurrent requests**: 5
+
+### Rate Limits (Paid)
+
+- Higher limits available
+- Contact Google for enterprise limits
+- Custom quotas possible
+
+### Image Limits
+
+- **Max file size**: 20 MB
+- **Max images per request**: 3 (for composition)
+- **Supported formats**: PNG, JPEG, WebP
+
+## FAQ
+
+**Q: Is the API key free?**
+A: Yes! Free tier includes 1,500 requests/day. Paid tiers available for more.
+
+**Q: How long does the key last?**
+A: Indefinitely, until you revoke it. But rotate regularly for security.
+
+**Q: Can I use one key for multiple apps?**
+A: Yes, but separate keys are better for tracking and security.
+
+**Q: What if I lose my key?**
+A: Create a new one at https://aistudio.google.com/app/apikey
+
+**Q: Can I share my key with my team?**
+A: Not recommended. Create separate keys or use service accounts.
+
+**Q: Does the key expire?**
+A: No automatic expiration, but revoke and recreate periodically.
+
+**Q: What happens if I go over free tier?**
+A: Requests will fail. Set up billing to continue, or wait for reset.
+
+## Getting Help
+
+**Google AI Studio:**
+- Docs: https://ai.google.dev/
+- Support: https://support.google.com/
+
+**Character Forge:**
+- Documentation: See `/docs` folder
+- Issues: Report on GitHub
+
+---
+
+**Keep your key safe and happy generating! 🔑**
diff --git a/docs/HUGGINGFACE_DEPLOYMENT.md b/docs/HUGGINGFACE_DEPLOYMENT.md
new file mode 100644
index 0000000000000000000000000000000000000000..35c26f81e2bfacb9e4c0c0d85b8a2216ab3cad09
--- /dev/null
+++ b/docs/HUGGINGFACE_DEPLOYMENT.md
@@ -0,0 +1,251 @@
+# HuggingFace Spaces Deployment Guide
+
+This guide will walk you through deploying Character Forge to HuggingFace Spaces.
+
+## Prerequisites
+
+- HuggingFace account (free at https://huggingface.co/)
+- Google Gemini API key (get yours at https://aistudio.google.com/app/apikey)
+
+## Deployment Steps
+
+### 1. Create a New Space
+
+1. Go to https://huggingface.co/spaces
+2. Click "Create new Space"
+3. Configure your space:
+ - **Name**: `character-forge` (or your preferred name)
+ - **License**: Apache 2.0
+ - **SDK**: Streamlit
+ - **Hardware**: CPU Basic (free tier works fine)
+4. Click "Create Space"
+
+### 2. Upload Files
+
+You have two options:
+
+#### Option A: Git Clone (Recommended)
+
+```bash
+# Clone your new space
+git clone https://huggingface.co/spaces/YOUR_USERNAME/character-forge
+cd character-forge
+
+# Copy all files from character_forge_release directory
+cp -r /path/to/character_forge_release/* .
+
+# Add and commit
+git add .
+git commit -m "Initial commit: Character Forge deployment"
+git push
+```
+
+#### Option B: Web Upload
+
+1. Click "Files and versions" in your Space
+2. Click "Add file" → "Upload files"
+3. Upload all files from the `character_forge_release` directory
+4. Commit the changes
+
+### 3. Configure Secrets
+
+Your Gemini API key must be stored securely as a secret:
+
+1. Go to your Space settings
+2. Click "Repository secrets"
+3. Add a new secret:
+ - **Name**: `GEMINI_API_KEY`
+ - **Value**: Your Gemini API key (starts with `AIzaSy...`)
+4. Save the secret
+
+### 4. Wait for Build
+
+HuggingFace will automatically:
+- Install dependencies from `requirements.txt`
+- Install system packages from `packages.txt`
+- Start your Streamlit app
+
+This takes 2-5 minutes.
+
+### 5. Test Your Space
+
+Once built, your Space will be available at:
+```
+https://huggingface.co/spaces/YOUR_USERNAME/character-forge
+```
+
+## Configuration Options
+
+### Hardware Upgrades
+
+For better performance:
+- **CPU Basic** (free): Good for testing, slower generations
+- **CPU Upgrade** ($0.03/hour): Faster, recommended for public spaces
+- **GPU**: Not needed for Gemini API backend
+
+### Environment Variables
+
+You can add more environment variables in Repository Secrets if needed:
+
+- `GEMINI_API_KEY`: Your Google Gemini API key (required)
+- Any other custom configuration
+
+### Custom Domain (Pro users)
+
+HuggingFace Pro users can set up custom domains in Space settings.
+
+## Troubleshooting
+
+### Build Fails
+
+**Check logs:**
+1. Go to "App" tab in your Space
+2. Click "Logs" at the bottom
+3. Look for error messages
+
+**Common issues:**
+- Missing dependencies: Add to `requirements.txt`
+- System packages: Add to `packages.txt`
+- Import errors: Check file paths in code
+
+### API Key Not Working
+
+**Verify secret:**
+1. Go to Repository secrets
+2. Check `GEMINI_API_KEY` is set correctly
+3. Restart the space (Settings → Factory reboot)
+
+**Test manually:**
+- Enter API key in the UI sidebar
+- Check if it works there
+
+### App Not Starting
+
+**Check logs for:**
+- Import errors
+- Missing files
+- Configuration issues
+
+**Try:**
+1. Factory reboot (Settings → Factory reboot)
+2. Check all files are uploaded
+3. Verify `app.py` is in root directory
+
+### Slow Performance
+
+**Free tier limitations:**
+- CPU Basic is slower
+- Consider CPU Upgrade for production
+
+**Optimize:**
+- Use temperature 0.3-0.4 for faster results
+- Smaller image sizes when possible
+
+## Making Your Space Public
+
+### Privacy Settings
+
+1. Go to Space settings
+2. Choose visibility:
+ - **Public**: Anyone can view and use
+ - **Private**: Only you can access
+
+### Usage Limits
+
+To prevent abuse on public spaces:
+
+1. Add rate limiting (code already included)
+2. Monitor usage in Space analytics
+3. Consider requiring sign-in for high usage
+
+### Sharing
+
+Share your Space:
+- Direct link: `https://huggingface.co/spaces/YOUR_USERNAME/character-forge`
+- Embed widget: Available in Space settings
+- Duplicate: Others can duplicate to their account
+
+## Updating Your Space
+
+### Via Git
+
+```bash
+cd character-forge
+# Make your changes
+git add .
+git commit -m "Update: description"
+git push
+```
+
+### Via Web
+
+1. Go to "Files and versions"
+2. Edit files directly or upload new versions
+3. Commit changes
+
+## Cost Estimation
+
+### HuggingFace Costs
+
+- **CPU Basic**: Free
+- **CPU Upgrade**: ~$0.03/hour (when running)
+- **Persistent storage**: Free up to 50GB
+
+### Gemini API Costs
+
+- ~$0.03 per image generation
+- Character sheet: ~$0.15 (5 images)
+- Free tier: 15 requests/minute
+
+### Total Cost Example
+
+Public space with 100 users/day:
+- HF Space (CPU Upgrade, 24/7): ~$21.60/month
+- Gemini API: Varies by usage
+ - Light: 100 images/day = ~$90/month
+ - Heavy: 1000 images/day = ~$900/month
+
+**Recommendation**: Start with free tier, upgrade as needed.
+
+## Best Practices
+
+### Security
+
+- ✅ Store API keys in Repository Secrets
+- ✅ Never commit secrets to git
+- ✅ Use environment variables for configuration
+- ❌ Don't hardcode API keys in code
+
+### Performance
+
+- ✅ Use caching where possible (Streamlit `@st.cache_data`)
+- ✅ Optimize image sizes
+- ✅ Set reasonable timeout limits
+- ❌ Don't store large files in git
+
+### User Experience
+
+- ✅ Add clear instructions in UI
+- ✅ Show helpful error messages
+- ✅ Provide examples
+- ❌ Don't assume users know how to use it
+
+## Support
+
+Need help?
+
+- **HuggingFace**: https://huggingface.co/docs/hub/spaces
+- **Character Forge**: Open an issue on GitHub
+- **Streamlit**: https://docs.streamlit.io/
+
+## Next Steps
+
+After deployment:
+
+1. ✅ Test all features
+2. ✅ Share with users
+3. ✅ Monitor logs and usage
+4. ✅ Collect feedback
+5. ✅ Iterate and improve
+
+Good luck with your deployment! 🚀
diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md
new file mode 100644
index 0000000000000000000000000000000000000000..3f5318416c481e95fd1d3c55b385e9e1b2178f07
--- /dev/null
+++ b/docs/QUICK_START.md
@@ -0,0 +1,255 @@
+# Quick Start Guide
+
+Get up and running with Character Forge in 5 minutes!
+
+## Step 1: Get Your API Key
+
+1. Visit https://aistudio.google.com/app/apikey
+2. Sign in with your Google account
+3. Click "Create API Key" → "Create API key in new project"
+4. Copy your key (starts with `AIzaSy...`)
+5. Keep it safe and secret!
+
+**Cost**: ~$0.03 per image | Free tier available
+
+## Step 2: Choose Your Installation
+
+### Option A: Local Installation (Recommended)
+
+**Windows:**
+```cmd
+# Install
+install.bat
+
+# Set your API key
+set GEMINI_API_KEY=your-key-here
+
+# Run
+start.bat
+```
+
+**Linux/Mac:**
+```bash
+# Install
+chmod +x install.sh
+./install.sh
+
+# Set your API key
+export GEMINI_API_KEY=your-key-here
+
+# Run
+chmod +x start.sh
+./start.sh
+```
+
+**Open browser**: http://localhost:8501
+
+### Option B: HuggingFace Spaces (Online)
+
+1. Go to https://huggingface.co/spaces
+2. Click "Create new Space"
+3. Upload Character Forge files
+4. Add API key in Repository Secrets
+5. Launch!
+
+**See**: [HUGGINGFACE_DEPLOYMENT.md](HUGGINGFACE_DEPLOYMENT.md) for detailed instructions
+
+## Step 3: Create Your First Character Sheet
+
+1. Open the app (localhost:8501 or your HF Space)
+2. Click "🔥 Character Forge" in the sidebar
+3. Upload a clear photo (face or full body)
+4. Click "Generate Character Sheet"
+5. Wait 2-3 minutes
+6. Download your complete character sheet!
+
+**Result**: 5 professionally composed views of your character
+
+## Step 4: Use Your Character Sheet
+
+Your character sheet can be used for:
+
+- ✅ Consistent character generation in new scenes
+- ✅ Multi-character compositions
+- ✅ Animation reference
+- ✅ Game development
+- ✅ Storyboarding
+
+## Features Overview
+
+### 🔥 Character Forge
+
+**Create complete character sheets from one image**
+
+- Upload 1 image → Get 5 views
+- Front/side faces + front/side/back body
+- Auto-composited into single sheet
+- 2-3 minutes total time
+
+### 🎬 Composition Assistant
+
+**Smart multi-image composition**
+
+- Upload 1-3 images
+- Auto-detection of image types
+- AI-generated composition prompts
+- Professional results
+
+### 📸 Standard Interface
+
+**Direct image generation**
+
+- Text-to-image
+- Image-to-image
+- Multiple aspect ratios
+- Temperature control
+
+### 📚 Library
+
+**Save and organize your assets**
+
+- Characters
+- Backgrounds
+- Styles
+- Quick reuse
+
+## Tips for Success
+
+### For Character Sheets
+
+✅ **DO:**
+- Use clear, well-lit photos
+- Front-facing works best
+- Simple backgrounds preferred
+- High resolution helps
+
+❌ **DON'T:**
+- Use blurry images
+- Too much background clutter
+- Extreme angles
+- Multiple people in frame
+
+### For Best Quality
+
+**Temperature Settings:**
+- **0.0-0.3**: Consistent, follows prompt closely
+- **0.4-0.6**: Balanced (recommended)
+- **0.7-1.0**: Creative, more variation
+
+**Prompts:**
+- Be specific and descriptive
+- Include style keywords ("photorealistic", "digital art")
+- Mention lighting and composition
+- Add detail about mood and atmosphere
+
+### Cost Management
+
+**Optimize your usage:**
+- Preview before generating
+- Use lower temperature for consistency (fewer retries)
+- Batch similar tasks together
+- Save good results to library for reuse
+
+**Typical costs with Gemini API:**
+- Single image: ~$0.03
+- Character sheet: ~$0.15 (5 images)
+- Composition: ~$0.03-0.06 (1-2 images)
+
+## Common Use Cases
+
+### Game Development
+
+1. Create character sheets for all NPCs
+2. Generate environment backgrounds
+3. Create texture variations
+4. Compose characters into scenes
+
+### Animation
+
+1. Generate character reference sheets
+2. Create background art
+3. Develop style references
+4. Storyboard scene composition
+
+### Creative Projects
+
+1. Illustrate stories
+2. Concept art development
+3. Visual worldbuilding
+4. Character design iteration
+
+## Troubleshooting
+
+### "API Key Not Set"
+
+**Solution:**
+- Set environment variable before running
+- OR enter key in sidebar when app starts
+- OR use HF Spaces Repository Secrets
+
+### "Generation Failed"
+
+**Possible causes:**
+- Content policy violation (try rephrasing)
+- Rate limit (wait a moment)
+- Network issue (check connection)
+- Invalid API key (verify it's correct)
+
+**Try:**
+1. Rephrase your prompt
+2. Adjust temperature
+3. Try a different image
+4. Check API key is valid
+
+### "Slow Generation"
+
+**Normal timing:**
+- Single image: 10-30 seconds
+- Character sheet: 2-3 minutes
+- Composition: 20-60 seconds
+
+**If slower:**
+- Check internet connection
+- Verify API quota not exceeded
+- Try during off-peak hours
+
+### Port Already in Use
+
+**Solution:**
+```bash
+# Kill existing process
+# Windows:
+taskkill /IM streamlit.exe /F
+
+# Linux/Mac:
+pkill -f streamlit
+```
+
+Or edit `app.py` to use different port.
+
+## Next Steps
+
+Now that you're set up:
+
+1. ✅ Generate your first character sheet
+2. ✅ Try the composition assistant
+3. ✅ Experiment with different temperatures
+4. ✅ Build your asset library
+5. ✅ Integrate into your workflow
+
+## Need Help?
+
+- **Documentation**: See `/docs` folder
+- **Examples**: Check the workflow examples
+- **Issues**: Report on GitHub
+- **API Help**: https://ai.google.dev/
+
+## Resources
+
+- **Gemini API Docs**: https://ai.google.dev/
+- **Streamlit Docs**: https://docs.streamlit.io/
+- **HuggingFace Spaces**: https://huggingface.co/docs/hub/spaces
+
+---
+
+**Happy Creating! 🎨**
diff --git a/install.bat b/install.bat
new file mode 100644
index 0000000000000000000000000000000000000000..b9b5ca6cb9abb231d059b7407cbb3c61fb66ddb3
--- /dev/null
+++ b/install.bat
@@ -0,0 +1,52 @@
+@echo off
+REM Character Forge - Installation Script
+REM ======================================
+
+echo.
+echo ========================================
+echo Character Forge - Installation
+echo ========================================
+echo.
+
+REM Check Python
+python --version >nul 2>&1
+if errorlevel 1 (
+ echo ERROR: Python is not installed or not in PATH!
+ echo Please install Python 3.10+ from https://www.python.org/
+ pause
+ exit /b 1
+)
+
+echo Python found:
+python --version
+echo.
+
+echo Installing dependencies...
+echo.
+
+REM Upgrade pip
+python -m pip install --upgrade pip
+
+REM Install requirements
+python -m pip install -r requirements.txt
+
+if errorlevel 1 (
+ echo.
+ echo ERROR: Installation failed!
+ pause
+ exit /b 1
+)
+
+echo.
+echo ========================================
+echo Installation Complete!
+echo ========================================
+echo.
+echo Character Forge is ready to run!
+echo.
+echo Next steps:
+echo 1. Get your Gemini API key from https://aistudio.google.com/app/apikey
+echo 2. Set it as environment variable: set GEMINI_API_KEY=your-key-here
+echo 3. Run: start.bat
+echo.
+pause
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000000000000000000000000000000000000..86ced5514288ddef573df180ffca4c395bf1cb61
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+# Character Forge - Installation Script
+# ======================================
+
+echo
+echo "========================================"
+echo "Character Forge - Installation"
+echo "========================================"
+echo
+
+# Check Python
+if ! command -v python3 &> /dev/null; then
+ echo "ERROR: Python 3 is not installed!"
+ echo "Please install Python 3.10+ from https://www.python.org/"
+ exit 1
+fi
+
+echo "Python found:"
+python3 --version
+echo
+
+echo "Installing dependencies..."
+echo
+
+# Upgrade pip
+python3 -m pip install --upgrade pip
+
+# Install requirements
+python3 -m pip install -r requirements.txt
+
+if [ $? -ne 0 ]; then
+ echo
+ echo "ERROR: Installation failed!"
+ exit 1
+fi
+
+echo
+echo "========================================"
+echo "Installation Complete!"
+echo "========================================"
+echo
+echo "Character Forge is ready to run!"
+echo
+echo "Next steps:"
+echo "1. Get your Gemini API key from https://aistudio.google.com/app/apikey"
+echo "2. Set it as environment variable: export GEMINI_API_KEY=your-key-here"
+echo "3. Run: ./start.sh"
+echo
diff --git a/packages.txt b/packages.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ba5827a72ff11af7fe11bda83109eba011817785
--- /dev/null
+++ b/packages.txt
@@ -0,0 +1,2 @@
+libgl1
+libglib2.0-0
diff --git a/requirements.txt b/requirements.txt
index 28d994e22f8dd432b51df193562052e315ad95f7..df5b6a776582f0a6a2c8d031e2275266d3bb424a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,28 @@
-altair
-pandas
-streamlit
\ No newline at end of file
+# Character Forge - Minimal Requirements (Image App Only)
+# ==========================================================
+# Just the essentials for running the Streamlit image generation app
+# Use this for faster installation if you don't need video tools
+
+# Core Framework
+streamlit>=1.31.0
+
+# Image Generation Backends
+google-genai>=0.3.0 # Gemini API
+requests>=2.31.0 # For OmniGen2/network backends
+websocket-client>=1.7.0 # For ComfyUI
+
+# Image Processing
+Pillow>=10.0.0 # PIL/Image operations
+numpy>=1.24.0 # Array operations
+
+# Configuration & Utilities
+PyYAML>=6.0.0 # YAML config files
+python-dateutil>=2.8.2 # Date/time handling
+pathlib>=1.0.1 # Path operations
+
+# Logging
+colorlog>=6.7.0 # Colored logging
+
+# Optional UI Enhancements
+streamlit-image-comparison
+streamlit-extras
diff --git a/shared/plugin_system/__init__.py b/shared/plugin_system/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..605a2b68926bd776730e95c45d9d0b066e8c2b79
--- /dev/null
+++ b/shared/plugin_system/__init__.py
@@ -0,0 +1,27 @@
+from .base_plugin import BaseBackendPlugin
+from .plugin_manager import PluginManager
+from .enhanced_base_plugin import EnhancedBackendPlugin
+from .backend_config import (
+ BackendConnectionConfig,
+ BackendLocation,
+ BackendProtocol
+)
+from .prompt_transformer import (
+ StandardGenerationRequest,
+ PromptTransformer,
+ get_transformer
+)
+
+__all__ = [
+ # Original plugin system
+ 'BaseBackendPlugin',
+ 'PluginManager',
+ # Enhanced plugin system
+ 'EnhancedBackendPlugin',
+ 'BackendConnectionConfig',
+ 'BackendLocation',
+ 'BackendProtocol',
+ 'StandardGenerationRequest',
+ 'PromptTransformer',
+ 'get_transformer',
+]
diff --git a/shared/plugin_system/backend_config.py b/shared/plugin_system/backend_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa3edcf27cf6d8cbd64f54b12d20dc1e49bea111
--- /dev/null
+++ b/shared/plugin_system/backend_config.py
@@ -0,0 +1,164 @@
+"""
+Backend Configuration
+
+Defines backend location and connection types.
+Supports local, network, and cloud backends.
+"""
+
+from enum import Enum
+from typing import Optional, Dict, Any
+from dataclasses import dataclass, field
+
+
+class BackendLocation(Enum):
+ """Backend deployment location."""
+ LOCAL = "local" # Running in project structure
+ NETWORK = "network" # Running on LAN (IP:PORT)
+ CLOUD = "cloud" # Commercial API over internet
+
+
+class BackendProtocol(Enum):
+ """Communication protocol for backend."""
+ PYTHON = "python" # Direct Python import
+ HTTP = "http" # HTTP REST API
+ WEBSOCKET = "websocket" # WebSocket connection
+ GRPC = "grpc" # gRPC
+
+
+@dataclass
+class BackendConnectionConfig:
+ """
+ Configuration for connecting to a backend.
+
+ Supports three deployment scenarios:
+ 1. Local: Backend runs in project, direct Python import
+ 2. Network: Backend runs on LAN, communicate via HTTP/WebSocket
+ 3. Cloud: Commercial API, communicate via HTTPS
+ """
+
+ # Backend identity
+ name: str
+ backend_type: str # e.g., "gemini", "omnigen2", "comfyui"
+
+ # Location
+ location: BackendLocation
+ protocol: BackendProtocol
+
+ # Connection details
+ endpoint: Optional[str] = None # URL or IP:PORT
+ api_key: Optional[str] = None # For authenticated APIs
+
+ # Capabilities
+ capabilities: Dict[str, Any] = field(default_factory=dict)
+
+ # Timeouts and limits
+ timeout: int = 120 # seconds
+ max_retries: int = 3
+
+ # Health check
+ health_check_endpoint: Optional[str] = None
+ health_check_interval: int = 60 # seconds
+
+ def __post_init__(self):
+ """Validate configuration."""
+ if self.location == BackendLocation.LOCAL:
+ # Local backends use Python protocol
+ if self.protocol != BackendProtocol.PYTHON:
+ raise ValueError("Local backends must use PYTHON protocol")
+
+ elif self.location in [BackendLocation.NETWORK, BackendLocation.CLOUD]:
+ # Network/Cloud backends need endpoint
+ if not self.endpoint:
+ raise ValueError(f"{self.location.value} backends require endpoint")
+
+ # Network/Cloud use HTTP/WebSocket/gRPC
+ if self.protocol == BackendProtocol.PYTHON:
+ raise ValueError(f"{self.location.value} backends cannot use PYTHON protocol")
+
+ def get_full_endpoint(self, path: str = "") -> str:
+ """Get full endpoint URL with path."""
+ if not self.endpoint:
+ return ""
+
+ base = self.endpoint.rstrip('/')
+ path = path.lstrip('/')
+
+ return f"{base}/{path}" if path else base
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'BackendConnectionConfig':
+ """Create configuration from dictionary."""
+ return cls(
+ name=data['name'],
+ backend_type=data['backend_type'],
+ location=BackendLocation(data['location']),
+ protocol=BackendProtocol(data['protocol']),
+ endpoint=data.get('endpoint'),
+ api_key=data.get('api_key'),
+ capabilities=data.get('capabilities', {}),
+ timeout=data.get('timeout', 120),
+ max_retries=data.get('max_retries', 3),
+ health_check_endpoint=data.get('health_check_endpoint'),
+ health_check_interval=data.get('health_check_interval', 60)
+ )
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert configuration to dictionary."""
+ return {
+ 'name': self.name,
+ 'backend_type': self.backend_type,
+ 'location': self.location.value,
+ 'protocol': self.protocol.value,
+ 'endpoint': self.endpoint,
+ 'api_key': self.api_key,
+ 'capabilities': self.capabilities,
+ 'timeout': self.timeout,
+ 'max_retries': self.max_retries,
+ 'health_check_endpoint': self.health_check_endpoint,
+ 'health_check_interval': self.health_check_interval
+ }
+
+
+# Example configurations:
+
+# Local backend (running in project)
+EXAMPLE_LOCAL = {
+ 'name': 'omnigen2_local',
+ 'backend_type': 'omnigen2',
+ 'location': 'local',
+ 'protocol': 'python',
+ 'capabilities': {
+ 'supports_multi_image': True,
+ 'max_resolution': 2048
+ }
+}
+
+# Network backend (running on LAN)
+EXAMPLE_NETWORK = {
+ 'name': 'omnigen2_server',
+ 'backend_type': 'omnigen2',
+ 'location': 'network',
+ 'protocol': 'http',
+ 'endpoint': 'http://192.168.1.100:8000',
+ 'health_check_endpoint': '/health',
+ 'capabilities': {
+ 'supports_multi_image': True,
+ 'max_resolution': 2048
+ }
+}
+
+# Cloud backend (commercial API)
+EXAMPLE_CLOUD = {
+ 'name': 'gemini_cloud',
+ 'backend_type': 'gemini',
+ 'location': 'cloud',
+ 'protocol': 'http',
+ 'endpoint': 'https://generativelanguage.googleapis.com/v1',
+ 'api_key': 'YOUR_API_KEY',
+ 'health_check_endpoint': '/models',
+ 'capabilities': {
+ 'supports_multi_image': True,
+ 'max_input_images': 16,
+ 'max_resolution': 4096
+ }
+}
diff --git a/shared/plugin_system/base_plugin.py b/shared/plugin_system/base_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..4505eb47ca781028fce63bbd3ef70e09c7269a27
--- /dev/null
+++ b/shared/plugin_system/base_plugin.py
@@ -0,0 +1,52 @@
+"""
+Base plugin interface for all backend plugins.
+
+All backends (ComfyUI, OmniGen2, Gemini, etc.) implement this interface.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, Optional, List
+from PIL import Image
+from pathlib import Path
+import yaml
+
+
+class BaseBackendPlugin(ABC):
+ """Abstract base class for all backend plugins."""
+
+ def __init__(self, config_path: Path):
+ """Initialize plugin with configuration."""
+ self.config = self.load_config(config_path)
+ self.name = self.config.get('name', 'Unknown')
+ self.version = self.config.get('version', '1.0.0')
+ self.enabled = self.config.get('enabled', True)
+
+ @abstractmethod
+ def health_check(self) -> bool:
+ """Check if backend is available and healthy."""
+ pass
+
+ @abstractmethod
+ def generate_image(
+ self,
+ prompt: str,
+ input_images: Optional[List[Image.Image]] = None,
+ **kwargs
+ ) -> Image.Image:
+ """Generate image using this backend."""
+ pass
+
+ @abstractmethod
+ def get_capabilities(self) -> Dict[str, Any]:
+ """Report backend capabilities."""
+ pass
+
+ def load_config(self, config_path: Path) -> Dict[str, Any]:
+ """Load plugin configuration from YAML."""
+ if not config_path.exists():
+ return {}
+ with open(config_path) as f:
+ return yaml.safe_load(f) or {}
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}(name={self.name}, version={self.version})"
diff --git a/shared/plugin_system/enhanced_base_plugin.py b/shared/plugin_system/enhanced_base_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..29417f9b034b7f2f77179da022b5501fea609d53
--- /dev/null
+++ b/shared/plugin_system/enhanced_base_plugin.py
@@ -0,0 +1,284 @@
+"""
+Enhanced Base Plugin
+
+Location-agnostic backend plugin that supports:
+- Local backends (running in project)
+- Network backends (running on LAN)
+- Cloud backends (commercial APIs)
+
+Uses prompt transformation layer for backend format abstraction.
+"""
+
+from abc import ABC, abstractmethod
+from typing import List, Optional
+from pathlib import Path
+import requests
+from PIL import Image
+
+from .backend_config import BackendConnectionConfig, BackendLocation, BackendProtocol
+from .prompt_transformer import (
+ StandardGenerationRequest,
+ PromptTransformer,
+ get_transformer
+)
+
+
+class EnhancedBackendPlugin(ABC):
+ """
+ Enhanced base class for all backend plugins.
+
+ Supports three deployment scenarios:
+ 1. Local: Backend runs in project structure
+ 2. Network: Backend runs on LAN (IP:PORT)
+ 3. Cloud: Commercial API over internet
+
+ The application NEVER directly imports backends.
+ Everything goes through this abstraction layer.
+ """
+
+ def __init__(self, config: BackendConnectionConfig):
+ """
+ Initialize plugin with connection configuration.
+
+ Args:
+ config: Backend connection configuration
+ """
+ self.config = config
+ self.name = config.name
+ self.backend_type = config.backend_type
+ self.location = config.location
+ self.protocol = config.protocol
+
+ # Get prompt transformer for this backend type
+ self.transformer = get_transformer(config.backend_type)
+
+ # Backend-specific client (set by subclass)
+ self._client = None
+
+ @abstractmethod
+ def _initialize_local(self) -> None:
+ """
+ Initialize local backend.
+
+ Subclasses implement this to set up local backend.
+ Example: Import and instantiate local Python module.
+ """
+ pass
+
+ @abstractmethod
+ def _initialize_network(self) -> None:
+ """
+ Initialize network backend.
+
+ Subclasses implement this to set up network connection.
+ Example: Create HTTP client with endpoint.
+ """
+ pass
+
+ @abstractmethod
+ def _initialize_cloud(self) -> None:
+ """
+ Initialize cloud backend.
+
+ Subclasses implement this to set up cloud API client.
+ Example: Configure API client with credentials.
+ """
+ pass
+
+ def initialize(self) -> None:
+ """
+ Initialize backend based on location.
+
+ Automatically calls the appropriate initialization method.
+ """
+ if self.location == BackendLocation.LOCAL:
+ self._initialize_local()
+ elif self.location == BackendLocation.NETWORK:
+ self._initialize_network()
+ elif self.location == BackendLocation.CLOUD:
+ self._initialize_cloud()
+
+ def health_check(self) -> bool:
+ """
+ Check if backend is available and healthy.
+
+ Works for local, network, and cloud backends.
+ """
+ if self.location == BackendLocation.LOCAL:
+ # Local: Check if client is initialized
+ return self._client is not None
+
+ elif self.location in [BackendLocation.NETWORK, BackendLocation.CLOUD]:
+ # Network/Cloud: Send health check request
+ try:
+ health_url = self.config.get_full_endpoint(
+ self.config.health_check_endpoint or '/health'
+ )
+
+ response = requests.get(
+ health_url,
+ timeout=5,
+ headers=self._get_auth_headers()
+ )
+
+ return response.status_code == 200
+
+ except Exception as e:
+ print(f"Health check failed for {self.name}: {e}")
+ return False
+
+ return False
+
+ def generate_image(
+ self,
+ request: StandardGenerationRequest
+ ) -> List[Image.Image]:
+ """
+ Generate image using this backend.
+
+ This is the ONLY method the application calls.
+ It handles:
+ 1. Transform standard request → backend format
+ 2. Send to backend (local/network/cloud)
+ 3. Transform backend response → standard format
+
+ Args:
+ request: Standard generation request
+
+ Returns:
+ List of generated images
+ """
+
+ # Step 1: Transform request to backend-specific format
+ backend_request = self.transformer.transform_request(request)
+
+ # Step 2: Send to backend based on location
+ if self.location == BackendLocation.LOCAL:
+ backend_response = self._generate_local(backend_request)
+
+ elif self.location == BackendLocation.NETWORK:
+ backend_response = self._generate_network(backend_request)
+
+ elif self.location == BackendLocation.CLOUD:
+ backend_response = self._generate_cloud(backend_request)
+
+ else:
+ raise ValueError(f"Unknown backend location: {self.location}")
+
+ # Step 3: Transform response to standard format
+ images = self.transformer.transform_response(backend_response)
+
+ return images
+
+ @abstractmethod
+ def _generate_local(self, backend_request: dict) -> any:
+ """
+ Generate using local backend.
+
+ Args:
+ backend_request: Backend-specific request format
+
+ Returns:
+ Backend-specific response
+ """
+ pass
+
+ @abstractmethod
+ def _generate_network(self, backend_request: dict) -> any:
+ """
+ Generate using network backend.
+
+ Args:
+ backend_request: Backend-specific request format
+
+ Returns:
+ Backend-specific response
+ """
+ pass
+
+ @abstractmethod
+ def _generate_cloud(self, backend_request: dict) -> any:
+ """
+ Generate using cloud backend.
+
+ Args:
+ backend_request: Backend-specific request format
+
+ Returns:
+ Backend-specific response
+ """
+ pass
+
+ def _get_auth_headers(self) -> dict:
+ """Get authentication headers for API requests."""
+ headers = {}
+
+ if self.config.api_key:
+ # Common auth header patterns
+ if self.backend_type == 'gemini':
+ headers['x-goog-api-key'] = self.config.api_key
+ else:
+ headers['Authorization'] = f'Bearer {self.config.api_key}'
+
+ return headers
+
+ def _send_http_request(
+ self,
+ endpoint: str,
+ data: dict,
+ method: str = 'POST'
+ ) -> any:
+ """
+ Send HTTP request to backend.
+
+ Helper method for network/cloud backends.
+ """
+ url = self.config.get_full_endpoint(endpoint)
+
+ headers = self._get_auth_headers()
+ headers['Content-Type'] = 'application/json'
+
+ try:
+ if method == 'POST':
+ response = requests.post(
+ url,
+ json=data,
+ headers=headers,
+ timeout=self.config.timeout
+ )
+ elif method == 'GET':
+ response = requests.get(
+ url,
+ params=data,
+ headers=headers,
+ timeout=self.config.timeout
+ )
+
+ response.raise_for_status()
+ return response.json()
+
+ except requests.exceptions.RequestException as e:
+ raise RuntimeError(f"Backend request failed: {e}")
+
+ def get_capabilities(self) -> dict:
+ """
+ Report backend capabilities.
+
+ Returns capabilities from configuration.
+ """
+ return {
+ 'name': self.name,
+ 'backend_type': self.backend_type,
+ 'location': self.location.value,
+ 'protocol': self.protocol.value,
+ 'endpoint': self.config.endpoint,
+ **self.config.capabilities
+ }
+
+ def __repr__(self):
+ return (
+ f"{self.__class__.__name__}("
+ f"name={self.name}, "
+ f"location={self.location.value}, "
+ f"endpoint={self.config.endpoint})"
+ )
diff --git a/shared/plugin_system/example_enhanced_plugin.py b/shared/plugin_system/example_enhanced_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..ad18a2104087ec648b32c087268bc707051c4697
--- /dev/null
+++ b/shared/plugin_system/example_enhanced_plugin.py
@@ -0,0 +1,230 @@
+"""
+Example Enhanced Plugin Implementation
+
+Shows how to create a backend plugin that works with:
+- Local deployment (running in project)
+- Network deployment (running on LAN)
+- Cloud deployment (commercial API)
+
+This is an example for Gemini, but pattern applies to any backend.
+"""
+
+import sys
+from pathlib import Path
+from typing import Any
+
+from .enhanced_base_plugin import EnhancedBackendPlugin
+from .backend_config import BackendConnectionConfig
+
+
+class EnhancedGeminiPlugin(EnhancedBackendPlugin):
+ """
+ Gemini plugin that supports local/network/cloud deployment.
+
+ Usage examples:
+
+ # 1. Cloud (commercial API):
+ config = BackendConnectionConfig(
+ name='gemini_cloud',
+ backend_type='gemini',
+ location=BackendLocation.CLOUD,
+ protocol=BackendProtocol.HTTP,
+ endpoint='https://generativelanguage.googleapis.com/v1',
+ api_key='YOUR_API_KEY'
+ )
+
+ # 2. Network (self-hosted on LAN):
+ config = BackendConnectionConfig(
+ name='gemini_lan',
+ backend_type='gemini',
+ location=BackendLocation.NETWORK,
+ protocol=BackendProtocol.HTTP,
+ endpoint='http://192.168.1.100:8000'
+ )
+
+ # 3. Local (running in project):
+ config = BackendConnectionConfig(
+ name='gemini_local',
+ backend_type='gemini',
+ location=BackendLocation.LOCAL,
+ protocol=BackendProtocol.PYTHON
+ )
+ """
+
+ def _initialize_local(self) -> None:
+ """Initialize local Gemini backend."""
+
+ # Import the actual backend client
+ # This is the ONLY place we import backend code
+ try:
+ # Add parent to path for import
+ parent = Path(__file__).parent.parent.parent
+ sys.path.insert(0, str(parent / 'character_forge_image'))
+
+ from core.gemini_client import GeminiClient
+ from config.settings import Settings
+
+ # Get API key from settings
+ settings = Settings()
+ api_key = settings.get_api_key()
+
+ if not api_key:
+ raise ValueError("Gemini API key not found")
+
+ # Initialize client
+ self._client = GeminiClient(api_key)
+
+ except ImportError as e:
+ raise RuntimeError(f"Failed to import local Gemini client: {e}")
+
+ def _initialize_network(self) -> None:
+ """Initialize network Gemini backend."""
+
+ # For network backend, we use HTTP client
+ # No imports needed - uses requests
+ self._client = {
+ 'type': 'network',
+ 'endpoint': self.config.endpoint
+ }
+
+ def _initialize_cloud(self) -> None:
+ """Initialize cloud Gemini backend."""
+
+ # For cloud backend, can use official SDK or HTTP
+ # Option 1: Use official SDK (if available)
+ try:
+ from google import genai
+ self._client = genai.Client(api_key=self.config.api_key)
+
+ except ImportError:
+ # Option 2: Fall back to HTTP client
+ self._client = {
+ 'type': 'cloud',
+ 'endpoint': self.config.endpoint,
+ 'api_key': self.config.api_key
+ }
+
+ def _generate_local(self, backend_request: dict) -> Any:
+ """Generate using local Gemini client."""
+
+ # Use the imported client
+ from models.generation_request import GenerationRequest
+
+ # Convert dict back to GenerationRequest
+ request = GenerationRequest(
+ prompt=backend_request['prompt'],
+ aspect_ratio=backend_request['aspect_ratio'],
+ number_of_images=backend_request['number_of_images'],
+ safety_filter_level=backend_request['safety_filter_level'],
+ person_generation=backend_request['person_generation']
+ )
+
+ # Call local client
+ result = self._client.generate(request)
+ return result
+
+ def _generate_network(self, backend_request: dict) -> Any:
+ """Generate using network Gemini backend."""
+
+ # Send HTTP request to network endpoint
+ response = self._send_http_request(
+ endpoint='/generate',
+ data=backend_request,
+ method='POST'
+ )
+
+ return response
+
+ def _generate_cloud(self, backend_request: dict) -> Any:
+ """Generate using cloud Gemini API."""
+
+ # If using official SDK
+ if hasattr(self._client, 'models'):
+ # Use SDK
+ return self._client.models.generate_images(**backend_request)
+
+ else:
+ # Use HTTP API
+ response = self._send_http_request(
+ endpoint='/models/gemini-2.5-flash-image:generate',
+ data=backend_request,
+ method='POST'
+ )
+
+ return response
+
+
+# Same pattern for other backends:
+
+class EnhancedOmniGen2Plugin(EnhancedBackendPlugin):
+ """OmniGen2 plugin supporting local/network/cloud."""
+
+ def _initialize_local(self) -> None:
+ """Local: Import OmniGen2 from project."""
+ # Import local OmniGen2 client
+ pass
+
+ def _initialize_network(self) -> None:
+ """Network: Connect to OmniGen2 server on LAN."""
+ # Set up HTTP client for network OmniGen2
+ pass
+
+ def _initialize_cloud(self) -> None:
+ """Cloud: Use hosted OmniGen2 API."""
+ # Set up cloud API client
+ pass
+
+ def _generate_local(self, backend_request: dict) -> Any:
+ """Generate with local OmniGen2."""
+ pass
+
+ def _generate_network(self, backend_request: dict) -> Any:
+ """Generate with network OmniGen2."""
+ pass
+
+ def _generate_cloud(self, backend_request: dict) -> Any:
+ """Generate with cloud OmniGen2."""
+ pass
+
+
+class EnhancedComfyUIPlugin(EnhancedBackendPlugin):
+ """ComfyUI plugin supporting local/network/cloud."""
+
+ def _initialize_local(self) -> None:
+ """Local: Connect to local ComfyUI instance."""
+ # ComfyUI always uses HTTP, even locally
+ self.config.endpoint = 'http://127.0.0.1:8188'
+ self._initialize_network()
+
+ def _initialize_network(self) -> None:
+ """Network: Connect to ComfyUI on LAN."""
+ self._client = {
+ 'type': 'network',
+ 'endpoint': self.config.endpoint
+ }
+
+ def _initialize_cloud(self) -> None:
+ """Cloud: Use hosted ComfyUI service."""
+ self._client = {
+ 'type': 'cloud',
+ 'endpoint': self.config.endpoint,
+ 'api_key': self.config.api_key
+ }
+
+ def _generate_local(self, backend_request: dict) -> Any:
+ """Generate with local ComfyUI."""
+ return self._generate_network(backend_request)
+
+ def _generate_network(self, backend_request: dict) -> Any:
+ """Generate with network ComfyUI."""
+ # Queue workflow
+ response = self._send_http_request(
+ endpoint='/prompt',
+ data={'prompt': backend_request},
+ method='POST'
+ )
+ return response
+
+ def _generate_cloud(self, backend_request: dict) -> Any:
+ """Generate with cloud ComfyUI."""
+ return self._generate_network(backend_request)
diff --git a/shared/plugin_system/plugin_manager.py b/shared/plugin_system/plugin_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..a94da1900a53e1ddbebc37018c23f79ab3bfc6ae
--- /dev/null
+++ b/shared/plugin_system/plugin_manager.py
@@ -0,0 +1,81 @@
+"""
+Plugin Manager - Discovers and loads all backend plugins.
+"""
+
+from pathlib import Path
+from typing import Dict, List, Optional, Any
+import importlib.util
+import yaml
+import sys
+
+
+class PluginManager:
+ """Manages all backend plugins."""
+
+ def __init__(self, plugin_dirs: List[Path]):
+ """Initialize plugin manager."""
+ self.plugin_dirs = plugin_dirs
+ self.plugins: Dict[str, Any] = {}
+ self.discover_plugins()
+
+ def discover_plugins(self):
+ """Automatically discover and load all plugins."""
+ for plugin_dir in self.plugin_dirs:
+ registry_path = plugin_dir / 'plugin_registry.yaml'
+
+ if not registry_path.exists():
+ continue
+
+ with open(registry_path) as f:
+ registry = yaml.safe_load(f)
+
+ for plugin_config in registry.get('plugins', []):
+ if plugin_config.get('enabled', True):
+ try:
+ self.load_plugin(plugin_dir, plugin_config)
+ except Exception as e:
+ print(f"Failed to load plugin {plugin_config['name']}: {e}")
+
+ def load_plugin(self, plugin_dir: Path, config: Dict):
+ """Load a single plugin."""
+ plugin_name = config['name']
+ plugin_module = config['module']
+ plugin_class = config['class']
+
+ # Import plugin module
+ module_path = plugin_dir / f"{plugin_module}.py"
+ if not module_path.exists():
+ print(f"Plugin module not found: {module_path}")
+ return
+
+ spec = importlib.util.spec_from_file_location(plugin_module, module_path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[plugin_module] = module
+ spec.loader.exec_module(module)
+
+ # Get plugin class
+ PluginClass = getattr(module, plugin_class)
+
+ # Find config path
+ config_path = plugin_dir.parent.parent / 'tools' / plugin_name / 'config.yaml'
+
+ # Instantiate plugin
+ plugin = PluginClass(config_path)
+
+ self.plugins[plugin_name] = plugin
+ print(f"Loaded plugin: {plugin_name} v{plugin.version}")
+
+ def get_plugin(self, name: str) -> Optional[Any]:
+ """Get plugin by name."""
+ return self.plugins.get(name)
+
+ def list_plugins(self) -> List[str]:
+ """List all available plugins."""
+ return list(self.plugins.keys())
+
+ def get_available_backends(self) -> Dict[str, bool]:
+ """Get all backends with health status."""
+ return {
+ name: plugin.health_check()
+ for name, plugin in self.plugins.items()
+ }
diff --git a/shared/plugin_system/prompt_transformer.py b/shared/plugin_system/prompt_transformer.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a67c0ffc861134b5a7addb54079e86327024309
--- /dev/null
+++ b/shared/plugin_system/prompt_transformer.py
@@ -0,0 +1,309 @@
+"""
+Prompt Transformation Layer
+
+Transforms standard internal prompts to backend-specific formats.
+Each backend may have different:
+- Prompt structure (text, JSON, special tokens)
+- Parameter names
+- Value formats
+- Special requirements
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, List, Optional
+from dataclasses import dataclass
+from PIL import Image
+
+
+@dataclass
+class StandardGenerationRequest:
+ """
+ Standard internal format for generation requests.
+
+ This is the ONE format the application uses.
+ Backend adapters transform this to backend-specific formats.
+ """
+
+ # Core request
+ prompt: str
+ negative_prompt: Optional[str] = None
+
+ # Input images (for img2img, controlnet, etc.)
+ input_images: List[Image.Image] = None
+
+ # Generation parameters
+ width: int = 1024
+ height: int = 1024
+ num_images: int = 1
+
+ # Quality controls
+ guidance_scale: float = 7.5
+ num_inference_steps: int = 50
+ seed: Optional[int] = None
+
+ # Advanced options
+ control_mode: Optional[str] = None # "canny", "depth", "pose", etc.
+ strength: float = 0.8 # For img2img
+
+ # Backend hints (preferences, not requirements)
+ preferred_model: Optional[str] = None
+ quality_preset: str = "balanced" # "fast", "balanced", "quality"
+
+ def __post_init__(self):
+ """Initialize mutable defaults."""
+ if self.input_images is None:
+ self.input_images = []
+
+
+class PromptTransformer(ABC):
+ """
+ Abstract base class for prompt transformers.
+
+ Each backend type has a transformer that converts
+ StandardGenerationRequest to backend-specific format.
+ """
+
+ @abstractmethod
+ def transform_request(self, request: StandardGenerationRequest) -> Dict[str, Any]:
+ """
+ Transform standard request to backend-specific format.
+
+ Args:
+ request: Standard internal format
+
+ Returns:
+ Backend-specific request dict
+ """
+ pass
+
+ @abstractmethod
+ def transform_response(self, response: Any) -> List[Image.Image]:
+ """
+ Transform backend response to standard format.
+
+ Args:
+ response: Backend-specific response
+
+ Returns:
+ List of generated images
+ """
+ pass
+
+
+class GeminiPromptTransformer(PromptTransformer):
+ """Transformer for Gemini API format."""
+
+ def transform_request(self, request: StandardGenerationRequest) -> Dict[str, Any]:
+ """Transform to Gemini API format."""
+
+ # Gemini uses aspect ratios instead of width/height
+ aspect_ratio = self._calculate_aspect_ratio(request.width, request.height)
+
+ return {
+ 'prompt': request.prompt,
+ 'aspect_ratio': aspect_ratio,
+ 'number_of_images': request.num_images,
+ 'safety_filter_level': 'block_some',
+ 'person_generation': 'allow_all',
+ # Gemini doesn't support negative prompts directly
+ # Could append to prompt: "... (avoid: {negative_prompt})"
+ }
+
+ def transform_response(self, response: Any) -> List[Image.Image]:
+ """Transform Gemini response."""
+ # Gemini returns GenerationResult with .images list
+ if hasattr(response, 'images'):
+ return response.images
+ return []
+
+ def _calculate_aspect_ratio(self, width: int, height: int) -> str:
+ """Calculate aspect ratio string from dimensions."""
+ ratios = {
+ (1, 1): "1:1",
+ (16, 9): "16:9",
+ (9, 16): "9:16",
+ (4, 3): "4:3",
+ (3, 4): "3:4",
+ }
+
+ # Find closest ratio
+ ratio = width / height
+ for (w, h), name in ratios.items():
+ if abs(ratio - (w/h)) < 0.1:
+ return name
+
+ return "1:1" # Default
+
+
+class OmniGen2PromptTransformer(PromptTransformer):
+ """Transformer for OmniGen2 format."""
+
+ def transform_request(self, request: StandardGenerationRequest) -> Dict[str, Any]:
+ """Transform to OmniGen2 format."""
+
+ # OmniGen2 uses direct width/height
+ transformed = {
+ 'prompt': request.prompt,
+ 'width': request.width,
+ 'height': request.height,
+ 'num_inference_steps': request.num_inference_steps,
+ 'guidance_scale': request.guidance_scale,
+ }
+
+ # Add negative prompt if provided
+ if request.negative_prompt:
+ transformed['negative_prompt'] = request.negative_prompt
+
+ # Add seed if provided
+ if request.seed is not None:
+ transformed['seed'] = request.seed
+ else:
+ transformed['seed'] = -1 # Random
+
+ # Handle input images
+ if request.input_images:
+ transformed['input_images'] = request.input_images
+ transformed['strength'] = request.strength
+
+ return transformed
+
+ def transform_response(self, response: Any) -> List[Image.Image]:
+ """Transform OmniGen2 response."""
+ if hasattr(response, 'images'):
+ return response.images
+ return []
+
+
+class ComfyUIPromptTransformer(PromptTransformer):
+ """Transformer for ComfyUI workflow format."""
+
+ def transform_request(self, request: StandardGenerationRequest) -> Dict[str, Any]:
+ """Transform to ComfyUI workflow format."""
+
+ # ComfyUI uses workflow JSON with nodes
+ # This is a simplified example - actual workflows are complex
+
+ workflow = {
+ 'nodes': {
+ # Text encoder
+ 'prompt_positive': {
+ 'class_type': 'CLIPTextEncode',
+ 'inputs': {
+ 'text': request.prompt
+ }
+ },
+
+ # Negative prompt
+ 'prompt_negative': {
+ 'class_type': 'CLIPTextEncode',
+ 'inputs': {
+ 'text': request.negative_prompt or ''
+ }
+ },
+
+ # KSampler
+ 'sampler': {
+ 'class_type': 'KSampler',
+ 'inputs': {
+ 'seed': request.seed if request.seed else -1,
+ 'steps': request.num_inference_steps,
+ 'cfg': request.guidance_scale,
+ 'width': request.width,
+ 'height': request.height,
+ }
+ },
+ }
+ }
+
+ return workflow
+
+ def transform_response(self, response: Any) -> List[Image.Image]:
+ """Transform ComfyUI response."""
+ # ComfyUI returns images in specific format
+ if isinstance(response, dict) and 'images' in response:
+ return response['images']
+ return []
+
+
+class FluxPromptTransformer(PromptTransformer):
+ """Transformer for Flux.1 Kontext AI format."""
+
+ def transform_request(self, request: StandardGenerationRequest) -> Dict[str, Any]:
+ """Transform to Flux format."""
+
+ transformed = {
+ 'prompt': request.prompt,
+ 'width': request.width,
+ 'height': request.height,
+ 'num_inference_steps': request.num_inference_steps,
+ 'guidance_scale': request.guidance_scale,
+ }
+
+ # Flux supports context images
+ if request.input_images:
+ transformed['context_images'] = request.input_images
+ transformed['context_strength'] = request.strength
+
+ return transformed
+
+ def transform_response(self, response: Any) -> List[Image.Image]:
+ """Transform Flux response."""
+ if hasattr(response, 'images'):
+ return response.images
+ return []
+
+
+class QwenPromptTransformer(PromptTransformer):
+ """Transformer for qwen_image_edit_2509 format."""
+
+ def transform_request(self, request: StandardGenerationRequest) -> Dict[str, Any]:
+ """Transform to qwen format."""
+
+ # qwen is specifically for image editing
+ if not request.input_images:
+ raise ValueError("qwen requires input image for editing")
+
+ transformed = {
+ 'instruction': request.prompt, # qwen uses 'instruction' not 'prompt'
+ 'input_image': request.input_images[0], # First image
+ 'guidance_scale': request.guidance_scale,
+ 'num_inference_steps': request.num_inference_steps,
+ }
+
+ if request.seed is not None:
+ transformed['seed'] = request.seed
+
+ return transformed
+
+ def transform_response(self, response: Any) -> List[Image.Image]:
+ """Transform qwen response."""
+ if hasattr(response, 'edited_image'):
+ return [response.edited_image]
+ return []
+
+
+# Registry of transformers
+TRANSFORMER_REGISTRY = {
+ 'gemini': GeminiPromptTransformer,
+ 'omnigen2': OmniGen2PromptTransformer,
+ 'comfyui': ComfyUIPromptTransformer,
+ 'flux': FluxPromptTransformer,
+ 'qwen': QwenPromptTransformer,
+}
+
+
+def get_transformer(backend_type: str) -> PromptTransformer:
+ """
+ Get transformer for backend type.
+
+ Args:
+ backend_type: Backend type (e.g., 'gemini', 'omnigen2')
+
+ Returns:
+ PromptTransformer instance
+ """
+ transformer_class = TRANSFORMER_REGISTRY.get(backend_type)
+ if not transformer_class:
+ raise ValueError(f"No transformer found for backend type: {backend_type}")
+
+ return transformer_class()
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000000000000000000000000000000000000..73ea507263b6e8dfd6bd8411d155005269c7d178
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,47 @@
+@echo off
+REM Character Forge - Startup Script
+REM =================================
+
+echo.
+echo ========================================
+echo Character Forge - Starting
+echo ========================================
+echo.
+
+REM Check if GEMINI_API_KEY is set
+if "%GEMINI_API_KEY%"=="" (
+ echo WARNING: GEMINI_API_KEY environment variable is not set!
+ echo.
+ echo You can either:
+ echo 1. Set it now: set GEMINI_API_KEY=your-key-here
+ echo 2. Enter it in the UI when the app starts
+ echo.
+ echo Get your API key at: https://aistudio.google.com/app/apikey
+ echo.
+ pause
+)
+
+REM Check if streamlit is available
+python -c "import streamlit" 2>nul
+if errorlevel 1 (
+ echo ERROR: Streamlit is not installed!
+ echo.
+ echo Please run: install.bat
+ echo.
+ pause
+ exit /b 1
+)
+
+echo Starting Character Forge...
+echo.
+echo Application will open in your browser
+echo URL: http://localhost:8501
+echo.
+echo Press Ctrl+C to stop
+echo.
+
+REM Change to character_forge_image directory and start
+cd character_forge_image
+streamlit run app.py
+
+cd ..
diff --git a/start.sh b/start.sh
new file mode 100644
index 0000000000000000000000000000000000000000..02f52f21d7795564cdc7ccaf5ec109a5c0553395
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Character Forge - Startup Script
+# =================================
+
+echo
+echo "========================================"
+echo "Character Forge - Starting"
+echo "========================================"
+echo
+
+# Check if GEMINI_API_KEY is set
+if [ -z "$GEMINI_API_KEY" ]; then
+ echo "WARNING: GEMINI_API_KEY environment variable is not set!"
+ echo
+ echo "You can either:"
+ echo "1. Set it now: export GEMINI_API_KEY=your-key-here"
+ echo "2. Enter it in the UI when the app starts"
+ echo
+ echo "Get your API key at: https://aistudio.google.com/app/apikey"
+ echo
+ read -p "Press Enter to continue..."
+fi
+
+# Check if streamlit is available
+if ! python3 -c "import streamlit" 2>/dev/null; then
+ echo "ERROR: Streamlit is not installed!"
+ echo
+ echo "Please run: ./install.sh"
+ echo
+ exit 1
+fi
+
+echo "Starting Character Forge..."
+echo
+echo "Application will open in your browser"
+echo "URL: http://localhost:8501"
+echo
+echo "Press Ctrl+C to stop"
+echo
+
+# Change to character_forge_image directory and start
+cd character_forge_image
+streamlit run app.py
+
+cd ..