From ec08eb5d31ad9d0a30bc7a83aece902b36ce3c70 Mon Sep 17 00:00:00 2001 From: Aodhan Collins Date: Thu, 5 Mar 2026 23:49:24 +0000 Subject: [PATCH] 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 --- app.py | 538 ++++++++++++++++++++++++++++++++- data/presets/example_01.json | 84 +++++ data/presets/preset.json | 29 ++ data/prompts/preset_system.txt | 58 ++++ models.py | 13 + templates/layout.html | 1 + templates/presets/create.html | 80 +++++ templates/presets/detail.html | 352 +++++++++++++++++++++ templates/presets/edit.html | 276 +++++++++++++++++ templates/presets/index.html | 63 ++++ 10 files changed, 1493 insertions(+), 1 deletion(-) create mode 100644 data/presets/example_01.json create mode 100644 data/presets/preset.json create mode 100644 data/prompts/preset_system.txt create mode 100644 templates/presets/create.html create mode 100644 templates/presets/detail.html create mode 100644 templates/presets/edit.html create mode 100644 templates/presets/index.html diff --git a/app.py b/app.py index f860573..b7d4dac 100644 --- a/app.py +++ b/app.py @@ -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_session import Session 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.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['CHECKPOINTS_DIR'] = 'data/checkpoints' 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['ILLUSTRIOUS_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Illustrious/' app.config['NOOB_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Noob/' @@ -936,6 +937,125 @@ def sync_looks(): 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(): if not os.path.exists(app.config['ACTIONS_DIR']): return @@ -2407,6 +2527,421 @@ def clear_all_scene_covers(): db.session.commit() 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/') +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//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//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//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//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//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//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 ============ @app.route('/outfits') @@ -6434,4 +6969,5 @@ if __name__ == '__main__': sync_scenes() sync_looks() sync_checkpoints() + sync_presets() app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/data/presets/example_01.json b/data/presets/example_01.json new file mode 100644 index 0000000..24d80a6 --- /dev/null +++ b/data/presets/example_01.json @@ -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": [] +} diff --git a/data/presets/preset.json b/data/presets/preset.json new file mode 100644 index 0000000..e0b192e --- /dev/null +++ b/data/presets/preset.json @@ -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 + } + } +} \ No newline at end of file diff --git a/data/prompts/preset_system.txt b/data/prompts/preset_system.txt new file mode 100644 index 0000000..8ebf091 --- /dev/null +++ b/data/prompts/preset_system.txt @@ -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. diff --git a/models.py b/models.py index 0eed0eb..67e8309 100644 --- a/models.py +++ b/models.py @@ -125,6 +125,19 @@ class Checkpoint(db.Model): def __repr__(self): return f'' +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'' + + class Settings(db.Model): id = db.Column(db.Integer, primary_key=True) llm_provider = db.Column(db.String(50), default='openrouter') # 'openrouter', 'ollama', 'lmstudio' diff --git a/templates/layout.html b/templates/layout.html index c092edd..dfcb546 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -23,6 +23,7 @@ Scenes Detailers Checkpoints + Presets
+ Character Generator diff --git a/templates/presets/create.html b/templates/presets/create.html new file mode 100644 index 0000000..b853d55 --- /dev/null +++ b/templates/presets/create.html @@ -0,0 +1,80 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Create Preset

+ Cancel +
+ +{% with messages = get_flashed_messages(with_categories=true) %} +{% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +{% endif %} +{% endwith %} + +
+
+
+
New Preset
+
+
+
+ + +
+ +
+
+ + +
+
+ +
+ + +
The AI will set entity IDs (specific, random, or none) and field toggles (on/off/random) based on your description.
+
+ +
+ +
+
+
+
+ +
+
About Presets
+
+

A preset is a saved generation recipe. It stores:

+
    +
  • Entity selections — which character, action, scene, etc. to use. Set to a specific ID, Random (pick at generation time), or None.
  • +
  • Field toggles — which prompt fields to include. Each can be ON, OFF, or RNG (randomly decide each generation).
  • +
+

After creation you'll be taken to the edit page to review and adjust the AI's choices before generating.

+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/presets/detail.html b/templates/presets/detail.html new file mode 100644 index 0000000..bec430b --- /dev/null +++ b/templates/presets/detail.html @@ -0,0 +1,352 @@ +{% extends "layout.html" %} + +{% block content %} + + + + + + +
+
+ Back to Library +

{{ preset.name }}

+
+
+ Edit + +
+ +
+
+
+ +
+ +
+
+
+ {% if preset.image_path %} + {{ preset.name }} + {% else %} +
+ No Image +
+ {% endif %} +
+
+
+ +
+ + +
+
+ +
+
+ + +
+
+
+
+ + +
+
+ Selected Preview +
+
+ Preview +
+ +
+
+ + +
+ + {% macro toggle_badge(val) %} + {% if val == 'random' %}RNG + {% elif val %}ON + {% else %}OFF{% endif %} + {% endmacro %} + + {% macro entity_badge(val) %} + {% if val == 'random' %}Random + {% elif val %}{{ val | replace('_', ' ') | title }} + {% else %}None{% endif %} + {% endmacro %} + + +
+
+ Character + {{ entity_badge(preset.data.character.character_id) }} +
+
+ {% set char_fields = preset.data.character.fields %} +
+ Identity +
+ {% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %} + + {{ k | replace('_', ' ') }} + {{ toggle_badge(char_fields.identity.get(k, true)) }} + + {% endfor %} +
+
+
+ Defaults +
+ {% for k in ['expression','pose','scene'] %} + + {{ k }} + {{ toggle_badge(char_fields.defaults.get(k, false)) }} + + {% endfor %} +
+
+ {% set wd = char_fields.wardrobe %} +
+ Wardrobe + outfit: {{ wd.get('outfit', 'default') }} + +
+ {% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %} + + {{ k | replace('_', ' ') }} + {{ toggle_badge(wd.fields.get(k, true)) }} + + {% endfor %} +
+
+
+ LoRA: + {% if preset.data.character.use_lora %}ON{% else %}OFF{% endif %} +
+
+
+ + +
+ {% 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, {}) %} +
+
+
+ {{ label }} + {{ entity_badge(sec.get(field_key)) }} +
+ {% if field_keys %} +
+
+ {% for k in field_keys %} + + {{ k | replace('_', ' ') }} + {{ toggle_badge(sec.get('fields', {}).get(k, true)) }} + + {% endfor %} +
+
+ LoRA: + {% if sec.get('use_lora', true) %}ON{% else %}OFF{% endif %} +
+
+ {% else %} +
+ LoRA: + {% if sec.get('use_lora', true) %}ON{% else %}OFF{% endif %} +
+ {% endif %} +
+
+ {% endfor %} + + +
+
+
Look
+
{{ entity_badge(preset.data.get('look', {}).get('look_id')) }}
+
+
+
+
+
Checkpoint
+
{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}
+
+
+
+ + + {% if preset.data.tags %} +
+
Extra Tags
+
+ {% for tag in preset.data.tags %} + {{ tag }} + {% endfor %} +
+
+ {% endif %} + + + {% set upload_dir = 'static/uploads/presets/' + preset.slug %} + {% if preset.image_path or True %} +
+
Generated Images
+
+
+ {% if preset.image_path %} +
+ +
+ {% endif %} +
+

No generated images yet.

+
+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/presets/edit.html b/templates/presets/edit.html new file mode 100644 index 0000000..4257013 --- /dev/null +++ b/templates/presets/edit.html @@ -0,0 +1,276 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Edit Preset: {{ preset.name }}

+ Cancel +
+ +{% macro toggle_group(name, val) %} +{# 3-way toggle: OFF / RNG / ON — renders as Bootstrap btn-group radio #} +{% set v = val | string | lower %} +
+ + + + + + + + +
+{% endmacro %} + +{% macro entity_select(name, items, id_attr, current_val, include_random=true) %} + +{% endmacro %} + +
+{% 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', {}) %} + +
+
+ + +
+
Basic Information
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ Character +
+ + +
+
+
+
+ + {{ entity_select('char_character_id', characters, 'character_id', char_cfg.get('character_id')) }} +
+ +
+ +
+ {% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %} +
+
+ {{ k | replace('_', ' ') }} + {{ toggle_group('id_' + k, id_fields.get(k, true)) }} +
+
+ {% endfor %} +
+
+ +
+ +
+ {% for k in ['expression','pose','scene'] %} +
+
+ {{ k }} + {{ toggle_group('def_' + k, def_fields.get(k, false)) }} +
+
+ {% endfor %} +
+
+ +
+ +
+ + +
+
+ {% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %} +
+
+ {{ k | replace('_', ' ') }} + {{ toggle_group('wd_' + k, wd_fields.get(k, true)) }} +
+
+ {% endfor %} +
+
+
+
+ + + {% set act = d.get('action', {}) %} +
+
+ Action +
+ + +
+
+
+
+ + {{ entity_select('action_id', actions, 'action_id', act.get('action_id')) }} +
+ +
+ {% for k in ['full_body','additional','head','eyes','arms','hands'] %} +
+
+ {{ k | replace('_', ' ') }} + {{ toggle_group('act_' + k, act.get('fields', {}).get(k, true)) }} +
+
+ {% endfor %} +
+
+
+ + +
+ {% set sty = d.get('style', {}) %} +
+
+
+ Style +
+ + +
+
+
+ {{ entity_select('style_id', styles, 'style_id', sty.get('style_id')) }} +
+
+
+ + {% set det = d.get('detailer', {}) %} +
+
+
+ Detailer +
+ + +
+
+
+ {{ entity_select('detailer_id', detailers, 'detailer_id', det.get('detailer_id')) }} +
+
+
+ + {% set lk = d.get('look', {}) %} +
+
+
Look (overrides char LoRA)
+
+ {{ entity_select('look_id', looks, 'look_id', lk.get('look_id')) }} +
+
+
+
+ + + {% set scn = d.get('scene', {}) %} +
+
+ Scene +
+ + +
+
+
+
+ + {{ entity_select('scene_id', scenes, 'scene_id', scn.get('scene_id')) }} +
+ +
+ {% for k in ['background','foreground','furniture','colors','lighting','theme'] %} +
+
+ {{ k }} + {{ toggle_group('scn_' + k, scn.get('fields', {}).get(k, true)) }} +
+
+ {% endfor %} +
+
+
+ + +
+ {% set out = d.get('outfit', {}) %} +
+
+
+ Outfit +
+ + +
+
+
+ {{ entity_select('outfit_id', outfits, 'outfit_id', out.get('outfit_id')) }} + Selecting an outfit overrides the character's wardrobe. +
+
+
+ + {% set ckpt = d.get('checkpoint', {}) %} +
+
+
Checkpoint
+
+ +
+
+
+
+ +
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/templates/presets/index.html b/templates/presets/index.html new file mode 100644 index 0000000..c251e61 --- /dev/null +++ b/templates/presets/index.html @@ -0,0 +1,63 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Preset Library

+
+ Create New Preset +
+ +
+
+
+ +{% with messages = get_flashed_messages(with_categories=true) %} +{% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +{% endif %} +{% endwith %} + +
+ {% for preset in presets %} +
+
+
+ {% if preset.image_path %} + {{ preset.name }} + {% else %} + No Image + {% endif %} +
+
+
{{ preset.name }}
+

+ {% 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(' · ') }} +

+
+ +
+
+ {% else %} +
+

No presets found. Create your first preset or add JSON files to data/presets/ and rescan.

+
+ {% endfor %} +
+{% endblock %}