Add Preset Library feature

Presets are saved generation recipes that combine all resource types
(character, outfit, action, style, scene, detailer, look, checkpoint)
with per-field on/off/random toggles. At generation time, entities
marked "random" are picked from the DB and fields marked "random" are
randomly included or excluded.

- Preset model + sync_presets() following existing category pattern
- _resolve_preset_entity() / _resolve_preset_fields() helpers
- Full route set: index, detail, generate, edit, upload, clone, save_json, create (LLM), rescan
- 4 templates: index (gallery), detail (summary + generate), edit (3-way toggle UI), create (LLM form)
- example_01.json reference preset + preset_system.txt LLM prompt
- Presets nav link in layout.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-05 23:49:24 +00:00
parent 2c1c3a7ed7
commit ec08eb5d31
10 changed files with 1493 additions and 1 deletions

538
app.py
View File

@@ -15,7 +15,7 @@ from mcp.client.stdio import stdio_client
from flask import Flask, render_template, request, redirect, url_for, flash, session from flask import Flask, render_template, request, redirect, url_for, flash, session
from flask_session import Session from flask_session import Session
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from models import db, Character, Settings, Outfit, Action, Style, Detailer, Scene, Checkpoint, Look from models import db, Character, Settings, Outfit, Action, Style, Detailer, Scene, Checkpoint, Look, Preset
app = Flask(__name__) app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
@@ -30,6 +30,7 @@ app.config['SCENES_DIR'] = 'data/scenes'
app.config['DETAILERS_DIR'] = 'data/detailers' app.config['DETAILERS_DIR'] = 'data/detailers'
app.config['CHECKPOINTS_DIR'] = 'data/checkpoints' app.config['CHECKPOINTS_DIR'] = 'data/checkpoints'
app.config['LOOKS_DIR'] = 'data/looks' app.config['LOOKS_DIR'] = 'data/looks'
app.config['PRESETS_DIR'] = 'data/presets'
app.config['COMFYUI_URL'] = os.environ.get('COMFYUI_URL', 'http://127.0.0.1:8188') app.config['COMFYUI_URL'] = os.environ.get('COMFYUI_URL', 'http://127.0.0.1:8188')
app.config['ILLUSTRIOUS_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Illustrious/' app.config['ILLUSTRIOUS_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Illustrious/'
app.config['NOOB_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Noob/' app.config['NOOB_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Noob/'
@@ -936,6 +937,125 @@ def sync_looks():
db.session.commit() db.session.commit()
def sync_presets():
if not os.path.exists(app.config['PRESETS_DIR']):
return
current_ids = []
for filename in os.listdir(app.config['PRESETS_DIR']):
if filename.endswith('.json'):
file_path = os.path.join(app.config['PRESETS_DIR'], filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
preset_id = data.get('preset_id') or filename.replace('.json', '')
current_ids.append(preset_id)
slug = re.sub(r'[^a-zA-Z0-9_]', '', preset_id)
preset = Preset.query.filter_by(preset_id=preset_id).first()
name = data.get('preset_name', preset_id.replace('_', ' ').title())
if preset:
preset.data = data
preset.name = name
preset.slug = slug
preset.filename = filename
if preset.image_path:
full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], preset.image_path)
if not os.path.exists(full_img_path):
preset.image_path = None
flag_modified(preset, "data")
else:
new_preset = Preset(
preset_id=preset_id,
slug=slug,
filename=filename,
name=name,
data=data
)
db.session.add(new_preset)
except Exception as e:
print(f"Error importing preset {filename}: {e}")
all_presets = Preset.query.all()
for preset in all_presets:
if preset.preset_id not in current_ids:
db.session.delete(preset)
db.session.commit()
# ---------------------------------------------------------------------------
# Preset helpers
# ---------------------------------------------------------------------------
_PRESET_ENTITY_MAP = {
'character': (Character, 'character_id'),
'outfit': (Outfit, 'outfit_id'),
'action': (Action, 'action_id'),
'style': (Style, 'style_id'),
'scene': (Scene, 'scene_id'),
'detailer': (Detailer, 'detailer_id'),
'look': (Look, 'look_id'),
'checkpoint': (Checkpoint, 'checkpoint_path'),
}
def _resolve_preset_entity(entity_type, entity_id):
"""Resolve a preset entity_id ('random', specific ID, or None) to an ORM object."""
if not entity_id:
return None
model_class, id_field = _PRESET_ENTITY_MAP[entity_type]
if entity_id == 'random':
return model_class.query.order_by(db.func.random()).first()
return model_class.query.filter(getattr(model_class, id_field) == entity_id).first()
def _resolve_preset_fields(preset_data):
"""Convert preset field toggle dicts into a selected_fields list.
Each field value: True = include, False = exclude, 'random' = randomly decide.
Returns a list of 'section::key' strings for fields that are active.
"""
selected = []
char_cfg = preset_data.get('character', {})
fields = char_cfg.get('fields', {})
for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']:
val = fields.get('identity', {}).get(key, True)
if val == 'random':
val = random.choice([True, False])
if val:
selected.append(f'identity::{key}')
for key in ['expression', 'pose', 'scene']:
val = fields.get('defaults', {}).get(key, False)
if val == 'random':
val = random.choice([True, False])
if val:
selected.append(f'defaults::{key}')
wardrobe_cfg = fields.get('wardrobe', {})
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']:
val = wardrobe_cfg.get('fields', {}).get(key, True)
if val == 'random':
val = random.choice([True, False])
if val:
selected.append(f'wardrobe::{key}')
# Always include name and lora triggers
selected.append('special::name')
if char_cfg.get('use_lora', True):
selected.append('lora::lora_triggers')
return selected
def sync_actions(): def sync_actions():
if not os.path.exists(app.config['ACTIONS_DIR']): if not os.path.exists(app.config['ACTIONS_DIR']):
return return
@@ -2407,6 +2527,421 @@ def clear_all_scene_covers():
db.session.commit() db.session.commit()
return {'success': True} return {'success': True}
# ============ PRESET ROUTES ============
@app.route('/presets')
def presets_index():
presets = Preset.query.order_by(Preset.filename).all()
return render_template('presets/index.html', presets=presets)
@app.route('/preset/<path:slug>')
def preset_detail(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_preset_{slug}')
return render_template('presets/detail.html', preset=preset, preview_path=preview_path)
@app.route('/preset/<path:slug>/generate', methods=['POST'])
def generate_preset_image(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
try:
action = request.form.get('action', 'preview')
data = preset.data
# Resolve entities
char_cfg = data.get('character', {})
character = _resolve_preset_entity('character', char_cfg.get('character_id'))
if not character:
character = Character.query.order_by(db.func.random()).first()
outfit_cfg = data.get('outfit', {})
action_cfg = data.get('action', {})
style_cfg = data.get('style', {})
scene_cfg = data.get('scene', {})
detailer_cfg = data.get('detailer', {})
look_cfg = data.get('look', {})
ckpt_cfg = data.get('checkpoint', {})
outfit = _resolve_preset_entity('outfit', outfit_cfg.get('outfit_id'))
action_obj = _resolve_preset_entity('action', action_cfg.get('action_id'))
style_obj = _resolve_preset_entity('style', style_cfg.get('style_id'))
scene_obj = _resolve_preset_entity('scene', scene_cfg.get('scene_id'))
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
# Checkpoint: preset override or session default
preset_ckpt = ckpt_cfg.get('checkpoint_path')
if preset_ckpt == 'random':
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None
ckpt_data = ckpt_obj.data if ckpt_obj else None
elif preset_ckpt:
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
ckpt_path = preset_ckpt
ckpt_data = ckpt_obj.data if ckpt_obj else None
else:
ckpt_path, ckpt_data = _get_default_checkpoint()
# Resolve selected fields from preset toggles
selected_fields = _resolve_preset_fields(data)
# Build combined data for prompt building
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
if wardrobe_source is None:
wardrobe_source = character.get_active_wardrobe() if character else {}
combined_data = {
'character_id': character.character_id if character else 'unknown',
'identity': character.data.get('identity', {}) if character else {},
'defaults': character.data.get('defaults', {}) if character else {},
'wardrobe': wardrobe_source,
'styles': character.data.get('styles', {}) if character else {},
'lora': (look_obj.data.get('lora', {}) if look_obj
else (character.data.get('lora', {}) if character else {})),
'tags': (character.data.get('tags', []) if character else []) + data.get('tags', []),
}
# Build extras prompt from secondary resources
extras_parts = []
if action_obj:
action_fields = action_cfg.get('fields', {})
for key in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']:
val_cfg = action_fields.get(key, True)
if val_cfg == 'random':
val_cfg = random.choice([True, False])
if val_cfg:
val = action_obj.data.get('action', {}).get(key, '')
if val:
extras_parts.append(val)
if action_cfg.get('use_lora', True):
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
extras_parts.extend(action_obj.data.get('tags', []))
if style_obj:
s = style_obj.data.get('style', {})
if s.get('artist_name'):
extras_parts.append(f"by {s['artist_name']}")
if s.get('artistic_style'):
extras_parts.append(s['artistic_style'])
if style_cfg.get('use_lora', True):
trg = style_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
if scene_obj:
scene_fields = scene_cfg.get('fields', {})
for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']:
val_cfg = scene_fields.get(key, True)
if val_cfg == 'random':
val_cfg = random.choice([True, False])
if val_cfg:
val = scene_obj.data.get('scene', {}).get(key, '')
if val:
extras_parts.append(val)
if scene_cfg.get('use_lora', True):
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
extras_parts.extend(scene_obj.data.get('tags', []))
if detailer_obj:
prompt_val = detailer_obj.data.get('prompt', '')
if isinstance(prompt_val, list):
extras_parts.extend(p for p in prompt_val if p)
elif prompt_val:
extras_parts.append(prompt_val)
if detailer_cfg.get('use_lora', True):
trg = detailer_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
prompts = build_prompt(combined_data, selected_fields, default_fields=None,
active_outfit=active_wardrobe)
if extras_parts:
extra_str = ', '.join(filter(None, extras_parts))
prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra_str}" if prompts['main'] else extra_str)
workflow = _prepare_workflow(
workflow, character, prompts,
checkpoint=ckpt_path, checkpoint_data=ckpt_data,
outfit=outfit if outfit_cfg.get('use_lora', True) else None,
action=action_obj if action_cfg.get('use_lora', True) else None,
style=style_obj if style_cfg.get('use_lora', True) else None,
scene=scene_obj if scene_cfg.get('use_lora', True) else None,
detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None,
look=look_obj,
)
label = f"Preset: {preset.name} {action}"
job = _enqueue_job(label, workflow, _make_finalize('presets', slug, Preset, action))
session[f'preview_preset_{slug}'] = None
session.modified = True
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('preset_detail', slug=slug))
except Exception as e:
logger.exception("Generation error (preset %s): %s", slug, e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_preset_cover_from_preview(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
preset.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/upload', methods=['POST'])
def upload_preset_image(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file uploaded.')
return redirect(url_for('preset_detail', slug=slug))
file = request.files['image']
if file.filename == '':
flash('No file selected.')
return redirect(url_for('preset_detail', slug=slug))
filename = secure_filename(file.filename)
folder = os.path.join(app.config['UPLOAD_FOLDER'], f'presets/{slug}')
os.makedirs(folder, exist_ok=True)
file.save(os.path.join(folder, filename))
preset.image_path = f'presets/{slug}/{filename}'
db.session.commit()
flash('Image uploaded!')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/edit', methods=['GET', 'POST'])
def edit_preset(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
if request.method == 'POST':
name = request.form.get('preset_name', preset.name)
preset.name = name
# Rebuild the data dict from form fields
def _tog(val):
"""Convert form value ('true'/'false'/'random') to JSON toggle value."""
if val == 'random':
return 'random'
return val == 'true'
def _entity_id(val):
return val if val else None
char_id = _entity_id(request.form.get('char_character_id'))
new_data = {
'preset_id': preset.preset_id,
'preset_name': name,
'character': {
'character_id': char_id,
'use_lora': request.form.get('char_use_lora') == 'on',
'fields': {
'identity': {k: _tog(request.form.get(f'id_{k}', 'true'))
for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']},
'defaults': {k: _tog(request.form.get(f'def_{k}', 'false'))
for k in ['expression', 'pose', 'scene']},
'wardrobe': {
'outfit': request.form.get('wardrobe_outfit', 'default') or 'default',
'fields': {k: _tog(request.form.get(f'wd_{k}', 'true'))
for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']},
},
},
},
'outfit': {'outfit_id': _entity_id(request.form.get('outfit_id')),
'use_lora': request.form.get('outfit_use_lora') == 'on'},
'action': {'action_id': _entity_id(request.form.get('action_id')),
'use_lora': request.form.get('action_use_lora') == 'on',
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}},
'style': {'style_id': _entity_id(request.form.get('style_id')),
'use_lora': request.form.get('style_use_lora') == 'on'},
'scene': {'scene_id': _entity_id(request.form.get('scene_id')),
'use_lora': request.form.get('scene_use_lora') == 'on',
'fields': {k: _tog(request.form.get(f'scn_{k}', 'true'))
for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
'detailer': {'detailer_id': _entity_id(request.form.get('detailer_id')),
'use_lora': request.form.get('detailer_use_lora') == 'on'},
'look': {'look_id': _entity_id(request.form.get('look_id'))},
'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))},
'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()],
}
preset.data = new_data
flag_modified(preset, "data")
db.session.commit()
# Write back to JSON file
if preset.filename:
file_path = os.path.join(app.config['PRESETS_DIR'], preset.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
flash('Preset saved!')
return redirect(url_for('preset_detail', slug=slug))
characters = Character.query.order_by(Character.name).all()
outfits = Outfit.query.order_by(Outfit.name).all()
actions = Action.query.order_by(Action.name).all()
styles = Style.query.order_by(Style.name).all()
scenes = Scene.query.order_by(Scene.name).all()
detailers = Detailer.query.order_by(Detailer.name).all()
looks = Look.query.order_by(Look.name).all()
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
return render_template('presets/edit.html', preset=preset,
characters=characters, outfits=outfits, actions=actions,
styles=styles, scenes=scenes, detailers=detailers,
looks=looks, checkpoints=checkpoints)
@app.route('/preset/<path:slug>/save_json', methods=['POST'])
def save_preset_json(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
preset.data = new_data
preset.name = new_data.get('preset_name', preset.name)
flag_modified(preset, "data")
db.session.commit()
if preset.filename:
file_path = os.path.join(app.config['PRESETS_DIR'], preset.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}, 400
@app.route('/preset/<path:slug>/clone', methods=['POST'])
def clone_preset(slug):
original = Preset.query.filter_by(slug=slug).first_or_404()
new_data = dict(original.data)
base_id = f"{original.preset_id}_copy"
new_id = base_id
counter = 1
while Preset.query.filter_by(preset_id=new_id).first():
new_id = f"{base_id}_{counter}"
counter += 1
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_data['preset_id'] = new_id
new_data['preset_name'] = f"{original.name} (Copy)"
new_filename = f"{new_id}.json"
os.makedirs(app.config['PRESETS_DIR'], exist_ok=True)
with open(os.path.join(app.config['PRESETS_DIR'], new_filename), 'w') as f:
json.dump(new_data, f, indent=2)
new_preset = Preset(preset_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['preset_name'], data=new_data)
db.session.add(new_preset)
db.session.commit()
flash(f"Cloned as '{new_data['preset_name']}'")
return redirect(url_for('preset_detail', slug=new_slug))
@app.route('/presets/rescan', methods=['POST'])
def rescan_presets():
sync_presets()
flash('Preset library synced.')
return redirect(url_for('presets_index'))
@app.route('/preset/create', methods=['GET', 'POST'])
def create_preset():
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
use_llm = request.form.get('use_llm') == 'on'
safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset'
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
base_id = safe_id
counter = 1
while os.path.exists(os.path.join(app.config['PRESETS_DIR'], f"{safe_id}.json")):
safe_id = f"{base_id}_{counter}"
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
counter += 1
if use_llm and description:
system_prompt = load_prompt('preset_system.txt')
if not system_prompt:
flash('Preset system prompt file not found.', 'error')
return redirect(request.url)
try:
llm_response = call_llm(
f"Create a preset profile named '{name}' based on this description: {description}",
system_prompt
)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
preset_data = json.loads(clean_json)
except Exception as e:
logger.exception("LLM error creating preset: %s", e)
flash(f"AI generation failed: {e}", 'error')
return redirect(request.url)
else:
# Blank preset with sensible defaults
preset_data = {
'character': {'character_id': 'random', 'use_lora': True,
'fields': {
'identity': {k: True for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']},
'defaults': {k: False for k in ['expression', 'pose', 'scene']},
'wardrobe': {'outfit': 'default',
'fields': {k: True for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}},
}},
'outfit': {'outfit_id': None, 'use_lora': True},
'action': {'action_id': None, 'use_lora': True,
'fields': {k: True for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}},
'style': {'style_id': None, 'use_lora': True},
'scene': {'scene_id': None, 'use_lora': True,
'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
'detailer': {'detailer_id': None, 'use_lora': True},
'look': {'look_id': None},
'checkpoint': {'checkpoint_path': None},
'tags': [],
}
preset_data['preset_id'] = safe_id
preset_data['preset_name'] = name
os.makedirs(app.config['PRESETS_DIR'], exist_ok=True)
file_path = os.path.join(app.config['PRESETS_DIR'], f"{safe_id}.json")
with open(file_path, 'w') as f:
json.dump(preset_data, f, indent=2)
new_preset = Preset(preset_id=safe_id, slug=safe_slug,
filename=f"{safe_id}.json", name=name, data=preset_data)
db.session.add(new_preset)
db.session.commit()
flash(f"Preset '{name}' created!")
return redirect(url_for('edit_preset', slug=safe_slug))
return render_template('presets/create.html')
@app.route('/get_missing_presets')
def get_missing_presets():
missing = Preset.query.filter((Preset.image_path == None) | (Preset.image_path == '')).order_by(Preset.filename).all()
return {'missing': [{'slug': p.slug, 'name': p.name} for p in missing]}
# ============ OUTFIT ROUTES ============ # ============ OUTFIT ROUTES ============
@app.route('/outfits') @app.route('/outfits')
@@ -6434,4 +6969,5 @@ if __name__ == '__main__':
sync_scenes() sync_scenes()
sync_looks() sync_looks()
sync_checkpoints() sync_checkpoints()
sync_presets()
app.run(debug=True, host='0.0.0.0', port=5000) app.run(debug=True, host='0.0.0.0', port=5000)

View File

@@ -0,0 +1,84 @@
{
"preset_id": "example_01",
"preset_name": "Example Preset",
"character": {
"character_id": "aerith_gainsborough",
"use_lora": true,
"fields": {
"identity": {
"base_specs": true,
"hair": true,
"eyes": true,
"hands": true,
"arms": false,
"torso": true,
"pelvis": false,
"legs": false,
"feet": false,
"extra": "random"
},
"defaults": {
"expression": "random",
"pose": false,
"scene": false
},
"wardrobe": {
"outfit": "default",
"fields": {
"full_body": true,
"headwear": "random",
"top": true,
"bottom": true,
"legwear": true,
"footwear": true,
"hands": false,
"gloves": false,
"accessories": "random"
}
}
}
},
"outfit": {
"outfit_id": null,
"use_lora": true
},
"action": {
"action_id": "random",
"use_lora": true,
"fields": {
"full_body": true,
"additional": true,
"head": true,
"eyes": false,
"arms": true,
"hands": true
}
},
"style": {
"style_id": null,
"use_lora": true
},
"scene": {
"scene_id": "random",
"use_lora": true,
"fields": {
"background": true,
"foreground": "random",
"furniture": "random",
"colors": false,
"lighting": true,
"theme": false
}
},
"detailer": {
"detailer_id": null,
"use_lora": true
},
"look": {
"look_id": null
},
"checkpoint": {
"checkpoint_path": null
},
"tags": []
}

29
data/presets/preset.json Normal file
View File

@@ -0,0 +1,29 @@
{
"preset_id": "example_01",
"preset_name": "Example Preset",
"prompt":{
"character": {
"character_id": "aerith_gainsborough",
"identity": {
"base_specs": true,
"hair": true,
"eyes": true
...
},
"defaults": {
"expression": false,
"pose": false,
"scene": false
},
"wardrobe": {
"outfit_id": "default",
"outfit": {
"headwear": true,
"accessories": true
...
}
},
"use_lora": true
}
}
}

View File

@@ -0,0 +1,58 @@
You are a JSON generator for generation preset profiles in GAZE, an AI image generation tool. Output ONLY valid JSON matching the exact structure below. Do not wrap in markdown blocks.
A preset is a complete generation recipe that specifies which resources to use and which prompt fields to include. Every entity can be set to a specific ID, "random" (pick randomly at generation time), or null (not used). Every field toggle can be true (always include), false (always exclude), or "random" (randomly decide each generation).
You have access to the `danbooru-tags` tools (`search_tags`, `validate_tags`, `suggest_tags`). Use them only if you are populating the `tags` array with explicit prompt tags. Do not use them for entity IDs or toggle values.
Structure:
{
"preset_id": "WILL_BE_REPLACED",
"preset_name": "WILL_BE_REPLACED",
"character": {
"character_id": "specific_id | random | null",
"use_lora": true,
"fields": {
"identity": {
"base_specs": true, "hair": true, "eyes": true, "hands": true,
"arms": false, "torso": true, "pelvis": false, "legs": false,
"feet": false, "extra": "random"
},
"defaults": {
"expression": "random",
"pose": false,
"scene": false
},
"wardrobe": {
"outfit": "default",
"fields": {
"full_body": true, "headwear": "random", "top": true,
"bottom": true, "legwear": true, "footwear": true,
"hands": false, "gloves": false, "accessories": "random"
}
}
}
},
"outfit": { "outfit_id": "specific_id | random | null", "use_lora": true },
"action": {
"action_id": "specific_id | random | null",
"use_lora": true,
"fields": { "full_body": true, "additional": true, "head": true, "eyes": false, "arms": true, "hands": true }
},
"style": { "style_id": "specific_id | random | null", "use_lora": true },
"scene": {
"scene_id": "specific_id | random | null",
"use_lora": true,
"fields": { "background": true, "foreground": "random", "furniture": "random", "colors": false, "lighting": true, "theme": false }
},
"detailer": { "detailer_id": "specific_id | random | null", "use_lora": true },
"look": { "look_id": "specific_id | random | null" },
"checkpoint": { "checkpoint_path": "specific_path | random | null" },
"tags": []
}
Guidelines:
- Set entity IDs to "random" when the user wants variety, null when they want to skip that resource, or a specific ID string when they reference something by name.
- Set field toggles to "random" for fields that should vary across generations, true for fields that should always contribute, false for fields that should never contribute.
- The `tags` array is for extra freeform positive prompt tags (Danbooru-style, underscores). Validate them with the tools.
- Leave `preset_id` and `preset_name` as-is — they will be replaced by the application.
- Output ONLY valid JSON. No explanations, no markdown fences.

View File

@@ -125,6 +125,19 @@ class Checkpoint(db.Model):
def __repr__(self): def __repr__(self):
return f'<Checkpoint {self.checkpoint_id}>' return f'<Checkpoint {self.checkpoint_id}>'
class Preset(db.Model):
id = db.Column(db.Integer, primary_key=True)
preset_id = db.Column(db.String(100), unique=True, nullable=False)
slug = db.Column(db.String(100), unique=True, nullable=False)
filename = db.Column(db.String(255), nullable=True)
name = db.Column(db.String(100), nullable=False)
data = db.Column(db.JSON, nullable=False)
image_path = db.Column(db.String(255), nullable=True)
def __repr__(self):
return f'<Preset {self.preset_id}>'
class Settings(db.Model): class Settings(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
llm_provider = db.Column(db.String(50), default='openrouter') # 'openrouter', 'ollama', 'lmstudio' llm_provider = db.Column(db.String(50), default='openrouter') # 'openrouter', 'ollama', 'lmstudio'

View File

@@ -23,6 +23,7 @@
<a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a> <a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a>
<a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a> <a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a>
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a> <a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a> <a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a> <a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>

View File

@@ -0,0 +1,80 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Create Preset</h1>
<a href="{{ url_for('presets_index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card">
<div class="card-header bg-dark text-white">New Preset</div>
<div class="card-body">
<form action="{{ url_for('create_preset') }}" method="post">
<div class="mb-3">
<label for="name" class="form-label">Preset Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
<label class="form-check-label" for="use_llm">Use AI to build preset from description</label>
</div>
</div>
<div class="mb-3" id="description-section">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="5"
placeholder="Describe the kind of generations you want this preset to produce. Include the type of character, setting, mood, outfit style, etc.
Example: A random character in a cheerful beach scene, wearing a swimsuit, with a random action pose. Always include identity and wardrobe fields. Randomise headwear and accessories. No style LoRA needed."></textarea>
<div class="form-text">The AI will set entity IDs (specific, random, or none) and field toggles (on/off/random) based on your description.</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success" id="create-btn">Create Preset</button>
</div>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">About Presets</div>
<div class="card-body text-muted small">
<p>A <strong>preset</strong> is a saved generation recipe. It stores:</p>
<ul>
<li><strong>Entity selections</strong> — which character, action, scene, etc. to use. Set to a specific ID, <span class="badge bg-warning text-dark">Random</span> (pick at generation time), or None.</li>
<li><strong>Field toggles</strong> — which prompt fields to include. Each can be <span class="badge bg-success">ON</span>, <span class="badge bg-secondary">OFF</span>, or <span class="badge bg-warning text-dark">RNG</span> (randomly decide each generation).</li>
</ul>
<p class="mb-0">After creation you'll be taken to the edit page to review and adjust the AI's choices before generating.</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('use_llm').addEventListener('change', function() {
document.getElementById('description-section').style.display = this.checked ? '' : 'none';
});
document.querySelector('form').addEventListener('submit', function() {
const btn = document.getElementById('create-btn');
if (document.getElementById('use_llm').checked) {
btn.disabled = true;
btn.textContent = 'Generating with AI...';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,352 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ preset.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="25" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ preset.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ url_for('presets_index') }}" class="btn btn-sm btn-outline-secondary me-2">Back to Library</a>
<h3 class="d-inline-block mb-0">{{ preset.name }}</h3>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('edit_preset', slug=preset.slug) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">JSON</button>
<form action="{{ url_for('clone_preset', slug=preset.slug) }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-secondary">Clone</button>
</form>
</div>
</div>
<div class="row">
<!-- Left: image + generate -->
<div class="col-md-4">
<div class="card mb-3">
<div class="img-container" style="height:auto;min-height:400px;cursor:pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
{% if preset.image_path %}
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}"
alt="{{ preset.name }}" class="img-fluid"
data-preview-path="{{ preset.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height:400px;">
<span class="text-muted">No Image</span>
</div>
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_preset_image', slug=preset.slug) }}" method="post" enctype="multipart/form-data" class="mb-3">
<label class="form-label text-muted small">Update Cover Image</label>
<div class="input-group input-group-sm">
<input class="form-control" type="file" name="image" required>
<button type="submit" class="btn btn-outline-primary">Upload</button>
</div>
</form>
<form id="generate-form" action="{{ url_for('generate_preset_image', slug=preset.slug) }}" method="post">
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success">Generate Preview</button>
<button type="submit" name="action" value="replace" class="btn btn-outline-warning btn-sm">Generate &amp; Set Cover</button>
</div>
</form>
</div>
</div>
<!-- Selected Preview -->
<div class="card mb-3" id="preview-pane" {% if not preview_path %}style="display:none"{% endif %}>
<div class="card-header d-flex justify-content-between align-items-center py-1">
<small class="fw-semibold">Selected Preview</small>
</div>
<div class="card-body p-1">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}"
class="img-fluid rounded" alt="Preview">
</div>
<div class="card-footer p-2">
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post">
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_path or '' }}">
<button type="submit" class="btn btn-sm btn-warning w-100">Set as Cover</button>
</form>
</div>
</div>
</div>
<!-- Right: preset summary -->
<div class="col-md-8">
{% macro toggle_badge(val) %}
{% if val == 'random' %}<span class="badge bg-warning text-dark">RNG</span>
{% elif val %}<span class="badge bg-success">ON</span>
{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
{% endmacro %}
{% macro entity_badge(val) %}
{% if val == 'random' %}<span class="badge bg-warning text-dark">Random</span>
{% elif val %}<span class="badge bg-info text-dark">{{ val | replace('_', ' ') | title }}</span>
{% else %}<span class="badge bg-secondary">None</span>{% endif %}
{% endmacro %}
<!-- Character -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<strong>Character</strong>
{{ entity_badge(preset.data.character.character_id) }}
</div>
<div class="card-body py-2">
{% set char_fields = preset.data.character.fields %}
<div class="mb-2">
<small class="text-muted fw-semibold d-block mb-1">Identity</small>
<div class="d-flex flex-wrap gap-1">
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(char_fields.identity.get(k, true)) }}
</span>
{% endfor %}
</div>
</div>
<div class="mb-2">
<small class="text-muted fw-semibold d-block mb-1">Defaults</small>
<div class="d-flex flex-wrap gap-1">
{% for k in ['expression','pose','scene'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k }}</span>
{{ toggle_badge(char_fields.defaults.get(k, false)) }}
</span>
{% endfor %}
</div>
</div>
{% set wd = char_fields.wardrobe %}
<div>
<small class="text-muted fw-semibold d-block mb-1">Wardrobe
<span class="badge bg-light text-dark border ms-1">outfit: {{ wd.get('outfit', 'default') }}</span>
</small>
<div class="d-flex flex-wrap gap-1">
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(wd.fields.get(k, true)) }}
</span>
{% endfor %}
</div>
</div>
<div class="mt-2">
<small class="text-muted">LoRA:</small>
{% if preset.data.character.use_lora %}<span class="badge bg-success">ON</span>{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
</div>
</div>
</div>
<!-- Secondary resources row -->
<div class="row g-2 mb-3">
{% for section, label, field_key, field_keys in [
('outfit', 'Outfit', 'outfit_id', []),
('action', 'Action', 'action_id', ['full_body','additional','head','eyes','arms','hands']),
('style', 'Style', 'style_id', []),
('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']),
('detailer', 'Detailer', 'detailer_id', []),
] %}
{% set sec = preset.data.get(section, {}) %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center py-1">
<small class="fw-semibold">{{ label }}</small>
{{ entity_badge(sec.get(field_key)) }}
</div>
{% if field_keys %}
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-1">
{% for k in field_keys %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-1 py-1" style="font-size:0.7rem">
<span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(sec.get('fields', {}).get(k, true)) }}
</span>
{% endfor %}
</div>
<div class="mt-1">
<small class="text-muted">LoRA:</small>
{% if sec.get('use_lora', true) %}<span class="badge bg-success">ON</span>{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
</div>
</div>
{% else %}
<div class="card-body py-2">
<small class="text-muted">LoRA:</small>
{% if sec.get('use_lora', true) %}<span class="badge bg-success">ON</span>{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Look & Checkpoint -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-1"><small class="fw-semibold">Look</small></div>
<div class="card-body py-2">{{ entity_badge(preset.data.get('look', {}).get('look_id')) }}</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-1"><small class="fw-semibold">Checkpoint</small></div>
<div class="card-body py-2">{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}</div>
</div>
</div>
</div>
<!-- Tags -->
{% if preset.data.tags %}
<div class="card mb-3">
<div class="card-header py-2"><strong>Extra Tags</strong></div>
<div class="card-body py-2">
{% for tag in preset.data.tags %}
<span class="badge bg-light text-dark border me-1">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Generated images -->
{% set upload_dir = 'static/uploads/presets/' + preset.slug %}
{% if preset.image_path or True %}
<div class="card">
<div class="card-header py-2"><strong>Generated Images</strong></div>
<div class="card-body py-2">
<div class="row g-2" id="generated-images">
{% if preset.image_path %}
<div class="col-4 col-md-3">
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}"
class="img-fluid rounded" style="cursor:pointer"
data-preview-path="{{ preset.image_path }}"
onclick="selectPreview('{{ preset.image_path }}', this.src)">
</div>
{% endif %}
</div>
<p id="no-images-msg" class="text-muted small mt-2 {% if preset.image_path %}d-none{% endif %}">No generated images yet.</p>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Job polling
let currentJobId = null;
document.getElementById('generate-form').addEventListener('submit', function(e) {
e.preventDefault();
const btn = e.submitter;
const actionVal = btn.value;
const formData = new FormData(this);
formData.set('action', actionVal);
btn.disabled = true;
btn.textContent = 'Generating...';
fetch(this.action, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData
})
.then(r => r.json())
.then(data => {
if (data.job_id) {
currentJobId = data.job_id;
pollJob(currentJobId, btn, actionVal);
} else {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Generate Preview';
alert('Error: ' + (data.error || 'Unknown error'));
}
})
.catch(() => { btn.disabled = false; });
});
function pollJob(jobId, btn, actionVal) {
fetch('/api/queue/' + jobId + '/status')
.then(r => r.json())
.then(data => {
if (data.status === 'done' && data.result) {
btn.disabled = false;
btn.textContent = btn.getAttribute('data-orig') || btn.textContent.replace('Generating...', 'Generate Preview');
// Add to gallery
const img = document.createElement('img');
img.src = data.result.image_url;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.dataset.previewPath = data.result.relative_path;
img.addEventListener('click', () => selectPreview(data.result.relative_path, img.src));
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
selectPreview(data.result.relative_path, data.result.image_url);
} else if (data.status === 'failed') {
btn.disabled = false;
btn.textContent = 'Generate Preview';
alert('Generation failed: ' + (data.error || 'Unknown error'));
} else {
setTimeout(() => pollJob(jobId, btn, actionVal), 1500);
}
})
.catch(() => setTimeout(() => pollJob(jobId, btn, actionVal), 3000));
}
function selectPreview(relativePath, imageUrl) {
document.getElementById('preview-path').value = relativePath;
document.getElementById('preview-img').src = imageUrl;
document.getElementById('preview-pane').style.display = '';
}
function showImage(src) {
if (src) document.getElementById('modalImage').src = src;
}
// Delegate click on generated images
document.addEventListener('click', function(e) {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
// JSON editor
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
</script>
{% endblock %}

276
templates/presets/edit.html Normal file
View File

@@ -0,0 +1,276 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Edit Preset: {{ preset.name }}</h1>
<a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{% macro toggle_group(name, val) %}
{# 3-way toggle: OFF / RNG / ON — renders as Bootstrap btn-group radio #}
{% set v = val | string | lower %}
<div class="btn-group btn-group-sm toggle-group" role="group" data-field="{{ name }}">
<input type="radio" class="btn-check" name="{{ name }}" id="{{ name }}_off" value="false" autocomplete="off" {% if v == 'false' %}checked{% endif %}>
<label class="btn btn-outline-secondary" for="{{ name }}_off">OFF</label>
<input type="radio" class="btn-check" name="{{ name }}" id="{{ name }}_rng" value="random" autocomplete="off" {% if v == 'random' %}checked{% endif %}>
<label class="btn btn-outline-warning" for="{{ name }}_rng">RNG</label>
<input type="radio" class="btn-check" name="{{ name }}" id="{{ name }}_on" value="true" autocomplete="off" {% if v not in ['false', 'random'] %}checked{% endif %}>
<label class="btn btn-outline-success" for="{{ name }}_on">ON</label>
</div>
{% endmacro %}
{% macro entity_select(name, items, id_attr, current_val, include_random=true) %}
<select class="form-select form-select-sm" name="{{ name }}">
<option value="">— None —</option>
{% if include_random %}<option value="random" {% if current_val == 'random' %}selected{% endif %}>🎲 Random</option>{% endif %}
{% for item in items %}
{% set item_id = item | attr(id_attr) %}
<option value="{{ item_id }}" {% if current_val == item_id %}selected{% endif %}>{{ item.name }}</option>
{% endfor %}
</select>
{% endmacro %}
<form action="{{ url_for('edit_preset', slug=preset.slug) }}" method="post">
{% set d = preset.data %}
{% set char_cfg = d.get('character', {}) %}
{% set char_fields = char_cfg.get('fields', {}) %}
{% set id_fields = char_fields.get('identity', {}) %}
{% set def_fields = char_fields.get('defaults', {}) %}
{% set wd_cfg = char_fields.get('wardrobe', {}) %}
{% set wd_fields = wd_cfg.get('fields', {}) %}
<div class="row">
<div class="col-md-8">
<!-- Basic Info -->
<div class="card mb-4">
<div class="card-header bg-dark text-white">Basic Information</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Preset Name</label>
<input type="text" class="form-control" name="preset_name" value="{{ preset.name }}" required>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Preset ID</label>
<input type="text" class="form-control form-control-sm" value="{{ preset.preset_id }}" disabled>
</div>
<div class="mb-3">
<label class="form-label">Extra Tags <span class="text-muted small">(comma-separated)</span></label>
<input type="text" class="form-control" name="tags" value="{{ d.get('tags', []) | join(', ') }}">
</div>
</div>
</div>
<!-- Character -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<strong>Character</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="char_use_lora" id="char_use_lora" {% if char_cfg.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white small" for="char_use_lora">Use LoRA</label>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Character</label>
{{ entity_select('char_character_id', characters, 'character_id', char_cfg.get('character_id')) }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Identity Fields</label>
<div class="row g-2">
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %}
<div class="col-6 col-sm-4 col-md-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
{{ toggle_group('id_' + k, id_fields.get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Default Fields</label>
<div class="row g-2">
{% for k in ['expression','pose','scene'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k }}</small>
{{ toggle_group('def_' + k, def_fields.get(k, false)) }}
</div>
</div>
{% endfor %}
</div>
</div>
<div>
<label class="form-label fw-semibold">Wardrobe Fields</label>
<div class="mb-2">
<label class="form-label small text-muted">Active outfit name</label>
<input type="text" class="form-control form-control-sm" name="wardrobe_outfit"
value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default">
</div>
<div class="row g-2">
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
{{ toggle_group('wd_' + k, wd_fields.get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Action -->
{% set act = d.get('action', {}) %}
<div class="card mb-4">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<strong>Action</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="action_use_lora" id="action_use_lora" {% if act.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white small" for="action_use_lora">Use LoRA</label>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Action</label>
{{ entity_select('action_id', actions, 'action_id', act.get('action_id')) }}
</div>
<label class="form-label fw-semibold">Fields</label>
<div class="row g-2">
{% for k in ['full_body','additional','head','eyes','arms','hands'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
{{ toggle_group('act_' + k, act.get('fields', {}).get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Style / Scene / Detailer -->
<div class="row g-3 mb-4">
{% set sty = d.get('style', {}) %}
<div class="col-md-4">
<div class="card h-100">
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center py-2">
<strong>Style</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="style_use_lora" id="style_use_lora" {% if sty.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white" for="style_use_lora" style="font-size:0.75rem">LoRA</label>
</div>
</div>
<div class="card-body">
{{ entity_select('style_id', styles, 'style_id', sty.get('style_id')) }}
</div>
</div>
</div>
{% set det = d.get('detailer', {}) %}
<div class="col-md-4">
<div class="card h-100">
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center py-2">
<strong>Detailer</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="detailer_use_lora" id="detailer_use_lora" {% if det.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white" for="detailer_use_lora" style="font-size:0.75rem">LoRA</label>
</div>
</div>
<div class="card-body">
{{ entity_select('detailer_id', detailers, 'detailer_id', det.get('detailer_id')) }}
</div>
</div>
</div>
{% set lk = d.get('look', {}) %}
<div class="col-md-4">
<div class="card h-100">
<div class="card-header bg-warning text-dark py-2"><strong>Look</strong> <small class="text-muted">(overrides char LoRA)</small></div>
<div class="card-body">
{{ entity_select('look_id', looks, 'look_id', lk.get('look_id')) }}
</div>
</div>
</div>
</div>
<!-- Scene -->
{% set scn = d.get('scene', {}) %}
<div class="card mb-4">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<strong>Scene</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="scene_use_lora" id="scene_use_lora" {% if scn.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white small" for="scene_use_lora">Use LoRA</label>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Scene</label>
{{ entity_select('scene_id', scenes, 'scene_id', scn.get('scene_id')) }}
</div>
<label class="form-label fw-semibold">Fields</label>
<div class="row g-2">
{% for k in ['background','foreground','furniture','colors','lighting','theme'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k }}</small>
{{ toggle_group('scn_' + k, scn.get('fields', {}).get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Outfit + Checkpoint -->
<div class="row g-3 mb-4">
{% set out = d.get('outfit', {}) %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center py-2">
<strong>Outfit</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="outfit_use_lora" id="outfit_use_lora" {% if out.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white" for="outfit_use_lora" style="font-size:0.75rem">LoRA</label>
</div>
</div>
<div class="card-body">
{{ entity_select('outfit_id', outfits, 'outfit_id', out.get('outfit_id')) }}
<small class="text-muted">Selecting an outfit overrides the character's wardrobe.</small>
</div>
</div>
</div>
{% set ckpt = d.get('checkpoint', {}) %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2"><strong>Checkpoint</strong></div>
<div class="card-body">
<select class="form-select form-select-sm" name="checkpoint_path">
<option value="">— Use session default —</option>
<option value="random" {% if ckpt.get('checkpoint_path') == 'random' %}selected{% endif %}>🎲 Random</option>
{% for ck in checkpoints %}
<option value="{{ ck.checkpoint_path }}" {% if ckpt.get('checkpoint_path') == ck.checkpoint_path %}selected{% endif %}>{{ ck.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 pb-4">
<button type="submit" class="btn btn-primary">Save Preset</button>
<a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Preset Library</h2>
<div class="d-flex gap-1 align-items-center">
<a href="{{ url_for('create_preset') }}" class="btn btn-sm btn-success">Create New Preset</a>
<form action="{{ url_for('rescan_presets') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan preset files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for preset in presets %}
<div class="col">
<div class="card h-100 character-card" onclick="window.location.href='{{ url_for('preset_detail', slug=preset.slug) }}'">
<div class="img-container">
{% if preset.image_path %}
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}" alt="{{ preset.name }}">
{% else %}
<span class="text-muted">No Image</span>
{% endif %}
</div>
<div class="card-body p-2">
<h6 class="card-title text-center mb-1">{{ preset.name }}</h6>
<p class="card-text small text-center text-muted mb-0">
{% set parts = [] %}
{% if preset.data.character and preset.data.character.character_id %}
{% set _ = parts.append(preset.data.character.character_id | replace('_', ' ') | title) %}
{% endif %}
{% if preset.data.action and preset.data.action.action_id %}
{% set _ = parts.append('+ action') %}
{% endif %}
{% if preset.data.scene and preset.data.scene.scene_id %}
{% set _ = parts.append('+ scene') %}
{% endif %}
{{ parts | join(' · ') }}
</p>
</div>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
<small class="text-muted">preset</small>
<div class="d-flex gap-1">
<a href="{{ url_for('edit_preset', slug=preset.slug) }}" class="btn btn-xs btn-outline-secondary" onclick="event.stopPropagation()">Edit</a>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<p class="text-muted">No presets found. <a href="{{ url_for('create_preset') }}">Create your first preset</a> or add JSON files to <code>data/presets/</code> and rescan.</p>
</div>
{% endfor %}
</div>
{% endblock %}