- 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>
1007 lines
34 KiB
Markdown
1007 lines
34 KiB
Markdown
# 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/<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`
|
|
|
|
```python
|
|
@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:
|
|
|
|
```html
|
|
<!-- 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`:**
|
|
|
|
```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
|
|
<!-- 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)**
|
|
```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
|
|
<!-- 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:
|
|
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
|
|
<!-- 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`**
|
|
|
|
```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
|