Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a38915b354 |
301
app.py
301
app.py
@@ -385,6 +385,7 @@ def inject_default_checkpoint():
|
||||
@app.route('/set_default_checkpoint', methods=['POST'])
|
||||
def set_default_checkpoint():
|
||||
session['default_checkpoint'] = request.form.get('checkpoint_path', '')
|
||||
session.modified = True
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
@@ -401,6 +402,27 @@ def api_status_comfyui():
|
||||
return {'status': 'error'}
|
||||
|
||||
|
||||
@app.route('/api/comfyui/loaded_checkpoint')
|
||||
def api_comfyui_loaded_checkpoint():
|
||||
"""Return the checkpoint name from the most recently completed ComfyUI job."""
|
||||
url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
|
||||
try:
|
||||
resp = requests.get(f'{url}/history', timeout=3)
|
||||
if not resp.ok:
|
||||
return {'checkpoint': None}
|
||||
history = resp.json()
|
||||
if not history:
|
||||
return {'checkpoint': None}
|
||||
# Sort by timestamp descending, take the most recent job
|
||||
latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', ''))
|
||||
# Node "4" is the checkpoint loader in the workflow
|
||||
nodes = latest.get('prompt', [None, None, {}])[2]
|
||||
ckpt_name = nodes.get('4', {}).get('inputs', {}).get('ckpt_name')
|
||||
return {'checkpoint': ckpt_name}
|
||||
except Exception:
|
||||
return {'checkpoint': None}
|
||||
|
||||
|
||||
@app.route('/api/status/mcp')
|
||||
def api_status_mcp():
|
||||
"""Return whether the danbooru-mcp Docker container is running."""
|
||||
@@ -417,80 +439,39 @@ def api_status_mcp():
|
||||
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
|
||||
def get_available_loras():
|
||||
loras = []
|
||||
if os.path.exists(app.config['LORA_DIR']):
|
||||
for f in os.listdir(app.config['LORA_DIR']):
|
||||
if f.endswith('.safetensors'):
|
||||
# Using the format seen in character JSONs
|
||||
loras.append(f"Illustrious/Looks/{f}")
|
||||
return sorted(loras)
|
||||
_LORA_DEFAULTS = {
|
||||
'characters': '/ImageModels/lora/Illustrious/Looks',
|
||||
'outfits': '/ImageModels/lora/Illustrious/Clothing',
|
||||
'actions': '/ImageModels/lora/Illustrious/Poses',
|
||||
'styles': '/ImageModels/lora/Illustrious/Styles',
|
||||
'scenes': '/ImageModels/lora/Illustrious/Backgrounds',
|
||||
'detailers': '/ImageModels/lora/Illustrious/Detailers',
|
||||
}
|
||||
|
||||
def get_available_clothing_loras():
|
||||
"""Get LoRAs from the Clothing directory for outfit LoRAs."""
|
||||
clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/'
|
||||
loras = []
|
||||
if os.path.exists(clothing_lora_dir):
|
||||
for f in os.listdir(clothing_lora_dir):
|
||||
if f.endswith('.safetensors'):
|
||||
loras.append(f"Illustrious/Clothing/{f}")
|
||||
return sorted(loras)
|
||||
|
||||
def get_available_action_loras():
|
||||
"""Get LoRAs from the Poses directory for action LoRAs."""
|
||||
poses_lora_dir = '/ImageModels/lora/Illustrious/Poses/'
|
||||
loras = []
|
||||
if os.path.exists(poses_lora_dir):
|
||||
for f in os.listdir(poses_lora_dir):
|
||||
if f.endswith('.safetensors'):
|
||||
loras.append(f"Illustrious/Poses/{f}")
|
||||
return sorted(loras)
|
||||
|
||||
def get_available_style_loras():
|
||||
"""Get LoRAs from the Styles directory for style LoRAs."""
|
||||
styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/'
|
||||
loras = []
|
||||
if os.path.exists(styles_lora_dir):
|
||||
for f in os.listdir(styles_lora_dir):
|
||||
if f.endswith('.safetensors'):
|
||||
loras.append(f"Illustrious/Styles/{f}")
|
||||
return sorted(loras)
|
||||
|
||||
def get_available_detailer_loras():
|
||||
"""Get LoRAs from the Detailers directory for detailer LoRAs."""
|
||||
detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/'
|
||||
loras = []
|
||||
if os.path.exists(detailers_lora_dir):
|
||||
for f in os.listdir(detailers_lora_dir):
|
||||
if f.endswith('.safetensors'):
|
||||
loras.append(f"Illustrious/Detailers/{f}")
|
||||
return sorted(loras)
|
||||
|
||||
def get_available_scene_loras():
|
||||
"""Get LoRAs from the Backgrounds directory for scene LoRAs."""
|
||||
backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/'
|
||||
loras = []
|
||||
if os.path.exists(backgrounds_lora_dir):
|
||||
for f in os.listdir(backgrounds_lora_dir):
|
||||
if f.endswith('.safetensors'):
|
||||
loras.append(f"Illustrious/Backgrounds/{f}")
|
||||
return sorted(loras)
|
||||
def get_available_loras(category):
|
||||
"""Return sorted list of LoRA paths for the given category.
|
||||
category: one of 'characters','outfits','actions','styles','scenes','detailers'
|
||||
"""
|
||||
settings = Settings.query.first()
|
||||
lora_dir = (getattr(settings, f'lora_dir_{category}', None) if settings else None) or _LORA_DEFAULTS.get(category, '')
|
||||
if not lora_dir or not os.path.isdir(lora_dir):
|
||||
return []
|
||||
subfolder = os.path.basename(lora_dir.rstrip('/'))
|
||||
return sorted(f"Illustrious/{subfolder}/{f}" for f in os.listdir(lora_dir) if f.endswith('.safetensors'))
|
||||
|
||||
def get_available_checkpoints():
|
||||
settings = Settings.query.first()
|
||||
checkpoint_dirs_str = (settings.checkpoint_dirs if settings else None) or \
|
||||
'/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'
|
||||
checkpoints = []
|
||||
|
||||
# Scan Illustrious
|
||||
if os.path.exists(app.config['ILLUSTRIOUS_MODELS_DIR']):
|
||||
for f in os.listdir(app.config['ILLUSTRIOUS_MODELS_DIR']):
|
||||
for ckpt_dir in checkpoint_dirs_str.split(','):
|
||||
ckpt_dir = ckpt_dir.strip()
|
||||
if not ckpt_dir or not os.path.isdir(ckpt_dir):
|
||||
continue
|
||||
prefix = os.path.basename(ckpt_dir.rstrip('/'))
|
||||
for f in os.listdir(ckpt_dir):
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt'):
|
||||
checkpoints.append(f"Illustrious/{f}")
|
||||
|
||||
# Scan Noob
|
||||
if os.path.exists(app.config['NOOB_MODELS_DIR']):
|
||||
for f in os.listdir(app.config['NOOB_MODELS_DIR']):
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt'):
|
||||
checkpoints.append(f"Noob/{f}")
|
||||
|
||||
checkpoints.append(f"{prefix}/{f}")
|
||||
return sorted(checkpoints)
|
||||
|
||||
def allowed_file(filename):
|
||||
@@ -1484,6 +1465,13 @@ def settings():
|
||||
settings.openrouter_model = request.form.get('model')
|
||||
settings.local_base_url = request.form.get('local_base_url')
|
||||
settings.local_model = request.form.get('local_model')
|
||||
settings.lora_dir_characters = request.form.get('lora_dir_characters') or settings.lora_dir_characters
|
||||
settings.lora_dir_outfits = request.form.get('lora_dir_outfits') or settings.lora_dir_outfits
|
||||
settings.lora_dir_actions = request.form.get('lora_dir_actions') or settings.lora_dir_actions
|
||||
settings.lora_dir_styles = request.form.get('lora_dir_styles') or settings.lora_dir_styles
|
||||
settings.lora_dir_scenes = request.form.get('lora_dir_scenes') or settings.lora_dir_scenes
|
||||
settings.lora_dir_detailers = request.form.get('lora_dir_detailers') or settings.lora_dir_detailers
|
||||
settings.checkpoint_dirs = request.form.get('checkpoint_dirs') or settings.checkpoint_dirs
|
||||
db.session.commit()
|
||||
flash('Settings updated successfully!')
|
||||
return redirect(url_for('settings'))
|
||||
@@ -1823,7 +1811,7 @@ def create_character():
|
||||
@app.route('/character/<path:slug>/edit', methods=['GET', 'POST'])
|
||||
def edit_character(slug):
|
||||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||||
loras = get_available_loras()
|
||||
loras = get_available_loras('characters')
|
||||
char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -2080,15 +2068,13 @@ def upload_image(slug):
|
||||
@app.route('/character/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||||
def replace_cover_from_preview(slug):
|
||||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||||
preview_path = session.get(f'preview_{slug}')
|
||||
|
||||
if preview_path:
|
||||
preview_path = request.form.get('preview_path')
|
||||
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
character.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('detail', slug=slug))
|
||||
|
||||
def _log_workflow_prompts(label, workflow):
|
||||
@@ -2278,19 +2264,6 @@ def _get_default_checkpoint():
|
||||
return None, None
|
||||
return ckpt.checkpoint_path, ckpt.data or {}
|
||||
|
||||
def _queue_generation(character, action='preview', selected_fields=None, client_id=None):
|
||||
# 1. Load workflow
|
||||
with open('comfy_workflow.json', 'r') as f:
|
||||
workflow = json.load(f)
|
||||
|
||||
# 2. Build prompts with active outfit
|
||||
prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit)
|
||||
|
||||
# 3. Prepare workflow
|
||||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||||
workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
|
||||
|
||||
return queue_prompt(workflow, client_id=client_id)
|
||||
@app.route('/get_missing_characters')
|
||||
def get_missing_characters():
|
||||
missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).all()
|
||||
@@ -2345,6 +2318,7 @@ def generate_image(slug):
|
||||
|
||||
# Save preferences
|
||||
session[f'prefs_{slug}'] = selected_fields
|
||||
session.modified = True
|
||||
|
||||
# Build workflow
|
||||
with open('comfy_workflow.json', 'r') as f:
|
||||
@@ -2353,6 +2327,7 @@ def generate_image(slug):
|
||||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||||
workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
|
||||
|
||||
label = f"{character.name} – {action}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action))
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'status': 'queued', 'job_id': job['id']}
|
||||
@@ -2428,7 +2403,9 @@ def rescan_outfits():
|
||||
|
||||
@app.route('/outfits/bulk_create', methods=['POST'])
|
||||
def bulk_create_outfits_from_loras():
|
||||
clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/'
|
||||
_s = Settings.query.first()
|
||||
clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(clothing_lora_dir)
|
||||
if not os.path.exists(clothing_lora_dir):
|
||||
flash('Clothing LoRA directory not found.', 'error')
|
||||
return redirect(url_for('outfits_index'))
|
||||
@@ -2489,7 +2466,7 @@ def bulk_create_outfits_from_loras():
|
||||
|
||||
if 'lora' not in outfit_data:
|
||||
outfit_data['lora'] = {}
|
||||
outfit_data['lora']['lora_name'] = f"Illustrious/Clothing/{filename}"
|
||||
outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if not outfit_data['lora'].get('lora_triggers'):
|
||||
outfit_data['lora']['lora_triggers'] = name_base
|
||||
if outfit_data['lora'].get('lora_weight') is None:
|
||||
@@ -2548,7 +2525,7 @@ def outfit_detail(slug):
|
||||
@app.route('/outfit/<path:slug>/edit', methods=['GET', 'POST'])
|
||||
def edit_outfit(slug):
|
||||
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
||||
loras = get_available_clothing_loras() # Use clothing LoRAs for outfits
|
||||
loras = get_available_loras('outfits') # Use clothing LoRAs for outfits
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
@@ -2667,6 +2644,7 @@ def generate_outfit_image(slug):
|
||||
# Save preferences
|
||||
session[f'prefs_outfit_{slug}'] = selected_fields
|
||||
session[f'char_outfit_{slug}'] = character_slug
|
||||
session.modified = True
|
||||
|
||||
# Build combined data for prompt building
|
||||
if character:
|
||||
@@ -2743,15 +2721,13 @@ def generate_outfit_image(slug):
|
||||
@app.route('/outfit/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||||
def replace_outfit_cover_from_preview(slug):
|
||||
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
||||
preview_path = session.get(f'preview_outfit_{slug}')
|
||||
|
||||
if preview_path:
|
||||
preview_path = request.form.get('preview_path')
|
||||
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
outfit.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('outfit_detail', slug=slug))
|
||||
|
||||
@app.route('/outfit/create', methods=['GET', 'POST'])
|
||||
@@ -2988,7 +2964,7 @@ def action_detail(slug):
|
||||
@app.route('/action/<path:slug>/edit', methods=['GET', 'POST'])
|
||||
def edit_action(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
loras = get_available_action_loras()
|
||||
loras = get_available_loras('actions')
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
@@ -3091,7 +3067,7 @@ def generate_action_image(slug):
|
||||
|
||||
try:
|
||||
# Get action type
|
||||
action_type = request.form.get('action', 'preview')
|
||||
action = request.form.get('action', 'preview')
|
||||
|
||||
# Get selected fields
|
||||
selected_fields = request.form.getlist('include_field')
|
||||
@@ -3107,6 +3083,7 @@ def generate_action_image(slug):
|
||||
# Save preferences
|
||||
session[f'char_action_{slug}'] = character_slug
|
||||
session[f'prefs_action_{slug}'] = selected_fields
|
||||
session.modified = True
|
||||
|
||||
# Build combined data for prompt building
|
||||
if character:
|
||||
@@ -3245,8 +3222,8 @@ def generate_action_image(slug):
|
||||
workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
|
||||
|
||||
char_label = character.name if character else 'no character'
|
||||
label = f"Action: {action_obj.name} ({char_label}) – {action_type}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action_type))
|
||||
label = f"Action: {action_obj.name} ({char_label}) – {action}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action))
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'status': 'queued', 'job_id': job['id']}
|
||||
@@ -3263,15 +3240,13 @@ def generate_action_image(slug):
|
||||
@app.route('/action/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||||
def replace_action_cover_from_preview(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
preview_path = session.get(f'preview_action_{slug}')
|
||||
|
||||
if preview_path:
|
||||
preview_path = request.form.get('preview_path')
|
||||
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
action.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
@app.route('/action/<path:slug>/save_defaults', methods=['POST'])
|
||||
@@ -3285,7 +3260,9 @@ def save_action_defaults(slug):
|
||||
|
||||
@app.route('/actions/bulk_create', methods=['POST'])
|
||||
def bulk_create_actions_from_loras():
|
||||
actions_lora_dir = '/ImageModels/lora/Illustrious/Poses/'
|
||||
_s = Settings.query.first()
|
||||
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(actions_lora_dir)
|
||||
if not os.path.exists(actions_lora_dir):
|
||||
flash('Actions LoRA directory not found.', 'error')
|
||||
return redirect(url_for('actions_index'))
|
||||
@@ -3348,7 +3325,7 @@ def bulk_create_actions_from_loras():
|
||||
|
||||
# Update lora dict safely
|
||||
if 'lora' not in action_data: action_data['lora'] = {}
|
||||
action_data['lora']['lora_name'] = f"Illustrious/Poses/{filename}"
|
||||
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
|
||||
# Fallbacks if LLM failed to extract metadata
|
||||
if not action_data['lora'].get('lora_triggers'):
|
||||
@@ -3554,7 +3531,7 @@ def style_detail(slug):
|
||||
@app.route('/style/<path:slug>/edit', methods=['GET', 'POST'])
|
||||
def edit_style(slug):
|
||||
style = Style.query.filter_by(slug=slug).first_or_404()
|
||||
loras = get_available_style_loras()
|
||||
loras = get_available_loras('styles')
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
@@ -3718,6 +3695,7 @@ def generate_style_image(slug):
|
||||
# Save preferences
|
||||
session[f'char_style_{slug}'] = character_slug
|
||||
session[f'prefs_style_{slug}'] = selected_fields
|
||||
session.modified = True
|
||||
|
||||
# Build workflow using helper (returns workflow dict, not prompt_response)
|
||||
workflow = _build_style_workflow(style_obj, character, selected_fields)
|
||||
@@ -3750,15 +3728,13 @@ def save_style_defaults(slug):
|
||||
@app.route('/style/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||||
def replace_style_cover_from_preview(slug):
|
||||
style = Style.query.filter_by(slug=slug).first_or_404()
|
||||
preview_path = session.get(f'preview_style_{slug}')
|
||||
|
||||
if preview_path:
|
||||
preview_path = request.form.get('preview_path')
|
||||
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
style.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('style_detail', slug=slug))
|
||||
|
||||
@app.route('/get_missing_styles')
|
||||
@@ -3819,7 +3795,9 @@ def generate_missing_styles():
|
||||
|
||||
@app.route('/styles/bulk_create', methods=['POST'])
|
||||
def bulk_create_styles_from_loras():
|
||||
styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/'
|
||||
_s = Settings.query.first()
|
||||
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(styles_lora_dir)
|
||||
if not os.path.exists(styles_lora_dir):
|
||||
flash('Styles LoRA directory not found.', 'error')
|
||||
return redirect(url_for('styles_index'))
|
||||
@@ -3877,7 +3855,7 @@ def bulk_create_styles_from_loras():
|
||||
style_data['style_name'] = style_name
|
||||
|
||||
if 'lora' not in style_data: style_data['lora'] = {}
|
||||
style_data['lora']['lora_name'] = f"Illustrious/Styles/{filename}"
|
||||
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
|
||||
if not style_data['lora'].get('lora_triggers'):
|
||||
style_data['lora']['lora_triggers'] = name_base
|
||||
@@ -4059,7 +4037,7 @@ def scene_detail(slug):
|
||||
@app.route('/scene/<path:slug>/edit', methods=['GET', 'POST'])
|
||||
def edit_scene(slug):
|
||||
scene = Scene.query.filter_by(slug=slug).first_or_404()
|
||||
loras = get_available_scene_loras()
|
||||
loras = get_available_loras('scenes')
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
@@ -4253,6 +4231,7 @@ def generate_scene_image(slug):
|
||||
# Save preferences
|
||||
session[f'char_scene_{slug}'] = character_slug
|
||||
session[f'prefs_scene_{slug}'] = selected_fields
|
||||
session.modified = True
|
||||
|
||||
# Build workflow using helper
|
||||
workflow = _queue_scene_generation(scene_obj, character, selected_fields)
|
||||
@@ -4285,20 +4264,20 @@ def save_scene_defaults(slug):
|
||||
@app.route('/scene/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||||
def replace_scene_cover_from_preview(slug):
|
||||
scene = Scene.query.filter_by(slug=slug).first_or_404()
|
||||
preview_path = session.get(f'preview_scene_{slug}')
|
||||
|
||||
if preview_path:
|
||||
preview_path = request.form.get('preview_path')
|
||||
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
scene.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('scene_detail', slug=slug))
|
||||
|
||||
@app.route('/scenes/bulk_create', methods=['POST'])
|
||||
def bulk_create_scenes_from_loras():
|
||||
backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/'
|
||||
_s = Settings.query.first()
|
||||
backgrounds_lora_dir = ((_s.lora_dir_scenes if _s else None) or '/ImageModels/lora/Illustrious/Backgrounds').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(backgrounds_lora_dir)
|
||||
if not os.path.exists(backgrounds_lora_dir):
|
||||
flash('Backgrounds LoRA directory not found.', 'error')
|
||||
return redirect(url_for('scenes_index'))
|
||||
@@ -4360,7 +4339,7 @@ def bulk_create_scenes_from_loras():
|
||||
scene_data['scene_name'] = scene_name
|
||||
|
||||
if 'lora' not in scene_data: scene_data['lora'] = {}
|
||||
scene_data['lora']['lora_name'] = f"Illustrious/Backgrounds/{filename}"
|
||||
scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
|
||||
if not scene_data['lora'].get('lora_triggers'):
|
||||
scene_data['lora']['lora_triggers'] = name_base
|
||||
@@ -4554,7 +4533,7 @@ def detailer_detail(slug):
|
||||
@app.route('/detailer/<path:slug>/edit', methods=['GET', 'POST'])
|
||||
def edit_detailer(slug):
|
||||
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
|
||||
loras = get_available_detailer_loras()
|
||||
loras = get_available_loras('detailers')
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
@@ -4712,7 +4691,7 @@ def generate_detailer_image(slug):
|
||||
|
||||
try:
|
||||
# Get action type
|
||||
action_type = request.form.get('action', 'preview')
|
||||
action = request.form.get('action', 'preview')
|
||||
|
||||
# Get selected fields
|
||||
selected_fields = request.form.getlist('include_field')
|
||||
@@ -4737,13 +4716,14 @@ def generate_detailer_image(slug):
|
||||
session[f'extra_pos_detailer_{slug}'] = extra_positive
|
||||
session[f'extra_neg_detailer_{slug}'] = extra_negative
|
||||
session[f'prefs_detailer_{slug}'] = selected_fields
|
||||
session.modified = True
|
||||
|
||||
# Build workflow using helper
|
||||
workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative)
|
||||
|
||||
char_label = character.name if character else 'no character'
|
||||
label = f"Detailer: {detailer_obj.name} ({char_label}) – {action_type}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action_type))
|
||||
label = f"Detailer: {detailer_obj.name} ({char_label}) – {action}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action))
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'status': 'queued', 'job_id': job['id']}
|
||||
@@ -4769,15 +4749,13 @@ def save_detailer_defaults(slug):
|
||||
@app.route('/detailer/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||||
def replace_detailer_cover_from_preview(slug):
|
||||
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
|
||||
preview_path = session.get(f'preview_detailer_{slug}')
|
||||
|
||||
if preview_path:
|
||||
preview_path = request.form.get('preview_path')
|
||||
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
detailer.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('detailer_detail', slug=slug))
|
||||
|
||||
@app.route('/detailer/<path:slug>/save_json', methods=['POST'])
|
||||
@@ -4798,7 +4776,9 @@ def save_detailer_json(slug):
|
||||
|
||||
@app.route('/detailers/bulk_create', methods=['POST'])
|
||||
def bulk_create_detailers_from_loras():
|
||||
detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/'
|
||||
_s = Settings.query.first()
|
||||
detailers_lora_dir = ((_s.lora_dir_detailers if _s else None) or '/ImageModels/lora/Illustrious/Detailers').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(detailers_lora_dir)
|
||||
if not os.path.exists(detailers_lora_dir):
|
||||
flash('Detailers LoRA directory not found.', 'error')
|
||||
return redirect(url_for('detailers_index'))
|
||||
@@ -4856,7 +4836,7 @@ def bulk_create_detailers_from_loras():
|
||||
detailer_data['detailer_name'] = detailer_name
|
||||
|
||||
if 'lora' not in detailer_data: detailer_data['lora'] = {}
|
||||
detailer_data['lora']['lora_name'] = f"Illustrious/Detailers/{filename}"
|
||||
detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
|
||||
if not detailer_data['lora'].get('lora_triggers'):
|
||||
detailer_data['lora']['lora_triggers'] = name_base
|
||||
@@ -5092,6 +5072,7 @@ def generate_checkpoint_image(slug):
|
||||
character_slug = character.slug
|
||||
|
||||
session[f'char_checkpoint_{slug}'] = character_slug
|
||||
session.modified = True
|
||||
workflow = _build_checkpoint_workflow(ckpt, character)
|
||||
|
||||
char_label = character.name if character else 'random'
|
||||
@@ -5102,7 +5083,7 @@ def generate_checkpoint_image(slug):
|
||||
return {'status': 'queued', 'job_id': job['id']}
|
||||
return redirect(url_for('checkpoint_detail', slug=slug))
|
||||
except Exception as e:
|
||||
print(f"Checkpoint generation error: {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)}")
|
||||
@@ -5111,13 +5092,13 @@ def generate_checkpoint_image(slug):
|
||||
@app.route('/checkpoint/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||||
def replace_checkpoint_cover_from_preview(slug):
|
||||
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
|
||||
preview_path = session.get(f'preview_checkpoint_{slug}')
|
||||
if preview_path:
|
||||
preview_path = request.form.get('preview_path')
|
||||
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
ckpt.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('checkpoint_detail', slug=slug))
|
||||
|
||||
@app.route('/checkpoint/<path:slug>/save_json', methods=['POST'])
|
||||
@@ -5281,7 +5262,7 @@ def look_detail(slug):
|
||||
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()
|
||||
loras = get_available_loras('characters')
|
||||
|
||||
if request.method == 'POST':
|
||||
look.name = request.form.get('look_name', look.name)
|
||||
@@ -5363,6 +5344,7 @@ def generate_look_image(slug):
|
||||
|
||||
session[f'prefs_look_{slug}'] = selected_fields
|
||||
session[f'char_look_{slug}'] = character_slug
|
||||
session.modified = True
|
||||
|
||||
lora_triggers = look.data.get('lora', {}).get('lora_triggers', '')
|
||||
look_positive = look.data.get('positive', '')
|
||||
@@ -5412,7 +5394,7 @@ def generate_look_image(slug):
|
||||
return redirect(url_for('look_detail', slug=slug))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Look generation error: {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)}")
|
||||
@@ -5421,13 +5403,13 @@ def generate_look_image(slug):
|
||||
@app.route('/look/<path:slug>/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 = session.get(f'preview_look_{slug}')
|
||||
if preview_path:
|
||||
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 from preview!')
|
||||
flash('Cover image updated!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
flash('No valid preview image selected.', 'error')
|
||||
return redirect(url_for('look_detail', slug=slug))
|
||||
|
||||
@app.route('/look/<path:slug>/save_defaults', methods=['POST'])
|
||||
@@ -5458,7 +5440,7 @@ def save_look_json(slug):
|
||||
@app.route('/look/create', methods=['GET', 'POST'])
|
||||
def create_look():
|
||||
characters = Character.query.order_by(Character.name).all()
|
||||
loras = get_available_loras()
|
||||
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(' ', '_'))
|
||||
@@ -5513,7 +5495,9 @@ def clear_all_look_covers():
|
||||
|
||||
@app.route('/looks/bulk_create', methods=['POST'])
|
||||
def bulk_create_looks_from_loras():
|
||||
lora_dir = app.config['LORA_DIR']
|
||||
_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'))
|
||||
@@ -5574,7 +5558,7 @@ def bulk_create_looks_from_loras():
|
||||
|
||||
if 'lora' not in look_data:
|
||||
look_data['lora'] = {}
|
||||
look_data['lora']['lora_name'] = f"Illustrious/Looks/{filename}"
|
||||
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:
|
||||
@@ -6386,7 +6370,14 @@ if __name__ == '__main__':
|
||||
columns_to_add = [
|
||||
('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"),
|
||||
('local_base_url', "VARCHAR(255)"),
|
||||
('local_model', "VARCHAR(100)")
|
||||
('local_model', "VARCHAR(100)"),
|
||||
('lora_dir_characters', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Looks'"),
|
||||
('lora_dir_outfits', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Clothing'"),
|
||||
('lora_dir_actions', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Poses'"),
|
||||
('lora_dir_styles', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Styles'"),
|
||||
('lora_dir_scenes', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Backgrounds'"),
|
||||
('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"),
|
||||
('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"),
|
||||
]
|
||||
for col_name, col_type in columns_to_add:
|
||||
try:
|
||||
|
||||
@@ -132,6 +132,15 @@ class Settings(db.Model):
|
||||
openrouter_model = db.Column(db.String(100), default='google/gemini-2.0-flash-001')
|
||||
local_base_url = db.Column(db.String(255), nullable=True)
|
||||
local_model = db.Column(db.String(100), nullable=True)
|
||||
# LoRA directories (absolute paths on disk)
|
||||
lora_dir_characters = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Looks')
|
||||
lora_dir_outfits = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Clothing')
|
||||
lora_dir_actions = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Poses')
|
||||
lora_dir_styles = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Styles')
|
||||
lora_dir_scenes = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Backgrounds')
|
||||
lora_dir_detailers = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Detailers')
|
||||
# Checkpoint scan directories (comma-separated list of absolute paths)
|
||||
checkpoint_dirs = db.Column(db.String(1000), default='/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob')
|
||||
|
||||
def __repr__(self):
|
||||
return '<Settings>'
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<div class="card mb-4">
|
||||
<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').src)">
|
||||
{% if action.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid" data-preview-path="{{ action.image_path }}">
|
||||
{% else %}
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
{% endif %}
|
||||
@@ -97,35 +97,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
|
||||
<span>Selected Preview</span>
|
||||
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
@@ -163,7 +148,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -288,7 +273,8 @@
|
||||
class="img-fluid rounded"
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal">
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="{{ img }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
|
||||
@@ -314,20 +300,30 @@
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
const previewHeader = document.getElementById('preview-card-header');
|
||||
const charSelect = document.getElementById('character_select');
|
||||
const charContext = document.getElementById('character-context');
|
||||
|
||||
// Toggle character context info
|
||||
charSelect.addEventListener('change', () => {
|
||||
if (charSelect.value && charSelect.value !== '__random__') {
|
||||
charContext.classList.remove('d-none');
|
||||
} else {
|
||||
charContext.classList.add('d-none');
|
||||
}
|
||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
||||
});
|
||||
|
||||
let currentJobId = null;
|
||||
let currentAction = null;
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -335,17 +331,9 @@
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') {
|
||||
clearInterval(poll);
|
||||
resolve(data);
|
||||
} else if (data.status === 'failed' || data.status === 'removed') {
|
||||
clearInterval(poll);
|
||||
reject(new Error(data.error || 'Job failed'));
|
||||
} else if (data.status === 'processing') {
|
||||
progressLabel.textContent = 'Generating…';
|
||||
} else {
|
||||
progressLabel.textContent = 'Queued…';
|
||||
}
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
||||
} catch (err) { console.error('Poll error:', err); }
|
||||
}, 1500);
|
||||
});
|
||||
@@ -355,12 +343,10 @@
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') return;
|
||||
e.preventDefault();
|
||||
currentAction = submitter.value;
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', currentAction);
|
||||
formData.append('action', 'preview');
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '';
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = 'Queuing…';
|
||||
try {
|
||||
@@ -368,31 +354,21 @@
|
||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
if (data.error) { alert('Error: ' + data.error); return; }
|
||||
progressLabel.textContent = 'Queued…';
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
if (jobResult.result?.image_url) {
|
||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Generation failed: ' + err.message);
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
});
|
||||
|
||||
// Batch: Generate All Characters
|
||||
const allCharacters = [
|
||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
let stopBatch = false;
|
||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
||||
@@ -400,7 +376,7 @@
|
||||
const batchLabel = document.getElementById('batch-label');
|
||||
const batchBar = document.getElementById('batch-bar');
|
||||
|
||||
function addToPreviewGallery(imageUrl, charName) {
|
||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||
const gallery = document.getElementById('preview-gallery');
|
||||
const placeholder = document.getElementById('gallery-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
@@ -411,8 +387,9 @@
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="${relativePath}"
|
||||
title="${charName}">
|
||||
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
|
||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
||||
</div>`;
|
||||
gallery.insertBefore(col, gallery.firstChild);
|
||||
const badge = document.querySelector('#previews-tab .badge');
|
||||
@@ -427,43 +404,37 @@
|
||||
stopAllBtn.classList.remove('d-none');
|
||||
batchProgress.classList.remove('d-none');
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||
for (let i = 0; i < allCharacters.length; i++) {
|
||||
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const formAction = genForm.getAttribute('action');
|
||||
batchLabel.textContent = 'Queuing all characters…';
|
||||
const pending = [];
|
||||
for (const char of allCharacters) {
|
||||
if (stopBatch) break;
|
||||
const char = allCharacters[i];
|
||||
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
|
||||
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const fd = new FormData();
|
||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
||||
fd.append('character_slug', char.slug);
|
||||
fd.append('action', 'preview');
|
||||
try {
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = `${char.name}: Queuing…`;
|
||||
const resp = await fetch(genForm.getAttribute('action'), {
|
||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await resp.json();
|
||||
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
addToPreviewGallery(jobResult.result.image_url, char.name);
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${char.name}:`, err);
|
||||
currentJobId = null;
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
||||
}
|
||||
|
||||
batchBar.style.width = '0%';
|
||||
let done = 0;
|
||||
const total = pending.length;
|
||||
batchLabel.textContent = `0 / ${total} complete`;
|
||||
await Promise.all(pending.map(({ char, jobId }) =>
|
||||
waitForJob(jobId).then(result => {
|
||||
done++;
|
||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||
batchLabel.textContent = `${done} / ${total} complete`;
|
||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
||||
));
|
||||
|
||||
batchBar.style.width = '100%';
|
||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||
generateAllBtn.disabled = false;
|
||||
@@ -474,10 +445,9 @@
|
||||
stopAllBtn.addEventListener('click', () => {
|
||||
stopBatch = true;
|
||||
stopAllBtn.classList.add('d-none');
|
||||
batchLabel.textContent = 'Stopping after current generation...';
|
||||
batchLabel.textContent = 'Stopping…';
|
||||
});
|
||||
|
||||
// JSON Editor
|
||||
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Action Gallery</h2>
|
||||
<h2>Action Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
|
||||
{% if ckpt.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}" class="img-fluid"
|
||||
data-preview-path="{{ ckpt.image_path }}">
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
@@ -89,39 +90,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
|
||||
<small>Selected Preview</small>
|
||||
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
@@ -131,7 +115,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<a href="{{ url_for('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="{{ url_for('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,6 +222,7 @@
|
||||
<img src="{{ url_for('static', filename='uploads/' + img) }}"
|
||||
class="img-fluid rounded"
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
data-preview-path="{{ img }}"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal">
|
||||
</div>
|
||||
@@ -259,9 +244,25 @@
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewCardHeader = document.getElementById('preview-card-header');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
|
||||
let currentJobId = null;
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -294,15 +295,8 @@
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
}
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
});
|
||||
@@ -320,7 +314,7 @@
|
||||
const batchLabel = document.getElementById('batch-label');
|
||||
const batchBar = document.getElementById('batch-bar');
|
||||
|
||||
function addToPreviewGallery(imageUrl, charName) {
|
||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||
const gallery = document.getElementById('preview-gallery');
|
||||
const placeholder = document.getElementById('gallery-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
@@ -329,6 +323,7 @@
|
||||
col.innerHTML = `<div class="position-relative">
|
||||
<img src="${imageUrl}" class="img-fluid rounded"
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
data-preview-path="${relativePath}"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
title="${charName}">
|
||||
@@ -347,36 +342,36 @@
|
||||
stopAllBtn.classList.remove('d-none');
|
||||
batchProgress.classList.remove('d-none');
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||
for (let i = 0; i < allCharacters.length; i++) {
|
||||
|
||||
// Phase 1: submit all jobs immediately
|
||||
const pending = [];
|
||||
for (const char of allCharacters) {
|
||||
if (stopBatch) break;
|
||||
const char = allCharacters[i];
|
||||
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
|
||||
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const fd = new FormData();
|
||||
fd.append('character_slug', char.slug);
|
||||
fd.append('action', 'preview');
|
||||
try {
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = `${char.name}: Queuing…`;
|
||||
const resp = await fetch(genForm.getAttribute('action'), {
|
||||
const resp = await fetch(form.getAttribute('action'), {
|
||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
addToPreviewGallery(jobResult.result.image_url, char.name);
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
||||
}
|
||||
|
||||
// Phase 2: poll all in parallel
|
||||
batchLabel.textContent = `0 / ${pending.length} complete`;
|
||||
let done = 0;
|
||||
const total = pending.length;
|
||||
await Promise.all(pending.map(({ char, jobId }) =>
|
||||
waitForJob(jobId).then(result => {
|
||||
done++;
|
||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||
batchLabel.textContent = `${done} / ${total} complete`;
|
||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
||||
));
|
||||
|
||||
batchBar.style.width = '100%';
|
||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||
generateAllBtn.disabled = false;
|
||||
@@ -387,7 +382,7 @@
|
||||
stopAllBtn.addEventListener('click', () => {
|
||||
stopBatch = true;
|
||||
stopAllBtn.classList.add('d-none');
|
||||
batchLabel.textContent = 'Stopping after current generation...';
|
||||
batchLabel.textContent = 'Stopping after current submissions...';
|
||||
});
|
||||
|
||||
// JSON Editor
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Checkpoint Gallery</h2>
|
||||
<h2>Checkpoint Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="card mb-4">
|
||||
<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').src)">
|
||||
{% if character.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + character.image_path) }}" alt="{{ character.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + character.image_path) }}" alt="{{ character.name }}" class="img-fluid" data-preview-path="{{ character.image_path }}">
|
||||
{% else %}
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
{% endif %}
|
||||
@@ -44,35 +44,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<form action="{{ url_for('replace_cover_from_preview', slug=character.slug) }}" method="post" class="m-0">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
|
||||
<span>Selected Preview</span>
|
||||
<form action="{{ url_for('replace_cover_from_preview', slug=character.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
@@ -103,7 +88,7 @@
|
||||
<h1 class="mb-0">{{ character.name }}</h1>
|
||||
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
||||
</div>
|
||||
<a href="/" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="/" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
|
||||
<!-- Outfit Switcher -->
|
||||
@@ -227,9 +212,27 @@
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
const previewHeader = document.getElementById('preview-card-header');
|
||||
|
||||
let currentJobId = null;
|
||||
let currentAction = null;
|
||||
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
// Clicking any image with data-preview-path selects it into the preview pane
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -254,19 +257,13 @@
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
// Only intercept generate actions
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!submitter || submitter.value !== 'preview') return;
|
||||
e.preventDefault();
|
||||
|
||||
currentAction = submitter.value;
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', currentAction);
|
||||
formData.append('action', 'preview');
|
||||
|
||||
// UI Reset
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '';
|
||||
@@ -275,34 +272,20 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(form.getAttribute('action'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
method: 'POST', body: formData,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Error: ' + data.error);
|
||||
progressContainer.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
if (data.error) { alert('Error: ' + data.error); return; }
|
||||
|
||||
currentJobId = data.job_id;
|
||||
progressLabel.textContent = 'Queued…';
|
||||
|
||||
// Wait for the background worker to finish
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
|
||||
// Image is already saved — just display it
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
if (jobResult.result?.image_url) {
|
||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Generation failed: ' + err.message);
|
||||
@@ -313,7 +296,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Image modal function
|
||||
function showImage(src) {
|
||||
document.getElementById('modalImage').src = src;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<div class="card mb-4">
|
||||
<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').src)">
|
||||
{% if detailer.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}" class="img-fluid" data-preview-path="{{ detailer.image_path }}">
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
@@ -121,35 +121,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
|
||||
<small>Selected Preview</small>
|
||||
<form action="{{ url_for('replace_detailer_cover_from_preview', slug=detailer.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<form action="{{ url_for('replace_detailer_cover_from_preview', slug=detailer.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
@@ -162,7 +147,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -257,7 +242,8 @@
|
||||
class="img-fluid rounded"
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal">
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="{{ img }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
|
||||
@@ -283,21 +269,31 @@
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
const previewHeader = document.getElementById('preview-card-header');
|
||||
const charSelect = document.getElementById('character_select');
|
||||
const charContext = document.getElementById('character-context');
|
||||
const actionSelect = document.getElementById('action_select');
|
||||
|
||||
// Toggle character context info
|
||||
charSelect.addEventListener('change', () => {
|
||||
if (charSelect.value && charSelect.value !== '__random__') {
|
||||
charContext.classList.remove('d-none');
|
||||
} else {
|
||||
charContext.classList.add('d-none');
|
||||
}
|
||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
||||
});
|
||||
|
||||
let currentJobId = null;
|
||||
let currentAction = null;
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -307,8 +303,7 @@
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
|
||||
else progressLabel.textContent = 'Queued…';
|
||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
||||
} catch (err) { console.error('Poll error:', err); }
|
||||
}, 1500);
|
||||
});
|
||||
@@ -318,9 +313,8 @@
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') return;
|
||||
e.preventDefault();
|
||||
currentAction = submitter.value;
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', currentAction);
|
||||
formData.append('action', 'preview');
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
@@ -330,26 +324,21 @@
|
||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
if (data.error) { alert('Error: ' + data.error); return; }
|
||||
progressLabel.textContent = 'Queued…';
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
if (jobResult.result?.image_url) {
|
||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||
}
|
||||
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
});
|
||||
|
||||
// Batch: Generate All Characters
|
||||
const allCharacters = [
|
||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
let stopBatch = false;
|
||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
||||
@@ -357,7 +346,7 @@
|
||||
const batchLabel = document.getElementById('batch-label');
|
||||
const batchBar = document.getElementById('batch-bar');
|
||||
|
||||
function addToPreviewGallery(imageUrl, charName) {
|
||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||
const gallery = document.getElementById('preview-gallery');
|
||||
const placeholder = document.getElementById('gallery-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
@@ -368,8 +357,9 @@
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="${relativePath}"
|
||||
title="${charName}">
|
||||
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
|
||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
||||
</div>`;
|
||||
gallery.insertBefore(col, gallery.firstChild);
|
||||
const badge = document.querySelector('#previews-tab .badge');
|
||||
@@ -384,12 +374,13 @@
|
||||
stopAllBtn.classList.remove('d-none');
|
||||
batchProgress.classList.remove('d-none');
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||
for (let i = 0; i < allCharacters.length; i++) {
|
||||
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const formAction = genForm.getAttribute('action');
|
||||
batchLabel.textContent = 'Queuing all characters…';
|
||||
const pending = [];
|
||||
for (const char of allCharacters) {
|
||||
if (stopBatch) break;
|
||||
const char = allCharacters[i];
|
||||
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
|
||||
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const fd = new FormData();
|
||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
||||
fd.append('character_slug', char.slug);
|
||||
@@ -398,26 +389,25 @@
|
||||
fd.append('extra_negative', document.getElementById('extra_negative').value);
|
||||
fd.append('action', 'preview');
|
||||
try {
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = `${char.name}: Queuing…`;
|
||||
const resp = await fetch(genForm.getAttribute('action'), {
|
||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await resp.json();
|
||||
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
addToPreviewGallery(jobResult.result.image_url, char.name);
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
||||
}
|
||||
|
||||
batchBar.style.width = '0%';
|
||||
let done = 0;
|
||||
const total = pending.length;
|
||||
batchLabel.textContent = `0 / ${total} complete`;
|
||||
await Promise.all(pending.map(({ char, jobId }) =>
|
||||
waitForJob(jobId).then(result => {
|
||||
done++;
|
||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||
batchLabel.textContent = `${done} / ${total} complete`;
|
||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
||||
));
|
||||
|
||||
batchBar.style.width = '100%';
|
||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||
generateAllBtn.disabled = false;
|
||||
@@ -428,10 +418,9 @@
|
||||
stopAllBtn.addEventListener('click', () => {
|
||||
stopBatch = true;
|
||||
stopAllBtn.classList.add('d-none');
|
||||
batchLabel.textContent = 'Stopping after current generation...';
|
||||
batchLabel.textContent = 'Stopping…';
|
||||
});
|
||||
|
||||
// JSON Editor
|
||||
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Detailer Gallery</h2>
|
||||
<h2>Detailer Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h4 class="mb-0">Gallery
|
||||
<h4 class="mb-0">Image Gallery
|
||||
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Character Gallery</h2>
|
||||
<h2>Character Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<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="/generator" class="btn btn-sm btn-outline-light">Generator</a>
|
||||
<a href="/gallery" class="btn btn-sm btn-outline-light">Gallery</a>
|
||||
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
|
||||
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
|
||||
<div class="vr mx-1 d-none d-lg-block"></div>
|
||||
<!-- Queue indicator -->
|
||||
@@ -48,19 +48,6 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="default-checkpoint-bar border-bottom mb-4">
|
||||
<div class="container d-flex align-items-center gap-2 py-2">
|
||||
<small class="text-muted text-nowrap">Default checkpoint:</small>
|
||||
<select id="defaultCheckpointSelect" class="form-select form-select-sm" style="max-width: 320px;">
|
||||
<option value="">— workflow default —</option>
|
||||
{% for ckpt in all_checkpoints %}
|
||||
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small id="checkpointSaveStatus" class="text-muted" style="opacity:0;transition:opacity 0.5s">Saved</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
@@ -129,22 +116,44 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
|
||||
|
||||
const ckptSelect = document.getElementById('defaultCheckpointSelect');
|
||||
const saveStatus = document.getElementById('checkpointSaveStatus');
|
||||
if (ckptSelect) {
|
||||
ckptSelect.addEventListener('change', () => {
|
||||
fetch('/set_default_checkpoint', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'checkpoint_path=' + encodeURIComponent(ckptSelect.value)
|
||||
}).then(() => {
|
||||
saveStatus.style.opacity = '1';
|
||||
setTimeout(() => { saveStatus.style.opacity = '0'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Loaded checkpoint → ComfyUI tooltip ----
|
||||
(function() {
|
||||
let _loadedCheckpoint = null;
|
||||
async function pollLoadedCheckpoint() {
|
||||
try {
|
||||
const r = await fetch('/api/comfyui/loaded_checkpoint', { cache: 'no-store' });
|
||||
const data = await r.json();
|
||||
_loadedCheckpoint = data.checkpoint || null;
|
||||
} catch {
|
||||
_loadedCheckpoint = null;
|
||||
}
|
||||
updateComfyTooltip();
|
||||
}
|
||||
function updateComfyTooltip() {
|
||||
const el = document.getElementById('status-comfyui');
|
||||
if (!el) return;
|
||||
const dot = el.querySelector('.status-dot');
|
||||
const online = dot && dot.classList.contains('status-ok');
|
||||
let text = 'ComfyUI: ' + (online ? 'online' : 'offline');
|
||||
if (_loadedCheckpoint) {
|
||||
const parts = _loadedCheckpoint.split(/[/\\]/);
|
||||
const name = parts[parts.length - 1].replace(/\.safetensors$/, '');
|
||||
text += '\n' + name;
|
||||
}
|
||||
el.setAttribute('data-bs-title', text);
|
||||
el.setAttribute('title', text);
|
||||
const tip = bootstrap.Tooltip.getInstance(el);
|
||||
if (tip) tip.setContent({ '.tooltip-inner': text });
|
||||
}
|
||||
// Hook into the existing status polling to refresh tooltip after status changes
|
||||
window._updateComfyTooltip = updateComfyTooltip;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
pollLoadedCheckpoint();
|
||||
setInterval(pollLoadedCheckpoint, 30000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// ---- Resource delete modal (category galleries) ----
|
||||
@@ -340,14 +349,15 @@
|
||||
if (!el) return;
|
||||
const dot = el.querySelector('.status-dot');
|
||||
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
|
||||
if (id === 'status-comfyui' && window._updateComfyTooltip) {
|
||||
window._updateComfyTooltip();
|
||||
return;
|
||||
}
|
||||
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
|
||||
el.setAttribute('data-bs-title', tooltipText);
|
||||
el.setAttribute('title', tooltipText);
|
||||
// Refresh tooltip instance if already initialised
|
||||
const tip = bootstrap.Tooltip.getInstance(el);
|
||||
if (tip) {
|
||||
tip.setContent({ '.tooltip-inner': tooltipText });
|
||||
}
|
||||
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
|
||||
}
|
||||
|
||||
async function pollService(svc) {
|
||||
|
||||
@@ -43,9 +43,10 @@
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<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').src)">
|
||||
<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 look.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid"
|
||||
data-preview-path="{{ look.image_path }}">
|
||||
{% else %}
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
{% endif %}
|
||||
@@ -89,35 +90,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
|
||||
<span>Selected Preview</span>
|
||||
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
<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 : '')">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
@@ -156,7 +142,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,9 +223,25 @@
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewCardHeader = document.getElementById('preview-card-header');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
|
||||
let currentJobId = null;
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -272,22 +274,15 @@
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
}
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
});
|
||||
});
|
||||
|
||||
function showImage(src) {
|
||||
document.getElementById('modalImage').src = src;
|
||||
if (src) document.getElementById('modalImage').src = src;
|
||||
}
|
||||
|
||||
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Looks Gallery</h2>
|
||||
<h2>Looks Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="card mb-4">
|
||||
<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').src)">
|
||||
{% if outfit.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}" class="img-fluid" data-preview-path="{{ outfit.image_path }}">
|
||||
{% else %}
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
{% endif %}
|
||||
@@ -86,35 +86,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
|
||||
<span>Selected Preview</span>
|
||||
<form action="{{ url_for('replace_outfit_cover_from_preview', slug=outfit.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
|
||||
<span>Latest Preview</span>
|
||||
<form action="{{ url_for('replace_outfit_cover_from_preview', slug=outfit.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
@@ -152,7 +137,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -245,7 +230,8 @@
|
||||
class="img-fluid rounded"
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal">
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="{{ img }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
|
||||
@@ -271,9 +257,25 @@
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
const previewHeader = document.getElementById('preview-card-header');
|
||||
|
||||
let currentJobId = null;
|
||||
let currentAction = null;
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
// Clicking any image with data-preview-path selects it into the preview pane
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -298,19 +300,13 @@
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
// Only intercept generate actions
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!submitter || submitter.value !== 'preview') return;
|
||||
e.preventDefault();
|
||||
|
||||
currentAction = submitter.value;
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', currentAction);
|
||||
formData.append('action', 'preview');
|
||||
|
||||
// UI Reset
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '';
|
||||
@@ -319,32 +315,19 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(form.getAttribute('action'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
method: 'POST', body: formData,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) { alert('Error: ' + data.error); return; }
|
||||
|
||||
if (data.error) {
|
||||
alert('Error: ' + data.error);
|
||||
progressContainer.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
currentJobId = data.job_id;
|
||||
progressLabel.textContent = 'Queued…';
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
if (jobResult.result?.image_url) {
|
||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Generation failed: ' + err.message);
|
||||
@@ -366,7 +349,7 @@
|
||||
const batchLabel = document.getElementById('batch-label');
|
||||
const batchBar = document.getElementById('batch-bar');
|
||||
|
||||
function addToPreviewGallery(imageUrl, charName) {
|
||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||
const gallery = document.getElementById('preview-gallery');
|
||||
const placeholder = document.getElementById('gallery-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
@@ -377,8 +360,9 @@
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="${relativePath}"
|
||||
title="${charName}">
|
||||
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
|
||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
||||
</div>`;
|
||||
gallery.insertBefore(col, gallery.firstChild);
|
||||
const badge = document.querySelector('#previews-tab .badge');
|
||||
@@ -393,43 +377,46 @@
|
||||
stopAllBtn.classList.remove('d-none');
|
||||
batchProgress.classList.remove('d-none');
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||
for (let i = 0; i < allCharacters.length; i++) {
|
||||
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const formAction = genForm.getAttribute('action');
|
||||
|
||||
// Phase 1: submit all jobs immediately
|
||||
batchLabel.textContent = 'Queuing all characters…';
|
||||
const pending = [];
|
||||
for (const char of allCharacters) {
|
||||
if (stopBatch) break;
|
||||
const char = allCharacters[i];
|
||||
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
|
||||
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const fd = new FormData();
|
||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
||||
fd.append('character_slug', char.slug);
|
||||
fd.append('action', 'preview');
|
||||
try {
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = `${char.name}: Queuing…`;
|
||||
const resp = await fetch(genForm.getAttribute('action'), {
|
||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await resp.json();
|
||||
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
addToPreviewGallery(jobResult.result.image_url, char.name);
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${char.name}:`, err);
|
||||
currentJobId = null;
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
||||
}
|
||||
|
||||
// Phase 2: poll all in parallel
|
||||
batchBar.style.width = '0%';
|
||||
let done = 0;
|
||||
const total = pending.length;
|
||||
batchLabel.textContent = `0 / ${total} complete`;
|
||||
|
||||
await Promise.all(pending.map(({ char, jobId }) =>
|
||||
waitForJob(jobId).then(result => {
|
||||
done++;
|
||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||
batchLabel.textContent = `${done} / ${total} complete`;
|
||||
if (result.result?.image_url) {
|
||||
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
||||
}
|
||||
}).catch(err => {
|
||||
done++;
|
||||
console.error(`Failed for ${char.name}:`, err);
|
||||
})
|
||||
));
|
||||
|
||||
batchBar.style.width = '100%';
|
||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||
generateAllBtn.disabled = false;
|
||||
@@ -440,7 +427,7 @@
|
||||
stopAllBtn.addEventListener('click', () => {
|
||||
stopBatch = true;
|
||||
stopAllBtn.classList.add('d-none');
|
||||
batchLabel.textContent = 'Stopping after current generation...';
|
||||
batchLabel.textContent = 'Stopping…';
|
||||
});
|
||||
|
||||
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Outfit Gallery</h2>
|
||||
<h2>Outfit Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<div class="card mb-4">
|
||||
<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').src)">
|
||||
{% if scene.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}" class="img-fluid" data-preview-path="{{ scene.image_path }}">
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
@@ -100,35 +100,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
|
||||
<small>Selected Preview</small>
|
||||
<form action="{{ url_for('replace_scene_cover_from_preview', slug=scene.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<form action="{{ url_for('replace_scene_cover_from_preview', slug=scene.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
@@ -145,7 +130,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +240,8 @@
|
||||
class="img-fluid rounded"
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal">
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="{{ img }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
|
||||
@@ -281,18 +267,30 @@
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
const previewHeader = document.getElementById('preview-card-header');
|
||||
const charSelect = document.getElementById('character_select');
|
||||
const charContext = document.getElementById('character-context');
|
||||
|
||||
charSelect.addEventListener('change', () => {
|
||||
if (charSelect.value && charSelect.value !== '__random__') {
|
||||
charContext.classList.remove('d-none');
|
||||
} else {
|
||||
charContext.classList.add('d-none');
|
||||
}
|
||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
||||
});
|
||||
|
||||
let currentJobId = null;
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -302,8 +300,7 @@
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
|
||||
else progressLabel.textContent = 'Queued…';
|
||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
||||
} catch (err) { console.error('Poll error:', err); }
|
||||
}, 1500);
|
||||
});
|
||||
@@ -324,26 +321,21 @@
|
||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
if (data.error) { alert('Error: ' + data.error); return; }
|
||||
progressLabel.textContent = 'Queued…';
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
if (jobResult.result?.image_url) {
|
||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||
}
|
||||
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
});
|
||||
|
||||
// Batch: Generate All Characters
|
||||
const allCharacters = [
|
||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
let stopBatch = false;
|
||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
||||
@@ -351,7 +343,7 @@
|
||||
const batchLabel = document.getElementById('batch-label');
|
||||
const batchBar = document.getElementById('batch-bar');
|
||||
|
||||
function addToPreviewGallery(imageUrl, charName) {
|
||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||
const gallery = document.getElementById('preview-gallery');
|
||||
const placeholder = document.getElementById('gallery-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
@@ -362,8 +354,9 @@
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="${relativePath}"
|
||||
title="${charName}">
|
||||
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
|
||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
||||
</div>`;
|
||||
gallery.insertBefore(col, gallery.firstChild);
|
||||
const badge = document.querySelector('#previews-tab .badge');
|
||||
@@ -378,37 +371,37 @@
|
||||
stopAllBtn.classList.remove('d-none');
|
||||
batchProgress.classList.remove('d-none');
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||
for (let i = 0; i < allCharacters.length; i++) {
|
||||
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const formAction = genForm.getAttribute('action');
|
||||
batchLabel.textContent = 'Queuing all characters…';
|
||||
const pending = [];
|
||||
for (const char of allCharacters) {
|
||||
if (stopBatch) break;
|
||||
const char = allCharacters[i];
|
||||
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
|
||||
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const fd = new FormData();
|
||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
||||
fd.append('character_slug', char.slug);
|
||||
fd.append('action', 'preview');
|
||||
try {
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = `${char.name}: Queuing…`;
|
||||
const resp = await fetch(genForm.getAttribute('action'), {
|
||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await resp.json();
|
||||
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
addToPreviewGallery(jobResult.result.image_url, char.name);
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
||||
}
|
||||
|
||||
batchBar.style.width = '0%';
|
||||
let done = 0;
|
||||
const total = pending.length;
|
||||
batchLabel.textContent = `0 / ${total} complete`;
|
||||
await Promise.all(pending.map(({ char, jobId }) =>
|
||||
waitForJob(jobId).then(result => {
|
||||
done++;
|
||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||
batchLabel.textContent = `${done} / ${total} complete`;
|
||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
||||
));
|
||||
|
||||
batchBar.style.width = '100%';
|
||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||
generateAllBtn.disabled = false;
|
||||
@@ -419,10 +412,9 @@
|
||||
stopAllBtn.addEventListener('click', () => {
|
||||
stopBatch = true;
|
||||
stopAllBtn.classList.add('d-none');
|
||||
batchLabel.textContent = 'Stopping after current generation...';
|
||||
batchLabel.textContent = 'Stopping…';
|
||||
});
|
||||
|
||||
// JSON Editor
|
||||
initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Scene Gallery</h2>
|
||||
<h2>Scene Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
@@ -73,6 +73,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Directory Settings -->
|
||||
<h5 class="mb-3 text-primary">LoRA Directories</h5>
|
||||
<p class="text-muted small">Absolute paths on disk where LoRA files are scanned for each category.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="lora_dir_characters" class="form-label">Characters / Looks</label>
|
||||
<input type="text" class="form-control" id="lora_dir_characters" name="lora_dir_characters"
|
||||
value="{{ settings.lora_dir_characters or '/ImageModels/lora/Illustrious/Looks' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="lora_dir_outfits" class="form-label">Outfits</label>
|
||||
<input type="text" class="form-control" id="lora_dir_outfits" name="lora_dir_outfits"
|
||||
value="{{ settings.lora_dir_outfits or '/ImageModels/lora/Illustrious/Clothing' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="lora_dir_actions" class="form-label">Actions</label>
|
||||
<input type="text" class="form-control" id="lora_dir_actions" name="lora_dir_actions"
|
||||
value="{{ settings.lora_dir_actions or '/ImageModels/lora/Illustrious/Poses' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="lora_dir_styles" class="form-label">Styles</label>
|
||||
<input type="text" class="form-control" id="lora_dir_styles" name="lora_dir_styles"
|
||||
value="{{ settings.lora_dir_styles or '/ImageModels/lora/Illustrious/Styles' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="lora_dir_scenes" class="form-label">Scenes</label>
|
||||
<input type="text" class="form-control" id="lora_dir_scenes" name="lora_dir_scenes"
|
||||
value="{{ settings.lora_dir_scenes or '/ImageModels/lora/Illustrious/Backgrounds' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="lora_dir_detailers" class="form-label">Detailers</label>
|
||||
<input type="text" class="form-control" id="lora_dir_detailers" name="lora_dir_detailers"
|
||||
value="{{ settings.lora_dir_detailers or '/ImageModels/lora/Illustrious/Detailers' }}">
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5 class="mb-3 text-primary">Checkpoint Directories</h5>
|
||||
<div class="mb-3">
|
||||
<label for="checkpoint_dirs" class="form-label">Checkpoint Scan Paths</label>
|
||||
<input type="text" class="form-control" id="checkpoint_dirs" name="checkpoint_dirs"
|
||||
value="{{ settings.checkpoint_dirs or '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob' }}">
|
||||
<div class="form-text">Comma-separated list of directories to scan for checkpoint files.</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5 class="mb-3 text-primary">Default Checkpoint</h5>
|
||||
<div class="mb-3">
|
||||
<label for="default_checkpoint" class="form-label">Active Checkpoint</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" id="default_checkpoint">
|
||||
<option value="">— workflow default —</option>
|
||||
{% for ckpt in all_checkpoints %}
|
||||
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span id="ckpt-save-status" class="input-group-text text-success" style="opacity:0;transition:opacity 0.5s">Saved</span>
|
||||
</div>
|
||||
<div class="form-text">Sets the checkpoint used for all generation requests. Saved immediately on change.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Save All Settings</button>
|
||||
</div>
|
||||
@@ -152,6 +216,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Default Checkpoint
|
||||
const defaultCkptSelect = document.getElementById('default_checkpoint');
|
||||
const ckptSaveStatus = document.getElementById('ckpt-save-status');
|
||||
if (defaultCkptSelect) {
|
||||
defaultCkptSelect.addEventListener('change', () => {
|
||||
fetch('/set_default_checkpoint', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'checkpoint_path=' + encodeURIComponent(defaultCkptSelect.value)
|
||||
}).then(() => {
|
||||
ckptSaveStatus.style.opacity = '1';
|
||||
setTimeout(() => { ckptSaveStatus.style.opacity = '0'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Local Model Loading
|
||||
const connectLocalBtn = document.getElementById('connect-local-btn');
|
||||
const localModelSelect = document.getElementById('local_model');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<div class="card mb-4">
|
||||
<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').src)">
|
||||
{% if style.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}" class="img-fluid">
|
||||
<img src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}" class="img-fluid" data-preview-path="{{ style.image_path }}">
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
@@ -100,35 +100,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview_image %}
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
|
||||
<small>Selected Preview</small>
|
||||
<form action="{{ url_for('replace_style_cover_from_preview', slug=style.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
|
||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
|
||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4 border-secondary d-none" id="preview-card">
|
||||
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
|
||||
<small>Latest Preview</small>
|
||||
<form action="{{ url_for('replace_style_cover_from_preview', slug=style.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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').src)">
|
||||
<img id="preview-img" src="" alt="Preview" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
@@ -145,7 +130,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +232,8 @@
|
||||
class="img-fluid rounded"
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal">
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="{{ img }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
|
||||
@@ -273,20 +259,30 @@
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPath = document.getElementById('preview-path');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
const previewHeader = document.getElementById('preview-card-header');
|
||||
const charSelect = document.getElementById('character_select');
|
||||
const charContext = document.getElementById('character-context');
|
||||
|
||||
// Toggle character context info
|
||||
charSelect.addEventListener('change', () => {
|
||||
if (charSelect.value && charSelect.value !== '__random__') {
|
||||
charContext.classList.remove('d-none');
|
||||
} else {
|
||||
charContext.classList.add('d-none');
|
||||
}
|
||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
||||
});
|
||||
|
||||
let currentJobId = null;
|
||||
let currentAction = null;
|
||||
function selectPreview(relativePath, imageUrl) {
|
||||
if (!relativePath) return;
|
||||
previewImg.src = imageUrl;
|
||||
previewPath.value = relativePath;
|
||||
replaceBtn.disabled = false;
|
||||
previewCard.classList.remove('d-none');
|
||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
||||
previewCard.classList.replace('border-secondary', 'border-success');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -296,8 +292,7 @@
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
|
||||
else progressLabel.textContent = 'Queued…';
|
||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
||||
} catch (err) { console.error('Poll error:', err); }
|
||||
}, 1500);
|
||||
});
|
||||
@@ -307,9 +302,8 @@
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') return;
|
||||
e.preventDefault();
|
||||
currentAction = submitter.value;
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', currentAction);
|
||||
formData.append('action', 'preview');
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
@@ -319,26 +313,21 @@
|
||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
if (data.error) { alert('Error: ' + data.error); return; }
|
||||
progressLabel.textContent = 'Queued…';
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
if (jobResult.result?.image_url) {
|
||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||
}
|
||||
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
});
|
||||
|
||||
// Batch: Generate All Characters
|
||||
const allCharacters = [
|
||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
let stopBatch = false;
|
||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
||||
@@ -346,7 +335,7 @@
|
||||
const batchLabel = document.getElementById('batch-label');
|
||||
const batchBar = document.getElementById('batch-bar');
|
||||
|
||||
function addToPreviewGallery(imageUrl, charName) {
|
||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||
const gallery = document.getElementById('preview-gallery');
|
||||
const placeholder = document.getElementById('gallery-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
@@ -357,8 +346,9 @@
|
||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||
onclick="showImage(this.src)"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
data-preview-path="${relativePath}"
|
||||
title="${charName}">
|
||||
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
|
||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
||||
</div>`;
|
||||
gallery.insertBefore(col, gallery.firstChild);
|
||||
const badge = document.querySelector('#previews-tab .badge');
|
||||
@@ -373,37 +363,37 @@
|
||||
stopAllBtn.classList.remove('d-none');
|
||||
batchProgress.classList.remove('d-none');
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||
for (let i = 0; i < allCharacters.length; i++) {
|
||||
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const formAction = genForm.getAttribute('action');
|
||||
batchLabel.textContent = 'Queuing all characters…';
|
||||
const pending = [];
|
||||
for (const char of allCharacters) {
|
||||
if (stopBatch) break;
|
||||
const char = allCharacters[i];
|
||||
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
|
||||
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const fd = new FormData();
|
||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
||||
fd.append('character_slug', char.slug);
|
||||
fd.append('action', 'preview');
|
||||
try {
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = `${char.name}: Queuing…`;
|
||||
const resp = await fetch(genForm.getAttribute('action'), {
|
||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await resp.json();
|
||||
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
addToPreviewGallery(jobResult.result.image_url, char.name);
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
||||
}
|
||||
|
||||
batchBar.style.width = '0%';
|
||||
let done = 0;
|
||||
const total = pending.length;
|
||||
batchLabel.textContent = `0 / ${total} complete`;
|
||||
await Promise.all(pending.map(({ char, jobId }) =>
|
||||
waitForJob(jobId).then(result => {
|
||||
done++;
|
||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||
batchLabel.textContent = `${done} / ${total} complete`;
|
||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
||||
));
|
||||
|
||||
batchBar.style.width = '100%';
|
||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||
generateAllBtn.disabled = false;
|
||||
@@ -414,10 +404,9 @@
|
||||
stopAllBtn.addEventListener('click', () => {
|
||||
stopBatch = true;
|
||||
stopAllBtn.classList.add('d-none');
|
||||
batchLabel.textContent = 'Stopping after current generation...';
|
||||
batchLabel.textContent = 'Stopping…';
|
||||
});
|
||||
|
||||
// JSON Editor
|
||||
initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Style Gallery</h2>
|
||||
<h2>Style Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
|
||||
Reference in New Issue
Block a user