- 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>
34 KiB
Outfit & Looks Refactor Plan
Table of Contents
- Bug Fixes
- Generate Character from Look
- Expanded Features: Multi-Assignment
- Outfit Handling Refactor
- Migration Script
- 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:
@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_idfield (string) - Template shows single character selector
New State:
- Look has
character_idsfield (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;">×</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:
- Character JSON schema
- Character creation flow
- 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:
- Generate an outfit first, then the character, OR
- 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 passexisting_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_lookroute toapp.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_idsJSON column toLookmodel - Create migration to convert
character_id→character_ids - Update
look_detailroute to handle multiple characters - Update
templates/looks/detail.htmlwith multi-select UI - Update look generation logic to respect all linked characters
Phase 4: Outfit-to-Character Assignment
- Add
assigned_outfit_idsanddefault_outfit_idtoCharactermodel - Add
get_available_outfits()andget_active_wardrobe()methods - Create routes:
assign_outfit,unassign_outfit,switch_outfit - Update
templates/detail.htmlwith 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.htmlwith 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
- Migration must be run first - All existing characters need wardrobes extracted before the new code can read them
- Default outfit - All characters must have a default outfit reference (even if empty)
- Character data structure - The JSON structure change is breaking; ensure migration script handles all edge cases
- Look character_id - Keep the old field temporarily during migration, remove after
character_idsis 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