import json import os import re import time import logging from flask import render_template, request, redirect, url_for, flash, session, current_app from werkzeug.utils import secure_filename from sqlalchemy.orm.attributes import flag_modified from models import db, Character, Look, Action, Checkpoint, Settings, Outfit from services.workflow import _prepare_workflow, _get_default_checkpoint from services.job_queue import _enqueue_job, _make_finalize from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags from services.sync import sync_looks from services.file_io import get_available_loras, _count_look_assignments from services.llm import load_prompt, call_llm from utils import allowed_file logger = logging.getLogger('gaze') def _ensure_look_lora_prefix(lora_name): """Ensure look LoRA paths have the correct 'Illustrious/Looks/' prefix.""" if not lora_name: return lora_name if not lora_name.startswith('Illustrious/Looks/'): # Add the prefix if missing if lora_name.startswith('Illustrious/'): # Has Illustrious but wrong subfolder - replace parts = lora_name.split('/', 1) if len(parts) > 1: lora_name = 'Illustrious/Looks/' + parts[1] else: lora_name = 'Illustrious/Looks/' + lora_name else: # No prefix at all - add it lora_name = 'Illustrious/Looks/' + lora_name return lora_name def _fix_look_lora_data(lora_data): """Fix look LoRA data to ensure correct prefix.""" if not lora_data: return lora_data lora_name = lora_data.get('lora_name', '') if lora_name: lora_data = lora_data.copy() # Avoid mutating original lora_data['lora_name'] = _ensure_look_lora_prefix(lora_name) return lora_data def register_routes(app): @app.route('/looks') def looks_index(): looks = Look.query.order_by(Look.name).all() look_assignments = _count_look_assignments() return render_template('looks/index.html', looks=looks, look_assignments=look_assignments) @app.route('/looks/rescan', methods=['POST']) def rescan_looks(): sync_looks() flash('Database synced with look files.') return redirect(url_for('looks_index')) @app.route('/look/') def look_detail(slug): look = Look.query.filter_by(slug=slug).first_or_404() characters = Character.query.order_by(Character.name).all() # Pre-select the linked characters if set (supports multi-character assignment) preferences = session.get(f'prefs_look_{slug}') preview_image = session.get(f'preview_look_{slug}') # Get linked character IDs (new character_ids JSON field) linked_character_ids = look.character_ids or [] # Fallback to legacy character_id if character_ids is empty if not linked_character_ids and look.character_id: linked_character_ids = [look.character_id] # Session-selected character for preview (single selection for generation) selected_character = session.get(f'char_look_{slug}', linked_character_ids[0] if linked_character_ids else '') # FIX: Add existing_previews scanning (matching other resource routes) upload_folder = app.config['UPLOAD_FOLDER'] preview_dir = os.path.join(upload_folder, 'looks', slug) existing_previews = [] if os.path.isdir(preview_dir): for f in os.listdir(preview_dir): if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): existing_previews.append(f'looks/{slug}/{f}') existing_previews.sort() extra_positive = session.get(f'extra_pos_look_{slug}', '') extra_negative = session.get(f'extra_neg_look_{slug}', '') return render_template('looks/detail.html', look=look, characters=characters, preferences=preferences, preview_image=preview_image, selected_character=selected_character, linked_character_ids=linked_character_ids, existing_previews=existing_previews, extra_positive=extra_positive, extra_negative=extra_negative) @app.route('/look//edit', methods=['GET', 'POST']) def edit_look(slug): look = Look.query.filter_by(slug=slug).first_or_404() characters = Character.query.order_by(Character.name).all() loras = get_available_loras('characters') if request.method == 'POST': look.name = request.form.get('look_name', look.name) # Handle multiple character IDs from checkboxes character_ids = request.form.getlist('character_ids') look.character_ids = character_ids if character_ids else [] # Also update legacy character_id field for backward compatibility if character_ids: look.character_id = character_ids[0] else: look.character_id = None new_data = look.data.copy() new_data['look_name'] = look.name new_data['character_id'] = look.character_id new_data['positive'] = request.form.get('positive', '') new_data['negative'] = request.form.get('negative', '') lora_name = request.form.get('lora_lora_name', '') lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0) lora_triggers = request.form.get('lora_lora_triggers', '') new_data['lora'] = {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers} for bound in ['lora_weight_min', 'lora_weight_max']: val_str = request.form.get(f'lora_{bound}', '').strip() if val_str: try: new_data['lora'][bound] = float(val_str) except ValueError: pass tags_raw = request.form.get('tags', '') new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()] look.data = new_data flag_modified(look, 'data') db.session.commit() if look.filename: file_path = os.path.join(app.config['LOOKS_DIR'], look.filename) with open(file_path, 'w') as f: json.dump(new_data, f, indent=2) flash(f'Look "{look.name}" updated!') return redirect(url_for('look_detail', slug=look.slug)) return render_template('looks/edit.html', look=look, characters=characters, loras=loras) @app.route('/look//upload', methods=['POST']) def upload_look_image(slug): look = Look.query.filter_by(slug=slug).first_or_404() if 'image' not in request.files: flash('No file selected') return redirect(url_for('look_detail', slug=slug)) file = request.files['image'] if file and allowed_file(file.filename): filename = secure_filename(file.filename) look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}') os.makedirs(look_folder, exist_ok=True) file_path = os.path.join(look_folder, filename) file.save(file_path) look.image_path = f'looks/{slug}/{filename}' db.session.commit() return redirect(url_for('look_detail', slug=slug)) @app.route('/look//generate', methods=['POST']) def generate_look_image(slug): look = Look.query.filter_by(slug=slug).first_or_404() try: action = request.form.get('action', 'preview') selected_fields = request.form.getlist('include_field') character_slug = request.form.get('character_slug', '') character = None # Only load a character when the user explicitly selects one character = _resolve_character(character_slug) if character_slug == '__random__' and character: character_slug = character.slug elif character_slug and not character: # fallback: try matching by character_id character = Character.query.filter_by(character_id=character_slug).first() # No fallback to look.character_id — looks are self-contained # Get additional prompts extra_positive = request.form.get('extra_positive', '').strip() extra_negative = request.form.get('extra_negative', '').strip() session[f'prefs_look_{slug}'] = selected_fields session[f'char_look_{slug}'] = character_slug session[f'extra_pos_look_{slug}'] = extra_positive session[f'extra_neg_look_{slug}'] = extra_negative session.modified = True lora_triggers = look.data.get('lora', {}).get('lora_triggers', '') look_positive = look.data.get('positive', '') with open('comfy_workflow.json', 'r') as f: workflow = json.load(f) if character: # Merge character identity with look LoRA and positive prompt combined_data = { 'character_id': character.character_id, 'identity': character.data.get('identity', {}), 'defaults': character.data.get('defaults', {}), 'wardrobe': character.data.get('wardrobe', {}).get(character.active_outfit or 'default', character.data.get('wardrobe', {}).get('default', {})), 'styles': character.data.get('styles', {}), 'lora': _fix_look_lora_data(look.data.get('lora', {})), 'tags': look.data.get('tags', []) } _ensure_character_fields(character, selected_fields, include_wardrobe=False, include_defaults=True) prompts = build_prompt(combined_data, selected_fields, character.default_fields) # Append look-specific triggers and positive extra = ', '.join(filter(None, [lora_triggers, look_positive])) if extra: prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra}" if prompts['main'] else extra) primary_color = character.data.get('styles', {}).get('primary_color', '') bg = f"{primary_color} simple background" if primary_color else "simple background" else: # Look is self-contained: build prompt from its own positive and triggers only main = _dedup_tags(', '.join(filter(None, ['(solo:1.2)', lora_triggers, look_positive]))) prompts = {'main': main, 'face': '', 'hand': ''} bg = "simple background" prompts['main'] = _dedup_tags(f"{prompts['main']}, {bg}" if prompts['main'] else bg) if extra_positive: prompts["main"] = f"{prompts['main']}, {extra_positive}" # Parse optional seed seed_val = request.form.get('seed', '').strip() fixed_seed = int(seed_val) if seed_val else None ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, look=look, fixed_seed=fixed_seed) char_label = character.name if character else 'no character' label = f"Look: {look.name} ({char_label}) – {action}" job = _enqueue_job(label, workflow, _make_finalize('looks', slug, Look, action)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('look_detail', slug=slug)) except Exception as e: print(f"Generation error: {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('look_detail', slug=slug)) @app.route('/look//replace_cover_from_preview', methods=['POST']) def replace_look_cover_from_preview(slug): look = Look.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)): look.image_path = preview_path db.session.commit() flash('Cover image updated!') else: flash('No valid preview image selected.', 'error') return redirect(url_for('look_detail', slug=slug)) @app.route('/look//save_defaults', methods=['POST']) def save_look_defaults(slug): look = Look.query.filter_by(slug=slug).first_or_404() look.default_fields = request.form.getlist('include_field') db.session.commit() flash('Default prompt selection saved!') return redirect(url_for('look_detail', slug=slug)) @app.route('/look//generate_character', methods=['POST']) def generate_character_from_look(slug): """Generate a character JSON using a look as the base.""" look = Look.query.filter_by(slug=slug).first_or_404() # Get or validate inputs character_name = request.form.get('character_name', look.name) use_llm = request.form.get('use_llm') == 'on' # Auto-generate slug character_slug = re.sub(r'[^a-zA-Z0-9]+', '_', character_name.lower()).strip('_') character_slug = re.sub(r'[^a-zA-Z0-9_]', '', character_slug) # Find available filename base_slug = character_slug counter = 1 while os.path.exists(os.path.join(app.config['CHARACTERS_DIR'], f"{character_slug}.json")): character_slug = f"{base_slug}_{counter}" counter += 1 if use_llm: # Use LLM to generate character from look context system_prompt = load_prompt('character_system.txt') if not system_prompt: flash('Character system prompt file not found.', 'error') return redirect(url_for('look_detail', slug=slug)) prompt = f"""Generate a character based on this look description: Look Name: {look.name} Positive Prompt: {look.data.get('positive', '')} Negative Prompt: {look.data.get('negative', '')} Tags: {', '.join(look.data.get('tags', []))} LoRA Triggers: {look.data.get('lora', {}).get('lora_triggers', '')} Create a complete character JSON with identity, styles, and appropriate wardrobe fields. The character should match the visual style described in the look. Character Name: {character_name} Character ID: {character_slug}""" try: llm_response = call_llm(prompt, system_prompt) # Clean response (remove markdown if present) clean_json = llm_response.replace('```json', '').replace('```', '').strip() character_data = json.loads(clean_json) # Enforce IDs character_data['character_id'] = character_slug character_data['character_name'] = character_name # Ensure the character inherits the look's LoRA with correct path lora_data = _fix_look_lora_data(look.data.get('lora', {}).copy()) character_data['lora'] = lora_data except Exception as e: logger.exception(f"LLM character generation error: {e}") flash(f'Failed to generate character with AI: {e}', 'error') return redirect(url_for('look_detail', slug=slug)) else: # Create minimal character template lora_data = _fix_look_lora_data(look.data.get('lora', {}).copy()) character_data = { "character_id": character_slug, "character_name": character_name, "identity": { "base": lora_data.get('lora_triggers', ''), "head": "", "upper_body": "", "lower_body": "", "hands": "", "feet": "", "additional": "" }, "defaults": { "expression": "", "pose": "", "scene": "" }, "wardrobe": { "base": "", "head": "", "upper_body": "", "lower_body": "", "hands": "", "feet": "", "additional": "" }, "styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" }, "lora": lora_data, "tags": look.data.get('tags', []) } # Save character JSON char_path = os.path.join(app.config['CHARACTERS_DIR'], f"{character_slug}.json") try: with open(char_path, 'w') as f: json.dump(character_data, f, indent=2) except Exception as e: flash(f'Failed to save character file: {e}', 'error') return redirect(url_for('look_detail', slug=slug)) # Create DB entry character = Character( character_id=character_slug, slug=character_slug, name=character_name, data=character_data ) db.session.add(character) db.session.commit() # Link the look to this character look.character_id = character_slug db.session.commit() flash(f'Character "{character_name}" created from look!', 'success') return redirect(url_for('detail', slug=character_slug)) @app.route('/look//save_json', methods=['POST']) def save_look_json(slug): look = Look.query.filter_by(slug=slug).first_or_404() try: new_data = json.loads(request.form.get('json_data', '')) except (ValueError, TypeError) as e: return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 look.data = new_data look.character_id = new_data.get('character_id', look.character_id) flag_modified(look, 'data') db.session.commit() if look.filename: file_path = os.path.join(app.config['LOOKS_DIR'], look.filename) with open(file_path, 'w') as f: json.dump(new_data, f, indent=2) return {'success': True} @app.route('/look/create', methods=['GET', 'POST']) def create_look(): characters = Character.query.order_by(Character.name).all() loras = get_available_loras('characters') if request.method == 'POST': name = request.form.get('name', '').strip() look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_')) filename = f'{look_id}.json' file_path = os.path.join(app.config['LOOKS_DIR'], filename) character_id = request.form.get('character_id', '') or None lora_name = request.form.get('lora_lora_name', '') lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0) lora_triggers = request.form.get('lora_lora_triggers', '') positive = request.form.get('positive', '') negative = request.form.get('negative', '') tags = [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()] data = { 'look_id': look_id, 'look_name': name, 'character_id': character_id, 'positive': positive, 'negative': negative, 'lora': {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers}, 'tags': tags } os.makedirs(app.config['LOOKS_DIR'], exist_ok=True) with open(file_path, 'w') as f: json.dump(data, f, indent=2) slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id) new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name, character_id=character_id, data=data) db.session.add(new_look) db.session.commit() flash(f'Look "{name}" created!') return redirect(url_for('look_detail', slug=slug)) return render_template('looks/create.html', characters=characters, loras=loras) @app.route('/get_missing_looks') def get_missing_looks(): missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all() return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]} @app.route('/clear_all_look_covers', methods=['POST']) def clear_all_look_covers(): looks = Look.query.all() for look in looks: look.image_path = None db.session.commit() return {'success': True} @app.route('/looks/bulk_create', methods=['POST']) def bulk_create_looks_from_loras(): _s = Settings.query.first() lora_dir = ((_s.lora_dir_characters if _s else None) or app.config['LORA_DIR']).rstrip('/') _lora_subfolder = os.path.basename(lora_dir) if not os.path.exists(lora_dir): flash('Looks LoRA directory not found.', 'error') return redirect(url_for('looks_index')) overwrite = request.form.get('overwrite') == 'true' created_count = 0 skipped_count = 0 overwritten_count = 0 system_prompt = load_prompt('look_system.txt') if not system_prompt: flash('Look system prompt file not found.', 'error') return redirect(url_for('looks_index')) for filename in os.listdir(lora_dir): if not filename.endswith('.safetensors'): continue name_base = filename.rsplit('.', 1)[0] look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() json_filename = f"{look_id}.json" json_path = os.path.join(app.config['LOOKS_DIR'], json_filename) is_existing = os.path.exists(json_path) if is_existing and not overwrite: skipped_count += 1 continue html_filename = f"{name_base}.html" html_path = os.path.join(lora_dir, html_filename) html_content = "" if os.path.exists(html_path): try: with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: html_raw = hf.read() clean_html = re.sub(r']*>.*?', '', html_raw, flags=re.DOTALL) clean_html = re.sub(r']*>.*?', '', clean_html, flags=re.DOTALL) clean_html = re.sub(r']*>', '', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html) html_content = ' '.join(clean_html.split()) except Exception as e: print(f"Error reading HTML {html_filename}: {e}") try: print(f"Asking LLM to describe look: {look_name}") prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'" if html_content: prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###" llm_response = call_llm(prompt, system_prompt) clean_json = llm_response.replace('```json', '').replace('```', '').strip() look_data = json.loads(clean_json) look_data['look_id'] = look_id look_data['look_name'] = look_name if 'lora' not in look_data: look_data['lora'] = {} look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" if not look_data['lora'].get('lora_triggers'): look_data['lora']['lora_triggers'] = name_base if look_data['lora'].get('lora_weight') is None: look_data['lora']['lora_weight'] = 0.8 if look_data['lora'].get('lora_weight_min') is None: look_data['lora']['lora_weight_min'] = 0.7 if look_data['lora'].get('lora_weight_max') is None: look_data['lora']['lora_weight_max'] = 1.0 os.makedirs(app.config['LOOKS_DIR'], exist_ok=True) with open(json_path, 'w') as f: json.dump(look_data, f, indent=2) if is_existing: overwritten_count += 1 else: created_count += 1 time.sleep(0.5) except Exception as e: print(f"Error creating look for {filename}: {e}") if created_count > 0 or overwritten_count > 0: sync_looks() msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.' if skipped_count > 0: msg += f' (Skipped {skipped_count} existing)' flash(msg) else: flash(f'No looks created or overwritten. {skipped_count} existing entries found.') return redirect(url_for('looks_index'))