Files
character-browser/plans/OUTFIT_LOOKS_REFACTOR.md
Aodhan Collins 5e4348ebc1 Add extra prompts, endless generation, random character default, and small fixes
- Add extra positive/negative prompt textareas to all 9 detail pages with session persistence
- Add Endless generation button to all detail pages (continuous preview generation until stopped)
- Default character selector to "Random Character" on all secondary detail pages
- Fix queue clear endpoint (remove spurious auth check)
- Refactor app.py into routes/ and services/ modules
- Update CLAUDE.md with new architecture documentation
- Various data file updates and cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:07:16 +00:00

34 KiB

Outfit & Looks Refactor Plan

Table of Contents

  1. Bug Fixes
  2. Generate Character from Look
  3. Expanded Features: Multi-Assignment
  4. Outfit Handling Refactor
  5. Migration Script
  6. Implementation Phases

Bug Fixes

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:

@app.route('/look/<path:slug>')
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/<slug>/generate_character

@app.route('/look/<slug>/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:

<!-- Add to header near other action buttons -->
<button type="button" class="btn btn-accent" data-bs-toggle="modal" data-bs-target="#generateCharModal">
  <i class="bi bi-person-plus"></i> Generate Character
</button>

<!-- Modal for Generate Character -->
<div class="modal fade" id="generateCharModal" tabindex="-1">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Generate Character from Look</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
      </div>
      <form method="POST" action="{{ url_for('generate_character_from_look', slug=look.slug) }}">
        <div class="modal-body">
          <div class="mb-3">
            <label class="form-label">Character Name</label>
            <input type="text" class="form-control" name="character_name" 
                   value="{{ look.look_name }}" required>
          </div>
          <div class="form-check">
            <input class="form-check-input" type="checkbox" name="use_llm" id="use_llm" checked>
            <label class="form-check-label" for="use_llm">
              Use LLM to generate detailed character
            </label>
          </div>
          <div class="form-text text-muted">
            The look's LoRA will be automatically assigned to the new character.
          </div>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
          <button type="submit" class="btn btn-primary">Generate Character</button>
        </div>
      </form>
    </div>
  </div>
</div>

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:

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:

# 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:

<!-- OLD: Single character dropdown -->
{# <select name="character_id" class="form-select">
    <option value="">-- Random Character --</option>
    {% for char in characters %}
    <option value="{{ char.character_id }}" {% if char.character_id == selected_character %}selected{% endif %}>
        {{ char.name }}
    </option>
    {% endfor %}
</select> #}

<!-- NEW: Multi-character selector with checkboxes -->
<div class="card">
  <div class="card-header">
    <span>Linked Characters</span>
    <small class="text-muted">(Check to link this look)</small>
  </div>
  <div class="card-body" style="max-height: 300px; overflow-y: auto;">
    {% for char in characters %}
    <div class="form-check">
      <input class="form-check-input" type="checkbox" name="character_ids" 
             value="{{ char.character_id }}" id="char_{{ char.character_id }}"
             {% if char.character_id in look.character_ids %}checked{% endif %}>
      <label class="form-check-label" for="char_{{ char.character_id }}">
        {{ char.name }}
      </label>
    </div>
    {% endfor %}
  </div>
</div>

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)

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)

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

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:

<!-- Outfit Management Section -->
<div class="card mt-4">
  <div class="card-header">
    <span>Wardrobe</span>
  </div>
  <div class="card-body">
    <!-- Active Outfit Selector -->
    <form method="POST" action="{{ url_for('switch_outfit', slug=character.slug) }}" class="mb-3">
      <label class="form-label">Active Outfit</label>
      <div class="input-group">
        <select name="outfit_id" class="form-select">
          {% for outfit in character.get_available_outfits() %}
          <option value="{{ outfit.outfit_id }}" 
                  {% if outfit.outfit_id == character.active_outfit %}selected{% endif %}>
            {{ outfit.outfit_name }}
            {% if outfit.outfit_id == 'default' %}(Default){% endif %}
          </option>
          {% endfor %}
        </select>
        <button type="submit" class="btn btn-primary">Switch</button>
      </div>
    </form>
    
    <!-- Assign New Outfit -->
    <form method="POST" action="{{ url_for('assign_outfit', slug=character.slug) }}">
      <label class="form-label">Add Outfit to Wardrobe</label>
      <div class="input-group">
        <select name="outfit_id" class="form-select">
          <option value="">-- Select Outfit --</option>
          {% for outfit in all_outfits %}
            {% if outfit.outfit_id not in character.assigned_outfit_ids and outfit.outfit_id != 'default' %}
            <option value="{{ outfit.outfit_id }}">{{ outfit.outfit_name }}</option>
            {% endif %}
          {% endfor %}
        </select>
        <button type="submit" class="btn btn-success">Assign</button>
      </div>
    </form>
    
    <!-- Currently Assigned -->
    {% if character.assigned_outfit_ids %}
    <div class="mt-3">
      <label class="form-label">Assigned Outfits</label>
      <div class="d-flex flex-wrap gap-2">
        {% for outfit_id in character.assigned_outfit_ids %}
          {% set outfit = get_outfit_by_id(outfit_id) %}
          {% if outfit %}
          <span class="badge bg-secondary">
            {{ outfit.outfit_name }}
            <a href="{{ url_for('unassign_outfit', slug=character.slug, outfit_id=outfit_id) }}" 
               class="text-white ms-1" style="text-decoration: none;">&times;</a>
          </span>
          {% endif %}
        {% endfor %}
      </div>
    </div>
    {% endif %}
  </div>
</div>

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:

{
  "character_id": "aerith_gainsborough",
  "character_name": "Aerith Gainsborough",
  "identity": { ... },
  "wardrobe": {
    "full_body": "pink_dress, jacket",
    "top": "...",
    ...
  },
  "styles": { ... },
  "lora": { ... }
}

NEW Structure:

{
  "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):

# 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:

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:

@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:

<!-- Add to character creation form -->
<div class="mb-3">
  <label class="form-label">Outfit</label>
  <div class="btn-group w-100" role="group">
    <input type="radio" class="btn-check" name="outfit_mode" id="outfit_generate" value="generate" checked>
    <label class="btn btn-outline-primary" for="outfit_generate">Generate New</label>
    
    <input type="radio" class="btn-check" name="outfit_mode" id="outfit_existing" value="existing">
    <label class="btn btn-outline-primary" for="outfit_existing">Use Existing</label>
    
    <input type="radio" class="btn-check" name="outfit_mode" id="outfit_none" value="none">
    <label class="btn btn-outline-primary" for="outfit_none">Default Only</label>
  </div>
</div>

<!-- Existing outfit selector (shown when 'existing' selected) -->
<div class="mb-3" id="existing_outfit_selector" style="display: none;">
  <select name="existing_outfit_id" class="form-select">
    <option value="">-- Select Outfit --</option>
    {% for outfit in all_outfits %}
    <option value="{{ outfit.outfit_id }}">{{ outfit.outfit_name }}</option>
    {% endfor %}
  </select>
</div>

<script>
document.querySelectorAll('input[name="outfit_mode"]').forEach(radio => {
    radio.addEventListener('change', () => {
        document.getElementById('existing_outfit_selector').style.display = 
            document.getElementById('outfit_existing').checked ? 'block' : 'none';
    });
});
</script>

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

#!/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_idcharacter_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

-- 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

-- 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

# 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