# Outfit & Looks Refactor Plan ## Table of Contents 1. [Bug Fixes](#bug-fixes) 2. [Generate Character from Look](#generate-character-from-look) 3. [Expanded Features: Multi-Assignment](#expanded-features-multi-assignment) 4. [Outfit Handling Refactor](#outfit-handling-refactor) 5. [Migration Script](#migration-script) 6. [Implementation Phases](#implementation-phases) --- ## Bug Fixes ### Issue: Preview Gallery Not Showing on Looks Detail Pages **Root Cause:** The `look_detail()` route in `app.py` does not scan for or pass `existing_previews` to the template, while all other resource detail routes (`outfit_detail`, `action_detail`, etc.) do. **Fix Required:** Update `look_detail()` in `app.py` to scan for existing preview images: ```python @app.route('/look/') def look_detail(slug): look = Look.query.filter_by(slug=slug).first_or_404() characters = Character.query.order_by(Character.name).all() # Pre-select the linked character if set preferences = session.get(f'prefs_look_{slug}') preview_image = session.get(f'preview_look_{slug}') selected_character = session.get(f'char_look_{slug}', look.character_id or '') # FIX: Add existing_previews scanning (matching other resource routes) upload_folder = current_app.config.get('UPLOAD_FOLDER', 'static/uploads') preview_dir = os.path.join(upload_folder, 'looks', slug) existing_previews = [] if os.path.isdir(preview_dir): for f in os.listdir(preview_dir): if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): existing_previews.append(f'looks/{slug}/{f}') existing_previews.sort() return render_template('looks/detail.html', look=look, characters=characters, preferences=preferences, preview_image=preview_image, selected_character=selected_character, existing_previews=existing_previews) # <-- ADD THIS ``` --- ## Generate Character from Look ### Overview Allow users to generate a character JSON using an existing look as input, similar to the resource transfer feature but without moving or removing any files. The look's LoRA will be automatically assigned to the new character. ### Backend Implementation **New Route:** `POST /look//generate_character` ```python @app.route('/look//generate_character', methods=['POST']) def generate_character_from_look(slug): """Generate a character JSON using a look as the base.""" look = Look.query.filter_by(slug=slug).first_or_404() # Get or validate inputs character_name = request.form.get('character_name', look.look_name) use_llm = request.form.get('use_llm') == 'on' # Auto-generate slug character_slug = re.sub(r'[^a-zA-Z0-9]+', '_', character_name.lower()).strip('_') character_slug = re.sub(r'[^a-zA-Z0-9_]', '', character_slug) # Find available filename base_slug = character_slug counter = 1 while os.path.exists(os.path.join(CHARACTERS_DIR, f"{character_slug}.json")): character_slug = f"{base_slug}_{counter}" counter += 1 if use_llm: # Use LLM to generate character from look context prompt = f"""Generate a character based on this look description: Look Name: {look.look_name} Positive Prompt: {look.data.get('positive', '')} Negative Prompt: {look.data.get('negative', '')} Tags: {', '.join(look.data.get('tags', []))} LoRA Triggers: {look.data.get('lora', {}).get('lora_triggers', '')} Create a complete character JSON with identity, styles, and appropriate wardrobe fields. The character should match the visual style described in the look.""" # Call LLM generation (reuse existing LLM infrastructure) character_data = generate_character_with_llm(prompt, character_name, character_slug) else: # Create minimal character template character_data = { "character_id": character_slug, "character_name": character_name, "identity": { "base_specs": look.data.get('lora', {}).get('lora_triggers', ''), "hair": "", "eyes": "", "hands": "", "arms": "", "torso": "", "pelvis": "", "legs": "", "feet": "", "extra": "" }, "defaults": { "expression": "", "pose": "", "scene": "" }, "wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" }, "styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" }, "lora": look.data.get('lora', {}), # <-- Auto-assign look's LoRA "tags": look.data.get('tags', []) } # Save character JSON char_path = os.path.join(CHARACTERS_DIR, f"{character_slug}.json") with open(char_path, 'w') as f: json.dump(character_data, f, indent=2) # Create DB entry character = Character( character_id=character_slug, slug=character_slug, name=character_name, data=character_data ) db.session.add(character) db.session.commit() # Link the look to this character look.character_id = character_slug db.session.commit() flash(f'Character "{character_name}" created from look!', 'success') return redirect(url_for('detail', slug=character_slug)) ``` ### Frontend Implementation **Update `templates/looks/detail.html`:** Add a "Generate Character" button in the header actions: ```html ``` --- ## Expanded Features: Multi-Assignment ### Allow a Look to be Assigned to More Than One Character **Current State:** - Look has a single `character_id` field (string) - Template shows single character selector **New State:** - Look has `character_ids` field (list of strings) - Template allows multiple character selection #### Database/Model Changes **Update `Look` model in `models.py`:** ```python class Look(ResourceMixin, db.Model): __tablename__ = 'looks' look_id = db.Column(db.String, primary_key=True) slug = db.Column(db.String, unique=True, nullable=False) name = db.Column(db.String, nullable=False) data = db.Column(db.JSON) image_path = db.Column(db.String) default_fields = db.Column(db.JSON) # OLD: Single character link # character_id = db.Column(db.String, db.ForeignKey('characters.character_id')) # character = db.relationship('Character', back_populates='looks') # NEW: Multiple character links character_ids = db.Column(db.JSON, default=list) # List of character_ids def get_linked_characters(self): """Get all characters linked to this look.""" if not self.character_ids: return [] return Character.query.filter(Character.character_id.in_(self.character_ids)).all() def add_character(self, character_id): """Link a character to this look.""" if not self.character_ids: self.character_ids = [] if character_id not in self.character_ids: self.character_ids.append(character_id) def remove_character(self, character_id): """Unlink a character from this look.""" if self.character_ids and character_id in self.character_ids: self.character_ids.remove(character_id) ``` **Migration for existing data:** ```python # One-time migration: convert character_id to character_ids list for look in Look.query.all(): if look.character_id and not look.character_ids: look.character_ids = [look.character_id] look.character_id = None # Clear old field db.session.commit() ``` #### UI Changes for Multi-Character Assignment **Update `templates/looks/detail.html`:** Replace single character selector with multi-select: ```html {# #}
Linked Characters (Check to link this look)
{% for char in characters %}
{% endfor %}
``` --- ### Allow Outfits to be Assigned to Characters as Additional Wardrobe Items **New Relationship:** Many-to-many between Characters and Outfits **Implementation Options:** **Option A: JSON Array in Character (Simplest)** ```python class Character(db.Model): # ... existing fields ... assigned_outfit_ids = db.Column(db.JSON, default=list) # List of outfit_ids default_outfit_id = db.Column(db.String, default='default') # Always have a default ``` **Option B: Association Table (More Robust)** ```python character_outfits = db.Table('character_outfits', db.Column('character_id', db.String, db.ForeignKey('characters.character_id')), db.Column('outfit_id', db.String, db.ForeignKey('outfits.outfit_id')), db.Column('is_default', db.Boolean, default=False) ) ``` **Recommended: Option A** for simplicity and alignment with existing JSON-heavy architecture. #### Character Model Updates ```python class Character(db.Model): character_id = db.Column(db.String, primary_key=True) slug = db.Column(db.String, unique=True, nullable=False) name = db.Column(db.String, nullable=False) data = db.Column(db.JSON) active_outfit = db.Column(db.String, default='default') # NEW: List of assigned outfit IDs (empty list means only default) assigned_outfit_ids = db.Column(db.JSON, default=list) def get_available_outfits(self): """Get all outfits available to this character (assigned + default).""" outfit_ids = ['default'] # Always include default if self.assigned_outfit_ids: outfit_ids.extend(self.assigned_outfit_ids) # Fetch outfit objects from DB outfits = Outfit.query.filter(Outfit.outfit_id.in_(outfit_ids)).all() # Sort so default is first outfits.sort(key=lambda o: (o.outfit_id != 'default', o.outfit_name)) return outfits def get_active_wardrobe(self): """Get wardrobe data from the active outfit.""" if self.active_outfit and self.active_outfit != 'default': outfit = Outfit.query.filter_by(outfit_id=self.active_outfit).first() if outfit and outfit.data.get('wardrobe'): return outfit.data['wardrobe'] # Fallback to legacy embedded wardrobe or empty return self.data.get('wardrobe', {}) if self.data else {} def assign_outfit(self, outfit_id): """Assign an outfit to this character.""" if not self.assigned_outfit_ids: self.assigned_outfit_ids = [] if outfit_id not in self.assigned_outfit_ids: self.assigned_outfit_ids.append(outfit_id) def unassign_outfit(self, outfit_id): """Remove an outfit assignment.""" if self.assigned_outfit_ids and outfit_id in self.assigned_outfit_ids: self.assigned_outfit_ids.remove(outfit_id) # Reset to default if we just removed the active outfit if self.active_outfit == outfit_id: self.active_outfit = 'default' ``` #### UI for Outfit Assignment **Update `templates/detail.html` (character detail):** Add an outfit management section: ```html
Wardrobe
{% if character.assigned_outfit_ids %}
{% for outfit_id in character.assigned_outfit_ids %} {% set outfit = get_outfit_by_id(outfit_id) %} {% if outfit %} {{ outfit.outfit_name }} × {% endif %} {% endfor %}
{% endif %}
``` --- ## Outfit Handling Refactor ### Overview Character JSON files will no longer contain built-in wardrobe items. Instead, they will reference external outfit files. This requires changes to: 1. Character JSON schema 2. Character creation flow 3. Prompt building logic ### New Character JSON Schema **OLD Structure:** ```json { "character_id": "aerith_gainsborough", "character_name": "Aerith Gainsborough", "identity": { ... }, "wardrobe": { "full_body": "pink_dress, jacket", "top": "...", ... }, "styles": { ... }, "lora": { ... } } ``` **NEW Structure:** ```json { "character_id": "aerith_gainsborough", "character_name": "Aerith Gainsborough", "identity": { ... }, "styles": { ... }, "lora": { ... }, "tags": [...], "defaults": { "expression": "", "pose": "", "scene": "", "outfit": "aerith_gainsborough - default" // Reference to outfit file } } ``` The `wardrobe` section is removed from the character JSON. Wardrobe data now lives exclusively in outfit files in `data/clothing/`. ### Prompt Building Refactor **Current Prompt Building (in `_prepare_workflow` or similar):** ```python # Build wardrobe from character's embedded wardrobe wardrobe_data = character.data.get('wardrobe', {}) wardrobe_parts = [v for k, v in wardrobe_data.items() if v and include_field(f'wardrobe::{k}')] if wardrobe_parts: prompt_parts.append(', '.join(wardrobe_parts)) ``` **New Prompt Building:** ```python def get_outfit_wardrobe(character, outfit_id=None): """ Get wardrobe data from the referenced outfit file. Falls back to default outfit if outfit_id not found. """ if outfit_id is None: outfit_id = character.data.get('defaults', {}).get('outfit', 'default') # Try to load outfit from DB/file outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() if outfit and outfit.data: return outfit.data.get('wardrobe', {}) # Fallback to default outfit default_outfit = Outfit.query.filter_by(outfit_id='default').first() if default_outfit and default_outfit.data: return default_outfit.data.get('wardrobe', {}) # Final fallback: empty wardrobe return {} # In prompt building: wardrobe_data = get_outfit_wardrobe(character, outfit_id) wardrobe_parts = [v for k, v in wardrobe_data.items() if v and include_field(f'wardrobe::{k}')] if wardrobe_parts: prompt_parts.append(', '.join(wardrobe_parts)) ``` ### Character Creation Refactor (Two-Step LLM Flow) When creating a character, we now need to either: 1. Generate an outfit first, then the character, OR 2. Assign an existing outfit **New Character Creation Flow:** ```python @app.route('/create', methods=['GET', 'POST']) def create_character(): if request.method == 'POST': name = request.form.get('name') use_llm = request.form.get('use_llm') == 'on' outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none' existing_outfit_id = request.form.get('existing_outfit_id') # ... slug generation and validation ... if use_llm: # Step 1: Generate outfit first (if requested) if outfit_mode == 'generate': outfit_prompt = f"""Generate an outfit for character "{name}". The character is described as: {prompt} Create an outfit JSON with wardrobe fields appropriate for this character.""" outfit_data = generate_outfit_with_llm(outfit_prompt, outfit_slug, name) # Save the outfit outfit_path = os.path.join(CLOTHING_DIR, f"{outfit_slug}.json") with open(outfit_path, 'w') as f: json.dump(outfit_data, f, indent=2) outfit = Outfit( outfit_id=outfit_slug, slug=outfit_slug, name=f"{name} - default", data=outfit_data ) db.session.add(outfit) db.session.commit() default_outfit_id = outfit_slug elif outfit_mode == 'existing': default_outfit_id = existing_outfit_id else: default_outfit_id = 'default' # Step 2: Generate character (without wardrobe section) char_prompt = f"""Generate a character named "{name}". Description: {prompt} Default Outfit: {default_outfit_id} Create a character JSON with identity, styles, and defaults sections. Do NOT include a wardrobe section - the outfit is handled separately.""" character_data = generate_character_with_llm(char_prompt, name, slug) # Ensure outfit reference is set if 'defaults' not in character_data: character_data['defaults'] = {} character_data['defaults']['outfit'] = default_outfit_id # Remove any wardrobe section that LLM might have added character_data.pop('wardrobe', None) else: # Non-LLM: Create minimal character character_data = { "character_id": slug, "character_name": name, "identity": { "base_specs": prompt, "hair": "", "eyes": "", "hands": "", "arms": "", "torso": "", "pelvis": "", "legs": "", "feet": "", "extra": "" }, "defaults": { "expression": "", "pose": "", "scene": "", "outfit": existing_outfit_id if outfit_mode == 'existing' else 'default' }, "styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" }, "lora": { "lora_name": "", "lora_weight": 1, "lora_weight_min": 0.7, "lora_weight_max": 1, "lora_triggers": "" }, "tags": [] } # ... save character ... # If outfit was generated, assign it to the character if outfit_mode == 'generate' and default_outfit_id != 'default': character.assigned_outfit_ids = [default_outfit_id] db.session.commit() ``` **Update `templates/create.html`:** ```html
``` --- ## Migration Script ### Extract Existing Wardrobes to Outfit Files Before the refactor can go live, we need to extract existing wardrobe data from character JSON files into the outfits directory. **Script: `tools/migrate_wardrobes.py`** ```python #!/usr/bin/env python3 """ Migration script: Extract wardrobes from character JSON files into outfit files. Usage: python tools/migrate_wardrobes.py [--dry-run] This will: 1. Read each character JSON in data/characters/ 2. Extract the wardrobe section 3. Create a new outfit file: data/clothing/{char_name} - default.json 4. Update the character JSON to remove wardrobe and add defaults.outfit reference """ import os import sys import json import re from pathlib import Path # Add parent to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) CHARACTERS_DIR = Path('data/characters') CLOTHING_DIR = Path('data/clothing') def sanitize_filename(name): """Convert name to safe filename.""" # Remove/replace unsafe characters safe = re.sub(r'[<>:"/\\|?*]', '', name) safe = safe.strip() return safe def migrate_character(char_path, dry_run=True): """Migrate a single character file.""" with open(char_path, 'r') as f: data = json.load(f) char_id = data.get('character_id', '') char_name = data.get('character_name', char_id) wardrobe = data.get('wardrobe', {}) # Skip if no wardrobe or wardrobe is empty if not wardrobe or not any(wardrobe.values()): print(f" Skipping {char_name} - no wardrobe data") return # Generate outfit filename outfit_name = f"{char_name} - default" outfit_slug = sanitize_filename(outfit_name).lower().replace(' ', '_') outfit_filename = f"{outfit_slug}.json" outfit_path = CLOTHING_DIR / outfit_filename # Handle duplicates counter = 1 while outfit_path.exists(): outfit_slug = f"{sanitize_filename(char_name).lower().replace(' ', '_')}_{counter}_default" outfit_filename = f"{outfit_slug}.json" outfit_path = CLOTHING_DIR / outfit_filename counter += 1 # Create outfit data outfit_data = { "outfit_id": outfit_slug, "outfit_name": f"{char_name} - default", "wardrobe": wardrobe, "lora": { "lora_name": "", "lora_weight": 0.8, "lora_triggers": "", "lora_weight_min": 0.8, "lora_weight_max": 0.8 }, "tags": ["default", char_name.lower().replace(' ', '_')] } # Copy LoRA from character if present char_lora = data.get('lora', {}) if char_lora and char_lora.get('lora_name'): outfit_data['lora'] = char_lora print(f" Creating outfit: {outfit_filename}") print(f" Wardrobe fields: {list(wardrobe.keys())}") if not dry_run: # Write outfit file with open(outfit_path, 'w') as f: json.dump(outfit_data, f, indent=2) # Update character JSON # Remove wardrobe section data.pop('wardrobe', None) # Ensure defaults exists and add outfit reference if 'defaults' not in data: data['defaults'] = {} data['defaults']['outfit'] = outfit_slug # Write updated character file with open(char_path, 'w') as f: json.dump(data, f, indent=2) print(f" Updated character file") return outfit_slug def main(): import argparse parser = argparse.ArgumentParser(description='Migrate character wardrobes to outfit files') parser.add_argument('--dry-run', action='store_true', help='Show what would be done without making changes') args = parser.parse_args() if args.dry_run: print("=== DRY RUN MODE (no files will be modified) ===\n") # Ensure clothing directory exists CLOTHING_DIR.mkdir(parents=True, exist_ok=True) # Process all character files char_files = list(CHARACTERS_DIR.glob('*.json')) print(f"Found {len(char_files)} character files") print() migrated = 0 skipped = 0 for char_path in sorted(char_files): print(f"Processing: {char_path.name}") result = migrate_character(char_path, dry_run=args.dry_run) if result: migrated += 1 else: skipped += 1 print() print(f"Migration complete:") print(f" Migrated: {migrated}") print(f" Skipped (no wardrobe): {skipped}") if args.dry_run: print() print("This was a dry run. Run without --dry-run to apply changes.") if __name__ == '__main__': main() ``` **Example Output:** ``` === DRY RUN MODE (no files will be modified) === Found 45 character files Processing: 2b.json Creating outfit: 2b_-_default.json Wardrobe fields: ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories'] Processing: aerith_gainsborough.json Creating outfit: aerith_gainsborough_-_default.json Wardrobe fields: ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories'] Processing: barret_wallace.json Skipping Barret Wallace - no wardrobe data Migration complete: Migrated: 42 Skipped (no wardrobe): 3 ``` --- ## Implementation Phases ### Phase 1: Bug Fix & Foundation (Immediate) - [ ] Fix `look_detail()` to pass `existing_previews` (5 min fix) - [ ] Create migration script `tools/migrate_wardrobes.py` - [ ] Run migration script to extract existing wardrobes ### Phase 2: Character Generation from Look - [ ] Add `generate_character_from_look` route to `app.py` - [ ] Add modal UI to `templates/looks/detail.html` - [ ] Test with both LLM and non-LLM paths ### Phase 3: Multi-Character Look Assignment - [ ] Add `character_ids` JSON column to `Look` model - [ ] Create migration to convert `character_id` → `character_ids` - [ ] Update `look_detail` route to handle multiple characters - [ ] Update `templates/looks/detail.html` with multi-select UI - [ ] Update look generation logic to respect all linked characters ### Phase 4: Outfit-to-Character Assignment - [ ] Add `assigned_outfit_ids` and `default_outfit_id` to `Character` model - [ ] Add `get_available_outfits()` and `get_active_wardrobe()` methods - [ ] Create routes: `assign_outfit`, `unassign_outfit`, `switch_outfit` - [ ] Update `templates/detail.html` with outfit management UI - [ ] Update outfit detail page to show linked characters ### Phase 5: Outfit Handling Refactor - [ ] Update character creation to support two-step LLM flow - [ ] Update `templates/create.html` with outfit mode selector - [ ] Refactor `_prepare_workflow` / prompt building to pull from outfit files - [ ] Create default outfit file if it doesn't exist - [ ] Update character detail page to show wardrobe from external outfit ### Phase 6: Testing & Cleanup - [ ] Verify all existing characters work with new outfit system - [ ] Test character creation with all outfit modes - [ ] Test look generation with multiple linked characters - [ ] Clean up any legacy code referencing embedded wardrobes - [ ] Update documentation (CLAUDE.md, DEVELOPMENT_GUIDE.md) --- ## Database Migration Summary ### Migration 1: Look Multi-Character Support ```sql -- Add new column ALTER TABLE looks ADD COLUMN character_ids JSON; -- Migrate existing data UPDATE looks SET character_ids = JSON_ARRAY(character_id) WHERE character_id IS NOT NULL; UPDATE looks SET character_ids = JSON_ARRAY() WHERE character_id IS NULL; -- Optionally drop old column after migration is verified -- ALTER TABLE looks DROP COLUMN character_id; ``` ### Migration 2: Character Outfit Assignment ```sql -- Add new columns ALTER TABLE characters ADD COLUMN assigned_outfit_ids JSON DEFAULT '[]'; ALTER TABLE characters ADD COLUMN default_outfit_id VARCHAR(255) DEFAULT 'default'; ``` ### Migration 3: Ensure Default Outfit Exists ```python # Ensure there's a default outfit in the database default_outfit = Outfit.query.filter_by(outfit_id='default').first() if not default_outfit: default_outfit = Outfit( outfit_id='default', slug='default', name='Default Outfit', data={'wardrobe': {}, 'lora': {}, 'tags': ['default']} ) db.session.add(default_outfit) db.session.commit() ``` --- ## Files to Modify | File | Changes | |------|---------| | `app.py` | Add `existing_previews` to `look_detail()`, add `generate_character_from_look` route, update `create_character` for two-step flow, update prompt building | | `models.py` | Add `character_ids` to `Look`, add `assigned_outfit_ids`/`default_outfit_id` to `Character`, add helper methods | | `templates/looks/detail.html` | Add generate character modal, update to multi-character selector | | `templates/detail.html` | Add outfit management section | | `templates/create.html` | Add outfit mode selector (generate/existing/none) | | `templates/outfits/detail.html` | Show linked characters | | `tools/migrate_wardrobes.py` | New migration script | | `CLAUDE.md` | Update character JSON schema documentation | | `DEVELOPMENT_GUIDE.md` | Update development guidelines | --- ## Backward Compatibility Notes 1. **Migration must be run first** - All existing characters need wardrobes extracted before the new code can read them 2. **Default outfit** - All characters must have a default outfit reference (even if empty) 3. **Character data structure** - The JSON structure change is breaking; ensure migration script handles all edge cases 4. **Look character_id** - Keep the old field temporarily during migration, remove after `character_ids` is populated --- ## Testing Checklist - [ ] Look preview gallery displays correctly - [ ] Generate character from look works (LLM mode) - [ ] Generate character from look works (non-LLM mode) - [ ] Look's LoRA is auto-assigned to new character - [ ] Multiple characters can be linked to a look - [ ] Look generation respects all linked characters - [ ] Existing outfits can be assigned to characters - [ ] Characters can switch between assigned outfits - [ ] Character creation with generated outfit works - [ ] Character creation with existing outfit works - [ ] Character creation with default outfit works - [ ] Prompt building pulls from correct outfit file - [ ] Fallback to default outfit if referenced outfit missing - [ ] Migration script extracts all wardrobes correctly - [ ] No data loss during migration