feat: implement Actions Gallery with character integration and triple LoRA chaining #3
43
DEVELOPMENT_GUIDE.md
Normal file
43
DEVELOPMENT_GUIDE.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Feature Development Guide: Gallery Pages & Character Integration
|
||||
|
||||
This guide outlines the architectural patterns and best practices developed during the implementation of the **Actions** and **Outfits** galleries. Use this as a blueprint for adding similar features (e.g., "Scenes", "Props", "Effects").
|
||||
|
||||
## 1. Data Model & Persistence
|
||||
- **Database Model:** Add a new class in `models.py`. Include `default_fields` (JSON) to support persistent prompt selections.
|
||||
- **JSON Sync:** Implement a `sync_[feature]()` function in `app.py` to keep the SQLite database in sync with the `data/[feature]/*.json` files.
|
||||
- **Slugs:** Use URL-safe slugs generated from the ID for clean routing.
|
||||
|
||||
## 2. Triple LoRA Chaining
|
||||
Our workflow supports chaining three distinct LoRAs from specific directories:
|
||||
1. **Character:** `Illustrious/Looks/` (Node 16)
|
||||
2. **Outfit:** `Illustrious/Clothing/` (Node 17)
|
||||
3. **Action/Feature:** `Illustrious/Poses/` (Node 18)
|
||||
|
||||
**Implementation Detail:**
|
||||
In `_prepare_workflow`, LoRAs must be chained sequentially. If a previous LoRA is missing, the next one must "reach back" to the Checkpoint (Node 4) or the last valid node in the chain to maintain the model/CLIP connection.
|
||||
|
||||
## 3. Adetailer Routing
|
||||
To improve generation quality, route specific JSON sub-fields to targeted Adetailers:
|
||||
- **Face Detailer (Node 14):** Receives `character_name`, `expression`, and action-specific `head`/`eyes` tags.
|
||||
- **Hand Detailer (Node 15):** Receives priority hand tags (Wardrobe Gloves > Wardrobe Hands > Identity Hands) and action-specific `arms`/`hands` tags.
|
||||
|
||||
## 4. character-Integrated Previews
|
||||
The "Killer Feature" is previewing a standalone item (like an Action or Outfit) on a specific character.
|
||||
|
||||
**Logic Flow:**
|
||||
1. **Merge Data:** Copy `character.data`.
|
||||
2. **Override/Merge:** Replace character `defaults` with feature-specific tags (e.g., Action pose overrides Character pose).
|
||||
3. **Context Injection:** Append character-specific styles (e.g., `[primary_color] simple background`) to the main prompt.
|
||||
4. **Auto-Selection:** When a character is selected, ensure their `identity` and `wardrobe` fields are automatically included in the prompt, even if the feature page has its own manual checkboxes.
|
||||
|
||||
## 5. UI/UX Patterns
|
||||
- **Selection Boxes:** Use checkboxes next to field labels to allow users to toggle specific tags.
|
||||
- **Default Selection:** Implement a "Save as Default Selection" button that persists the current checkbox state to the database.
|
||||
- **Session State:** Store the last selected character and field preferences in the Flask `session` to provide a seamless experience when navigating between items.
|
||||
- **AJAX Generation:** Use the WebSocket + Polling hybrid pattern in the frontend to show real-time progress bars without page reloads.
|
||||
|
||||
## 6. Directory Isolation
|
||||
Always isolate LoRAs by purpose to prevent dropdown clutter:
|
||||
- `get_available_loras()` -> Characters
|
||||
- `get_available_clothing_loras()` -> Outfits
|
||||
- `get_available_action_loras()` -> Actions/Poses
|
||||
105
README.md
105
README.md
@@ -6,16 +6,17 @@ A local web-based GUI for managing character profiles (JSON) and generating cons
|
||||
|
||||
- **Character Gallery**: Automatically scans your `characters/` folder and builds a searchable, sortable database.
|
||||
- **Outfit Gallery**: Manage reusable outfit presets that can be applied to any character.
|
||||
- **AI-Powered Creation**: Create new characters and outfits using AI to generate profiles from descriptions, or manually create blank templates.
|
||||
- **Granular Prompt Control**: Every field in your character JSON (Identity, Wardrobe, Styles) has a checkbox. You decide exactly what is sent to the AI.
|
||||
- **Actions Gallery**: A library of reusable poses and actions (e.g., "Belly Dancing", "Sword Fighting") that can be previewed on any character model.
|
||||
- **AI-Powered Creation**: Create new characters, outfits, and actions using AI to generate profiles from descriptions, or manually create blank templates.
|
||||
- **character-Integrated Previews**: Standalone items (Outfits/Actions) can be previewed directly on a specific character's model with automatic style injection (e.g., background color matching).
|
||||
- **Granular Prompt Control**: Every field in your JSON models (Identity, Wardrobe, Styles, Action details) has a checkbox. You decide exactly what is sent to the AI.
|
||||
- **ComfyUI Integration**:
|
||||
- **SDXL Optimized**: Designed for high-quality SDXL workflows.
|
||||
- **SDXL Optimized**: Designed for high-quality SDXL/Illustrious workflows.
|
||||
- **Localized ADetailer**: Automated Face and Hand detailing with focused prompts (e.g., only eye color and expression are sent to the face detailer).
|
||||
- **LoRA Support**: Automatically detects and applies LoRAs specified in your character sheets.
|
||||
- **Triple LoRA Chaining**: Chains up to three distinct LoRAs (Character + Outfit + Action) sequentially in the generation workflow.
|
||||
- **Real-time Progress**: Live progress bars and queue status via WebSockets (with a reliable polling fallback).
|
||||
- **Batch Processing**:
|
||||
- **Fill Missing**: Generate covers for every character missing one with a single click.
|
||||
- **Refresh All**: Unassign all current covers and generate a fresh set for the whole collection.
|
||||
- **Advanced Generator**: A dedicated page to mix-and-match characters with different checkpoints (Illustrious/Noob support) and custom prompt additions.
|
||||
- **Smart URLs**: Sanitized, human-readable URLs (slugs) that handle special characters and slashes gracefully.
|
||||
|
||||
@@ -47,104 +48,30 @@ A local web-based GUI for managing character profiles (JSON) and generating cons
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating Characters & Outfits
|
||||
- **AI Generation**: Toggle "Use AI to generate profile from description" on, then describe your character or outfit. The AI will generate a complete profile with appropriate tags.
|
||||
### Creating Content
|
||||
- **AI Generation**: Toggle "Use AI to generate profile from description" on, then describe your character, outfit, or action. The AI will generate a complete profile with appropriate tags.
|
||||
- **Manual Creation**: Toggle AI generation off to create a blank template you can edit yourself.
|
||||
- **Auto-naming**: Leave the filename field empty to auto-generate one from the name. If a file already exists, a number will be appended automatically.
|
||||
|
||||
### Gallery Management
|
||||
- **Rescan**: Use the "Rescan Character Files" button if you've added new JSON files or manually edited them.
|
||||
- **Save Defaults**: On a character page, select your favorite prompt combination and click "Save as Default Selection" to remember it for future quick generations.
|
||||
- **Rescan**: Use the "Rescan" buttons if you've added new JSON files or manually edited them.
|
||||
- **Save Defaults**: On any detail page, select your favorite prompt combination and click "Save as Default Selection" to remember it for future generations.
|
||||
|
||||
### Generation
|
||||
- **Preview**: Generates an image and shows it to you without replacing your current cover.
|
||||
- **Replace**: Generates an image and sets it as the character's official gallery cover.
|
||||
- **Replace**: Generates an image and sets it as the item's official gallery cover.
|
||||
- **Clean Start**: If you want to wipe the database and all generated images to start fresh:
|
||||
```bash
|
||||
./launch.sh --clean
|
||||
```
|
||||
|
||||
## JSON Structure
|
||||
|
||||
### Character Profile
|
||||
```json
|
||||
{
|
||||
"character_id": "example_character",
|
||||
"character_name": "Example Character",
|
||||
"identity": {
|
||||
"base_specs": "1girl, slender build, fair skin",
|
||||
"hair": "long blue hair",
|
||||
"eyes": "blue eyes",
|
||||
"hands": "",
|
||||
"arms": "",
|
||||
"torso": "",
|
||||
"pelvis": "",
|
||||
"legs": "",
|
||||
"feet": "",
|
||||
"extra": ""
|
||||
},
|
||||
"defaults": {
|
||||
"expression": "smile",
|
||||
"pose": "standing",
|
||||
"scene": "simple background"
|
||||
},
|
||||
"wardrobe": {
|
||||
"default": {
|
||||
"full_body": "",
|
||||
"headwear": "",
|
||||
"top": "white blouse",
|
||||
"bottom": "blue skirt",
|
||||
"legwear": "black thighhighs",
|
||||
"footwear": "black shoes",
|
||||
"hands": "",
|
||||
"accessories": "ribbon"
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"aesthetic": "anime style",
|
||||
"primary_color": "blue",
|
||||
"secondary_color": "white",
|
||||
"tertiary_color": ""
|
||||
},
|
||||
"lora": {
|
||||
"lora_name": "",
|
||||
"lora_weight": 1.0,
|
||||
"lora_triggers": ""
|
||||
},
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
```
|
||||
|
||||
### Outfit Profile
|
||||
```json
|
||||
{
|
||||
"outfit_id": "school_uniform_01",
|
||||
"outfit_name": "School Uniform",
|
||||
"wardrobe": {
|
||||
"full_body": "",
|
||||
"headwear": "",
|
||||
"top": "white blouse, sailor collar",
|
||||
"bottom": "pleated skirt",
|
||||
"legwear": "knee socks",
|
||||
"footwear": "loafers",
|
||||
"hands": "",
|
||||
"accessories": "ribbon tie"
|
||||
},
|
||||
"lora": {
|
||||
"lora_name": "",
|
||||
"lora_weight": 0.8,
|
||||
"lora_triggers": ""
|
||||
},
|
||||
"tags": ["school uniform", "uniform"]
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
- `/data/characters`: Your character JSON files.
|
||||
- `/data/characters`: Character JSON files.
|
||||
- `/data/clothing`: Outfit preset JSON files.
|
||||
- `/static/uploads`: Generated images (organized by character subfolders).
|
||||
- `/templates`: HTML UI using Bootstrap 5.
|
||||
- `/data/actions`: Action/Pose preset JSON files.
|
||||
- `/static/uploads`: Generated images (organized by subfolders).
|
||||
- `app.py`: Flask backend and prompt-building logic.
|
||||
- `comfy_workflow.json`: The API-format workflow used for generations.
|
||||
- `models.py`: SQLite database schema.
|
||||
- `models.py`: SQLAlchemy database models.
|
||||
- `DEVELOPMENT_GUIDE.md`: Architectual patterns for extending the browser.
|
||||
|
||||
653
app.py
653
app.py
@@ -6,7 +6,7 @@ import requests
|
||||
import random
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, session
|
||||
from werkzeug.utils import secure_filename
|
||||
from models import db, Character, Settings, Outfit
|
||||
from models import db, Character, Settings, Outfit, Action
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
|
||||
@@ -15,6 +15,7 @@ app.config['UPLOAD_FOLDER'] = 'static/uploads'
|
||||
app.config['SECRET_KEY'] = 'dev-key-123'
|
||||
app.config['CHARACTERS_DIR'] = 'data/characters'
|
||||
app.config['CLOTHING_DIR'] = 'data/clothing'
|
||||
app.config['ACTIONS_DIR'] = 'data/actions'
|
||||
app.config['COMFYUI_URL'] = 'http://127.0.0.1:8188'
|
||||
app.config['ILLUSTRIOUS_MODELS_DIR'] = '/mnt/alexander/AITools/Image Models/Stable-diffusion/Illustrious/'
|
||||
app.config['NOOB_MODELS_DIR'] = '/mnt/alexander/AITools/Image Models/Stable-diffusion/Noob/'
|
||||
@@ -43,6 +44,16 @@ def get_available_clothing_loras():
|
||||
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 = '/mnt/alexander/AITools/Image Models/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_checkpoints():
|
||||
checkpoints = []
|
||||
|
||||
@@ -87,6 +98,7 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
||||
wardrobe = wardrobe_data
|
||||
|
||||
defaults = data.get('defaults', {})
|
||||
action_data = data.get('action', {})
|
||||
|
||||
# Pre-calculate Hand/Glove priority
|
||||
# Priority: wardrobe gloves > wardrobe hands (outfit) > identity hands (character)
|
||||
@@ -137,15 +149,21 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
||||
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
|
||||
parts.append(lora.get('lora_triggers'))
|
||||
|
||||
# 2. Face Prompt: Tag, Eyes, Expression, Headwear
|
||||
# 2. Face Prompt: Tag, Eyes, Expression, Headwear, Action details
|
||||
face_parts = []
|
||||
if char_tag and is_selected('special', 'name'): face_parts.append(char_tag)
|
||||
if identity.get('eyes') and is_selected('identity', 'eyes'): face_parts.append(identity.get('eyes'))
|
||||
if defaults.get('expression') and is_selected('defaults', 'expression'): face_parts.append(defaults.get('expression'))
|
||||
if wardrobe.get('headwear') and is_selected('wardrobe', 'headwear'): face_parts.append(wardrobe.get('headwear'))
|
||||
|
||||
# 3. Hand Prompt: Hand value (Gloves or Hands)
|
||||
# Add specific Action expression details if available
|
||||
if action_data.get('head') and is_selected('action', 'head'): face_parts.append(action_data.get('head'))
|
||||
if action_data.get('eyes') and is_selected('action', 'eyes'): face_parts.append(action_data.get('eyes'))
|
||||
|
||||
# 3. Hand Prompt: Hand value (Gloves or Hands), Action details
|
||||
hand_parts = [hand_val] if hand_val else []
|
||||
if action_data.get('arms') and is_selected('action', 'arms'): hand_parts.append(action_data.get('arms'))
|
||||
if action_data.get('hands') and is_selected('action', 'hands'): hand_parts.append(action_data.get('hands'))
|
||||
|
||||
return {
|
||||
"main": ", ".join(parts),
|
||||
@@ -290,6 +308,63 @@ def sync_outfits():
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def sync_actions():
|
||||
if not os.path.exists(app.config['ACTIONS_DIR']):
|
||||
return
|
||||
|
||||
current_ids = []
|
||||
|
||||
for filename in os.listdir(app.config['ACTIONS_DIR']):
|
||||
if filename.endswith('.json'):
|
||||
file_path = os.path.join(app.config['ACTIONS_DIR'], filename)
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
action_id = data.get('action_id') or filename.replace('.json', '')
|
||||
|
||||
current_ids.append(action_id)
|
||||
|
||||
# Generate URL-safe slug
|
||||
slug = re.sub(r'[^a-zA-Z0-9_]', '', action_id)
|
||||
|
||||
# Check if action already exists
|
||||
action = Action.query.filter_by(action_id=action_id).first()
|
||||
name = data.get('action_name', action_id.replace('_', ' ').title())
|
||||
|
||||
if action:
|
||||
action.data = data
|
||||
action.name = name
|
||||
action.slug = slug
|
||||
action.filename = filename
|
||||
|
||||
# Check if cover image still exists
|
||||
if action.image_path:
|
||||
full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], action.image_path)
|
||||
if not os.path.exists(full_img_path):
|
||||
print(f"Image missing for {action.name}, clearing path.")
|
||||
action.image_path = None
|
||||
|
||||
flag_modified(action, "data")
|
||||
else:
|
||||
new_action = Action(
|
||||
action_id=action_id,
|
||||
slug=slug,
|
||||
filename=filename,
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
db.session.add(new_action)
|
||||
except Exception as e:
|
||||
print(f"Error importing action {filename}: {e}")
|
||||
|
||||
# Remove actions that are no longer in the folder
|
||||
all_actions = Action.query.all()
|
||||
for action in all_actions:
|
||||
if action.action_id not in current_ids:
|
||||
db.session.delete(action)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def call_llm(prompt, system_prompt="You are a creative assistant."):
|
||||
settings = Settings.query.first()
|
||||
if not settings or not settings.openrouter_api_key:
|
||||
@@ -907,7 +982,7 @@ def replace_cover_from_preview(slug):
|
||||
|
||||
return redirect(url_for('detail', slug=slug))
|
||||
|
||||
def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None):
|
||||
def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None):
|
||||
# 1. Update prompts using replacement to preserve embeddings
|
||||
workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"])
|
||||
|
||||
@@ -932,7 +1007,7 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
|
||||
if checkpoint:
|
||||
workflow["4"]["inputs"]["ckpt_name"] = checkpoint
|
||||
|
||||
# 3. Handle LoRAs - Node 16 for character, Node 17 for outfit
|
||||
# 3. Handle LoRAs - Node 16 for character, Node 17 for outfit, Node 18 for action
|
||||
# Start with direct checkpoint connections
|
||||
model_source = ["4", 0]
|
||||
clip_source = ["4", 1]
|
||||
@@ -960,16 +1035,27 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
|
||||
workflow["17"]["inputs"]["strength_model"] = outfit_lora_data.get('lora_weight', 0.8)
|
||||
workflow["17"]["inputs"]["strength_clip"] = outfit_lora_data.get('lora_weight', 0.8)
|
||||
# Chain from character LoRA (node 16) or checkpoint (node 4)
|
||||
if char_lora_name and "16" in workflow:
|
||||
workflow["17"]["inputs"]["model"] = ["16", 0]
|
||||
workflow["17"]["inputs"]["clip"] = ["16", 1]
|
||||
else:
|
||||
workflow["17"]["inputs"]["model"] = ["4", 0]
|
||||
workflow["17"]["inputs"]["clip"] = ["4", 1]
|
||||
workflow["17"]["inputs"]["model"] = model_source
|
||||
workflow["17"]["inputs"]["clip"] = clip_source
|
||||
model_source = ["17", 0]
|
||||
clip_source = ["17", 1]
|
||||
print(f"Outfit LoRA: {outfit_lora_name} @ {outfit_lora_data.get('lora_weight', 0.8)}")
|
||||
|
||||
# Action LoRA (Node 18) - chains from previous LoRA or checkpoint
|
||||
action_lora_data = action.data.get('lora', {}) if action else {}
|
||||
action_lora_name = action_lora_data.get('lora_name')
|
||||
|
||||
if action_lora_name and "18" in workflow:
|
||||
workflow["18"]["inputs"]["lora_name"] = action_lora_name
|
||||
workflow["18"]["inputs"]["strength_model"] = action_lora_data.get('lora_weight', 1.0)
|
||||
workflow["18"]["inputs"]["strength_clip"] = action_lora_data.get('lora_weight', 1.0)
|
||||
# Chain from previous source
|
||||
workflow["18"]["inputs"]["model"] = model_source
|
||||
workflow["18"]["inputs"]["clip"] = clip_source
|
||||
model_source = ["18", 0]
|
||||
clip_source = ["18", 1]
|
||||
print(f"Action LoRA: {action_lora_name} @ {action_lora_data.get('lora_weight', 1.0)}")
|
||||
|
||||
# Apply connections to all model/clip consumers
|
||||
workflow["3"]["inputs"]["model"] = model_source
|
||||
workflow["11"]["inputs"]["model"] = model_source
|
||||
@@ -1619,6 +1705,538 @@ def clone_outfit(slug):
|
||||
flash(f'Outfit cloned as "{new_id}"!')
|
||||
return redirect(url_for('outfit_detail', slug=new_slug))
|
||||
|
||||
def sync_actions():
|
||||
if not os.path.exists(app.config['ACTIONS_DIR']):
|
||||
return
|
||||
|
||||
current_ids = []
|
||||
|
||||
for filename in os.listdir(app.config['ACTIONS_DIR']):
|
||||
if filename.endswith('.json'):
|
||||
file_path = os.path.join(app.config['ACTIONS_DIR'], filename)
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
action_id = data.get('action_id') or filename.replace('.json', '')
|
||||
|
||||
current_ids.append(action_id)
|
||||
|
||||
# Generate URL-safe slug
|
||||
slug = re.sub(r'[^a-zA-Z0-9_]', '', action_id)
|
||||
|
||||
# Check if action already exists
|
||||
action = Action.query.filter_by(action_id=action_id).first()
|
||||
name = data.get('action_name', action_id.replace('_', ' ').title())
|
||||
|
||||
if action:
|
||||
action.data = data
|
||||
action.name = name
|
||||
action.slug = slug
|
||||
action.filename = filename
|
||||
|
||||
# Check if cover image still exists
|
||||
if action.image_path:
|
||||
full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], action.image_path)
|
||||
if not os.path.exists(full_img_path):
|
||||
print(f"Image missing for {action.name}, clearing path.")
|
||||
action.image_path = None
|
||||
|
||||
flag_modified(action, "data")
|
||||
else:
|
||||
new_action = Action(
|
||||
action_id=action_id,
|
||||
slug=slug,
|
||||
filename=filename,
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
db.session.add(new_action)
|
||||
except Exception as e:
|
||||
print(f"Error importing action {filename}: {e}")
|
||||
|
||||
# Remove actions that are no longer in the folder
|
||||
all_actions = Action.query.all()
|
||||
for action in all_actions:
|
||||
if action.action_id not in current_ids:
|
||||
db.session.delete(action)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# ============ ACTION ROUTES ============
|
||||
|
||||
@app.route('/actions')
|
||||
def actions_index():
|
||||
actions = Action.query.order_by(Action.name).all()
|
||||
return render_template('actions/index.html', actions=actions)
|
||||
|
||||
@app.route('/actions/rescan', methods=['POST'])
|
||||
def rescan_actions():
|
||||
sync_actions()
|
||||
flash('Database synced with action files.')
|
||||
return redirect(url_for('actions_index'))
|
||||
|
||||
@app.route('/action/<path:slug>')
|
||||
def action_detail(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
characters = Character.query.order_by(Character.name).all()
|
||||
|
||||
# Load state from session
|
||||
preferences = session.get(f'prefs_action_{slug}')
|
||||
preview_image = session.get(f'preview_action_{slug}')
|
||||
selected_character = session.get(f'char_action_{slug}')
|
||||
|
||||
return render_template('actions/detail.html', action=action, characters=characters,
|
||||
preferences=preferences, preview_image=preview_image,
|
||||
selected_character=selected_character)
|
||||
|
||||
@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()
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
# 1. Update basic fields
|
||||
action.name = request.form.get('action_name')
|
||||
|
||||
# 2. Rebuild the data dictionary
|
||||
new_data = action.data.copy()
|
||||
new_data['action_name'] = action.name
|
||||
|
||||
# Update action_id if provided
|
||||
new_action_id = request.form.get('action_id', action.action_id)
|
||||
new_data['action_id'] = new_action_id
|
||||
|
||||
# Update action section
|
||||
if 'action' in new_data:
|
||||
for key in new_data['action'].keys():
|
||||
form_key = f"action_{key}"
|
||||
if form_key in request.form:
|
||||
new_data['action'][key] = request.form.get(form_key)
|
||||
|
||||
# Update lora section
|
||||
if 'lora' in new_data:
|
||||
for key in new_data['lora'].keys():
|
||||
form_key = f"lora_{key}"
|
||||
if form_key in request.form:
|
||||
val = request.form.get(form_key)
|
||||
if key == 'lora_weight':
|
||||
try: val = float(val)
|
||||
except: val = 1.0
|
||||
new_data['lora'][key] = val
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
||||
|
||||
action.data = new_data
|
||||
flag_modified(action, "data")
|
||||
|
||||
# 3. Write back to JSON file
|
||||
action_file = action.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', action.action_id)}.json"
|
||||
file_path = os.path.join(app.config['ACTIONS_DIR'], action_file)
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
|
||||
db.session.commit()
|
||||
flash('Action profile updated successfully!')
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Edit error: {e}")
|
||||
flash(f"Error saving changes: {str(e)}")
|
||||
|
||||
return render_template('actions/edit.html', action=action, loras=loras)
|
||||
|
||||
@app.route('/action/<path:slug>/upload', methods=['POST'])
|
||||
def upload_action_image(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
|
||||
if 'image' not in request.files:
|
||||
flash('No file part')
|
||||
return redirect(request.url)
|
||||
|
||||
file = request.files['image']
|
||||
if file.filename == '':
|
||||
flash('No selected file')
|
||||
return redirect(request.url)
|
||||
|
||||
if file and allowed_file(file.filename):
|
||||
# Create action subfolder
|
||||
action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}")
|
||||
os.makedirs(action_folder, exist_ok=True)
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(action_folder, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Store relative path in DB
|
||||
action.image_path = f"actions/{slug}/{filename}"
|
||||
db.session.commit()
|
||||
flash('Image uploaded successfully!')
|
||||
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
@app.route('/action/<path:slug>/generate', methods=['POST'])
|
||||
def generate_action_image(slug):
|
||||
action_obj = Action.query.filter_by(slug=slug).first_or_404()
|
||||
|
||||
try:
|
||||
# Get action type
|
||||
action_type = request.form.get('action', 'preview')
|
||||
client_id = request.form.get('client_id')
|
||||
|
||||
# Get selected fields
|
||||
selected_fields = request.form.getlist('include_field')
|
||||
|
||||
# Get selected character (if any)
|
||||
character_slug = request.form.get('character_slug', '')
|
||||
character = None
|
||||
|
||||
# Handle random character selection
|
||||
if character_slug == '__random__':
|
||||
all_characters = Character.query.all()
|
||||
if all_characters:
|
||||
character = random.choice(all_characters)
|
||||
character_slug = character.slug
|
||||
elif character_slug:
|
||||
character = Character.query.filter_by(slug=character_slug).first()
|
||||
|
||||
# Save preferences
|
||||
session[f'char_action_{slug}'] = character_slug
|
||||
session[f'prefs_action_{slug}'] = selected_fields
|
||||
|
||||
# Build combined data for prompt building
|
||||
if character:
|
||||
# Combine character identity/wardrobe with action details
|
||||
# Action details replace character's 'defaults' (pose, etc.)
|
||||
combined_data = character.data.copy()
|
||||
|
||||
# Update 'defaults' with action details
|
||||
action_data = action_obj.data.get('action', {})
|
||||
combined_data['action'] = action_data # Ensure action section is present for routing
|
||||
|
||||
# Aggregate pose-related fields into 'pose'
|
||||
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet']
|
||||
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
|
||||
|
||||
# Aggregate expression-related fields into 'expression'
|
||||
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)]
|
||||
|
||||
combined_data['defaults'] = {
|
||||
'pose': ", ".join(pose_parts),
|
||||
'expression': ", ".join(expression_parts),
|
||||
'scene': action_data.get('additional', '')
|
||||
}
|
||||
|
||||
# Merge lora triggers if present
|
||||
action_lora = action_obj.data.get('lora', {})
|
||||
if action_lora.get('lora_triggers'):
|
||||
if 'lora' not in combined_data: combined_data['lora'] = {}
|
||||
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
|
||||
|
||||
# Merge tags
|
||||
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
|
||||
|
||||
# Use action's defaults if no manual selection
|
||||
if not selected_fields:
|
||||
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
|
||||
|
||||
# Auto-include essential character fields if a character is selected
|
||||
if selected_fields:
|
||||
# Add character identity fields to selection if not already present
|
||||
for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']:
|
||||
if character.data.get('identity', {}).get(key):
|
||||
field_key = f'identity::{key}'
|
||||
if field_key not in selected_fields:
|
||||
selected_fields.append(field_key)
|
||||
|
||||
# Always include character name
|
||||
if 'special::name' not in selected_fields:
|
||||
selected_fields.append('special::name')
|
||||
|
||||
# Add active wardrobe fields
|
||||
wardrobe = character.get_active_wardrobe()
|
||||
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']:
|
||||
if wardrobe.get(key):
|
||||
field_key = f'wardrobe::{key}'
|
||||
if field_key not in selected_fields:
|
||||
selected_fields.append(field_key)
|
||||
else:
|
||||
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
|
||||
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
|
||||
# Add identity fields
|
||||
for key in ['base_specs', 'hair', 'eyes']:
|
||||
if character.data.get('identity', {}).get(key):
|
||||
selected_fields.append(f'identity::{key}')
|
||||
# Add wardrobe fields
|
||||
wardrobe = character.get_active_wardrobe()
|
||||
for key in ['full_body', 'top', 'bottom']:
|
||||
if wardrobe.get(key):
|
||||
selected_fields.append(f'wardrobe::{key}')
|
||||
|
||||
default_fields = action_obj.default_fields
|
||||
active_outfit = character.active_outfit
|
||||
else:
|
||||
# Action only - no character (rarely makes sense for actions but let's handle it)
|
||||
action_data = action_obj.data.get('action', {})
|
||||
|
||||
# Aggregate pose-related fields into 'pose'
|
||||
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet']
|
||||
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
|
||||
|
||||
# Aggregate expression-related fields into 'expression'
|
||||
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)]
|
||||
|
||||
combined_data = {
|
||||
'character_id': action_obj.action_id,
|
||||
'defaults': {
|
||||
'pose': ", ".join(pose_parts),
|
||||
'expression': ", ".join(expression_parts),
|
||||
'scene': action_data.get('additional', '')
|
||||
},
|
||||
'lora': action_obj.data.get('lora', {}),
|
||||
'tags': action_obj.data.get('tags', [])
|
||||
}
|
||||
if not selected_fields:
|
||||
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags']
|
||||
default_fields = action_obj.default_fields
|
||||
active_outfit = 'default'
|
||||
|
||||
# Queue generation
|
||||
with open('comfy_workflow.json', 'r') as f:
|
||||
workflow = json.load(f)
|
||||
|
||||
# Build prompts for combined data
|
||||
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
|
||||
|
||||
# Add colored simple background to the main prompt for action previews
|
||||
if character:
|
||||
primary_color = character.data.get('styles', {}).get('primary_color', '')
|
||||
if primary_color:
|
||||
prompts["main"] = f"{prompts['main']}, {primary_color} simple background"
|
||||
else:
|
||||
prompts["main"] = f"{prompts['main']}, simple background"
|
||||
else:
|
||||
prompts["main"] = f"{prompts['main']}, simple background"
|
||||
|
||||
# Prepare workflow
|
||||
workflow = _prepare_workflow(workflow, character, prompts, action=action_obj)
|
||||
|
||||
prompt_response = queue_prompt(workflow, client_id=client_id)
|
||||
|
||||
if 'prompt_id' not in prompt_response:
|
||||
raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}")
|
||||
|
||||
prompt_id = prompt_response['prompt_id']
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'status': 'queued', 'prompt_id': prompt_id}
|
||||
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Generation error: {e}")
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': str(e)}, 500
|
||||
flash(f"Error during generation: {str(e)}")
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
@app.route('/action/<path:slug>/finalize_generation/<prompt_id>', methods=['POST'])
|
||||
def finalize_action_generation(slug, prompt_id):
|
||||
action_obj = Action.query.filter_by(slug=slug).first_or_404()
|
||||
|
||||
try:
|
||||
history = get_history(prompt_id)
|
||||
if prompt_id not in history:
|
||||
return {'error': 'History not found'}, 404
|
||||
|
||||
outputs = history[prompt_id]['outputs']
|
||||
for node_id in outputs:
|
||||
if 'images' in outputs[node_id]:
|
||||
image_info = outputs[node_id]['images'][0]
|
||||
image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type'])
|
||||
|
||||
# Create action subfolder
|
||||
action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}")
|
||||
os.makedirs(action_folder, exist_ok=True)
|
||||
|
||||
filename = f"gen_{int(time.time())}.png"
|
||||
file_path = os.path.join(action_folder, filename)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
# Always save as preview
|
||||
relative_path = f"actions/{slug}/{filename}"
|
||||
session[f'preview_action_{slug}'] = relative_path
|
||||
|
||||
return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')}
|
||||
|
||||
return {'error': 'No image found in output'}, 404
|
||||
except Exception as e:
|
||||
print(f"Finalize error: {e}")
|
||||
return {'error': str(e)}, 500
|
||||
|
||||
@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:
|
||||
action.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated from preview!')
|
||||
else:
|
||||
flash('No preview image available', 'error')
|
||||
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
@app.route('/action/<path:slug>/save_defaults', methods=['POST'])
|
||||
def save_action_defaults(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
selected_fields = request.form.getlist('include_field')
|
||||
action.default_fields = selected_fields
|
||||
db.session.commit()
|
||||
flash('Default prompt selection saved for this action!')
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
@app.route('/action/create', methods=['GET', 'POST'])
|
||||
def create_action():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
prompt = request.form.get('prompt', '')
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug)
|
||||
if not safe_slug:
|
||||
safe_slug = 'action'
|
||||
|
||||
base_slug = safe_slug
|
||||
counter = 1
|
||||
while os.path.exists(os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json")):
|
||||
safe_slug = f"{base_slug}_{counter}"
|
||||
counter += 1
|
||||
|
||||
if use_llm:
|
||||
if not prompt:
|
||||
flash("Description is required when AI generation is enabled.")
|
||||
return redirect(request.url)
|
||||
|
||||
system_prompt = """You are a JSON generator. output ONLY valid JSON matching this exact structure. Do not wrap in markdown blocks.
|
||||
Structure:
|
||||
{
|
||||
"action_id": "WILL_BE_REPLACED",
|
||||
"action_name": "WILL_BE_REPLACED",
|
||||
"action": {
|
||||
"full_body": "string (pose description)",
|
||||
"head": "string (expression/head position)",
|
||||
"eyes": "string",
|
||||
"arms": "string",
|
||||
"hands": "string",
|
||||
"torso": "string",
|
||||
"pelvis": "string",
|
||||
"legs": "string",
|
||||
"feet": "string",
|
||||
"additional": "string"
|
||||
},
|
||||
"lora": {
|
||||
"lora_name": "",
|
||||
"lora_weight": 1.0,
|
||||
"lora_triggers": ""
|
||||
},
|
||||
"tags": ["string", "string"]
|
||||
}"""
|
||||
|
||||
try:
|
||||
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
action_data = json.loads(clean_json)
|
||||
action_data['action_id'] = safe_slug
|
||||
action_data['action_name'] = name
|
||||
except Exception as e:
|
||||
print(f"LLM error: {e}")
|
||||
flash(f"Failed to generate action profile: {e}")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
action_data = {
|
||||
"action_id": safe_slug,
|
||||
"action_name": name,
|
||||
"action": {
|
||||
"full_body": "", "head": "", "eyes": "", "arms": "", "hands": "",
|
||||
"torso": "", "pelvis": "", "legs": "", "feet": "", "additional": ""
|
||||
},
|
||||
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
|
||||
"tags": []
|
||||
}
|
||||
|
||||
try:
|
||||
file_path = os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json")
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(action_data, f, indent=2)
|
||||
|
||||
new_action = Action(
|
||||
action_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json",
|
||||
name=name, data=action_data
|
||||
)
|
||||
db.session.add(new_action)
|
||||
db.session.commit()
|
||||
|
||||
flash('Action created successfully!')
|
||||
return redirect(url_for('action_detail', slug=safe_slug))
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create action: {e}")
|
||||
return redirect(request.url)
|
||||
|
||||
return render_template('actions/create.html')
|
||||
|
||||
@app.route('/action/<path:slug>/clone', methods=['POST'])
|
||||
def clone_action(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
|
||||
# Find the next available number for the clone
|
||||
base_id = action.action_id
|
||||
import re
|
||||
match = re.match(r'^(.+?)_(\d+)$', base_id)
|
||||
if match:
|
||||
base_name = match.group(1)
|
||||
current_num = int(match.group(2))
|
||||
else:
|
||||
base_name = base_id
|
||||
current_num = 1
|
||||
|
||||
next_num = current_num + 1
|
||||
while True:
|
||||
new_id = f"{base_name}_{next_num:02d}"
|
||||
new_filename = f"{new_id}.json"
|
||||
new_path = os.path.join(app.config['ACTIONS_DIR'], new_filename)
|
||||
if not os.path.exists(new_path):
|
||||
break
|
||||
next_num += 1
|
||||
|
||||
new_data = action.data.copy()
|
||||
new_data['action_id'] = new_id
|
||||
new_data['action_name'] = f"{action.name} (Copy)"
|
||||
|
||||
with open(new_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
|
||||
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
|
||||
new_action = Action(
|
||||
action_id=new_id, slug=new_slug, filename=new_filename,
|
||||
name=new_data['action_name'], data=new_data
|
||||
)
|
||||
db.session.add(new_action)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Action cloned as "{new_id}"!')
|
||||
return redirect(url_for('action_detail', slug=new_slug))
|
||||
|
||||
if __name__ == '__main__':
|
||||
with app.app_context():
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
@@ -1636,6 +2254,19 @@ if __name__ == '__main__':
|
||||
else:
|
||||
print(f"Migration note: {e}")
|
||||
|
||||
# Migration: Add default_fields column to action table if it doesn't exist
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
db.session.execute(text('ALTER TABLE action ADD COLUMN default_fields JSON'))
|
||||
db.session.commit()
|
||||
print("Added default_fields column to action table")
|
||||
except Exception as e:
|
||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
||||
print("default_fields column already exists in action table")
|
||||
else:
|
||||
print(f"Migration action note: {e}")
|
||||
|
||||
sync_characters()
|
||||
sync_outfits()
|
||||
sync_actions()
|
||||
app.run(debug=True, port=5000)
|
||||
|
||||
@@ -179,5 +179,15 @@
|
||||
"clip": ["16", 1]
|
||||
},
|
||||
"class_type": "LoraLoader"
|
||||
},
|
||||
"18": {
|
||||
"inputs": {
|
||||
"lora_name": "",
|
||||
"strength_model": 1.0,
|
||||
"strength_clip": 1.0,
|
||||
"model": ["17", 0],
|
||||
"clip": ["17", 1]
|
||||
},
|
||||
"class_type": "LoraLoader"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,24 @@
|
||||
"action_id": "belly_dancing",
|
||||
"action_name": "Belly Dancing",
|
||||
"action": {
|
||||
"full_body": "belly dancing",
|
||||
"full_body": "belly dancing, standing",
|
||||
"head": "",
|
||||
"eyes": "",
|
||||
"arms": "hands above head",
|
||||
"hands": "hands together",
|
||||
"hands": "palms together",
|
||||
"torso": "",
|
||||
"pelvis": "shaking hips",
|
||||
"pelvis": "swaying hips",
|
||||
"legs": "",
|
||||
"feet": "",
|
||||
"additional": ""
|
||||
}
|
||||
},
|
||||
"lora": {
|
||||
"lora_name": "",
|
||||
"lora_weight": 1.0,
|
||||
"lora_triggers": ""
|
||||
},
|
||||
"tags": [
|
||||
"belly dancing",
|
||||
"dance"
|
||||
]
|
||||
}
|
||||
13
models.py
13
models.py
@@ -47,6 +47,19 @@ class Outfit(db.Model):
|
||||
def __repr__(self):
|
||||
return f'<Outfit {self.outfit_id}>'
|
||||
|
||||
class Action(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
action_id = db.Column(db.String(100), unique=True, nullable=False)
|
||||
slug = db.Column(db.String(100), unique=True, nullable=False)
|
||||
filename = db.Column(db.String(255), nullable=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
data = db.Column(db.JSON, nullable=False)
|
||||
default_fields = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Action {self.action_id}>'
|
||||
|
||||
class Settings(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
openrouter_api_key = db.Column(db.String(255), nullable=True)
|
||||
|
||||
74
templates/actions/create.html
Normal file
74
templates/actions/create.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">Create New Action</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('create_action') }}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Action Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Belly Dancing" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. belly_dancing">
|
||||
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
||||
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="prompt-group">
|
||||
<label for="prompt" class="form-label">Description / Concept</label>
|
||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the action, pose, or movement. The AI will generate the full action profile based on this."></textarea>
|
||||
<div class="form-text">Required when AI generation is enabled.</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" id="ai-info">
|
||||
<i class="bi bi-info-circle"></i> The AI will generate a complete action profile with pose details and tags based on your description.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary d-none" id="manual-info">
|
||||
<i class="bi bi-info-circle"></i> A blank action profile will be created. You can edit it afterwards to add details.
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-success btn-lg" id="submit-btn">Create & Generate</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('use_llm').addEventListener('change', function() {
|
||||
const promptGroup = document.getElementById('prompt-group');
|
||||
const aiInfo = document.getElementById('ai-info');
|
||||
const manualInfo = document.getElementById('manual-info');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const promptInput = document.getElementById('prompt');
|
||||
|
||||
if (this.checked) {
|
||||
promptGroup.classList.remove('d-none');
|
||||
aiInfo.classList.remove('d-none');
|
||||
manualInfo.classList.add('d-none');
|
||||
submitBtn.textContent = 'Create & Generate';
|
||||
promptInput.required = true;
|
||||
} else {
|
||||
promptGroup.classList.add('d-none');
|
||||
aiInfo.classList.add('d-none');
|
||||
manualInfo.classList.remove('d-none');
|
||||
submitBtn.textContent = 'Create Action';
|
||||
promptInput.required = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
407
templates/actions/detail.html
Normal file
407
templates/actions/detail.html
Normal file
@@ -0,0 +1,407 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Image Modal -->
|
||||
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-transparent border-0">
|
||||
<div class="modal-body p-0 text-center">
|
||||
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro selection_checkbox(section, key, label, value) %}
|
||||
<input class="form-check-input me-1" type="checkbox" name="include_field" value="{{ section }}::{{ key }}"
|
||||
{% if preferences is not none %}
|
||||
{% if section + '::' + key in preferences %}checked{% endif %}
|
||||
{% elif action.default_fields is not none %}
|
||||
{% if section + '::' + key in action.default_fields %}checked{% endif %}
|
||||
{% else %}
|
||||
{% if value %}checked{% endif %}
|
||||
{% endif %}>
|
||||
{% endmacro %}
|
||||
|
||||
<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)">
|
||||
{% if action.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid">
|
||||
{% else %}
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('upload_action_image', slug=action.slug) }}" method="post" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="image" class="form-label">Update Image</label>
|
||||
<input class="form-control" type="file" id="image" name="image" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 mb-2">Upload</button>
|
||||
</form>
|
||||
|
||||
{# Character Selector #}
|
||||
<div class="mb-3">
|
||||
<label for="character_select" class="form-label">Preview with Character</label>
|
||||
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
|
||||
<option value="">-- No Character (Action Only) --</option>
|
||||
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
|
||||
{% for char in characters %}
|
||||
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Select a character to preview this action on their model.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
|
||||
<button type="submit" form="generate-form" formaction="{{ url_for('save_action_defaults', slug=action.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save as Default Selection</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="progress-container" class="mb-4 d-none">
|
||||
<label id="progress-label" class="form-label">Generating...</label>
|
||||
<div class="progress" role="progressbar" aria-label="Generation Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
|
||||
</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_action_cover_from_preview', slug=action.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>
|
||||
<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">
|
||||
<span>Tags</span>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
||||
{% if preferences is not none %}
|
||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
||||
{% elif action.default_fields is not none %}
|
||||
{% if 'special::tags' in action.default_fields %}checked{% endif %}
|
||||
{% else %}
|
||||
checked
|
||||
{% endif %}>
|
||||
<label class="form-check-label text-white small" for="includeTags">Include</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for tag in action.data.tags %}
|
||||
<span class="badge bg-secondary">{{ tag }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No tags</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ action.name }}</h1>
|
||||
<a href="{{ url_for('edit_action', slug=action.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
||||
<form action="{{ url_for('clone_action', slug=action.slug) }}" method="post" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Action</button>
|
||||
</form>
|
||||
</div>
|
||||
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
|
||||
</div>
|
||||
|
||||
<form id="generate-form" action="{{ url_for('generate_action_image', slug=action.slug) }}" method="post">
|
||||
{# Action details section #}
|
||||
{% set action_details = action.data.get('action', {}) %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<strong>Action Details</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
{% for key, value in action_details.items() %}
|
||||
<dt class="col-sm-4 text-capitalize">
|
||||
{{ selection_checkbox('action', key, key.replace('_', ' '), value) }}
|
||||
{{ key.replace('_', ' ') }}
|
||||
</dt>
|
||||
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Defaults (Pose/Expression Aggregates) #}
|
||||
<div class="card mb-4 border-info">
|
||||
<div class="card-header bg-info text-white">
|
||||
<strong>Prompt Aggregates (Character Overrides)</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-text mb-3">These fields will override character defaults when a character is selected.</div>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4 text-capitalize">
|
||||
{{ selection_checkbox('defaults', 'pose', 'Pose', True) }}
|
||||
Pose (Combined)
|
||||
</dt>
|
||||
<dd class="col-sm-8 text-muted small">Aggregated from Action Pose fields</dd>
|
||||
|
||||
<dt class="col-sm-4 text-capitalize">
|
||||
{{ selection_checkbox('defaults', 'expression', 'Expression', True) }}
|
||||
Expression (Combined)
|
||||
</dt>
|
||||
<dd class="col-sm-8 text-muted small">Aggregated from Action Expression fields</dd>
|
||||
|
||||
<dt class="col-sm-4 text-capitalize">
|
||||
{{ selection_checkbox('defaults', 'scene', 'Scene', action_details.get('additional')) }}
|
||||
Scene (Additional)
|
||||
</dt>
|
||||
<dd class="col-sm-8 small">{{ action_details.get('additional') if action_details.get('additional') else '--' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Character Identity/Wardrobe context when character is selected #}
|
||||
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included based on the character's default selection.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# LoRA section #}
|
||||
{% set lora = action.data.get('lora', {}) %}
|
||||
{% if lora %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<strong>LoRA</strong>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
|
||||
{% if preferences is not none %}
|
||||
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
|
||||
{% elif action.default_fields is not none %}
|
||||
{% if 'lora::lora_triggers' in action.default_fields %}checked{% endif %}
|
||||
{% else %}
|
||||
checked
|
||||
{% endif %}>
|
||||
<label class="form-check-label small" for="includeLora">Include Triggers</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
{% for key, value in lora.items() %}
|
||||
<dt class="col-sm-4 text-capitalize">{{ key.replace('_', ' ') }}</dt>
|
||||
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
// Generate a unique client ID
|
||||
const clientId = 'action_detail_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
|
||||
let currentPromptId = null;
|
||||
let currentAction = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (!currentPromptId) return;
|
||||
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let resolveCompletion = null;
|
||||
async function waitForCompletion(promptId) {
|
||||
return new Promise((resolve) => {
|
||||
const checkResolve = () => {
|
||||
clearInterval(pollInterval);
|
||||
resolve();
|
||||
};
|
||||
resolveCompletion = checkResolve;
|
||||
|
||||
// Fallback polling in case WebSocket is blocked (403)
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/check_status/${promptId}`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'finished') {
|
||||
console.log('Finished via Polling');
|
||||
checkResolve();
|
||||
}
|
||||
} catch (err) { console.error('Polling error:', err); }
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
// Only intercept generate actions
|
||||
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('client_id', clientId);
|
||||
|
||||
// UI Reset
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.textContent = '0%';
|
||||
progressLabel.textContent = 'Starting...';
|
||||
|
||||
try {
|
||||
const response = await fetch(form.getAttribute('action'), {
|
||||
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;
|
||||
}
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
progressLabel.textContent = 'Queued...';
|
||||
|
||||
// Wait for completion (WebSocket or Polling)
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
// Finalize
|
||||
finalizeGeneration(currentPromptId, currentAction);
|
||||
currentPromptId = null;
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Request failed');
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
async function finalizeGeneration(promptId, action) {
|
||||
progressLabel.textContent = 'Saving image...';
|
||||
const url = `/action/{{ action.slug }}/finalize_generation/${promptId}`;
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'preview'); // Always save as preview
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update preview image
|
||||
previewImg.src = data.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
|
||||
// Enable the replace cover button if it exists
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) {
|
||||
replaceBtn.disabled = false;
|
||||
// Check if there's a form to update
|
||||
const form = replaceBtn.closest('form');
|
||||
if (form) {
|
||||
form.action = `/action/{{ action.slug }}/replace_cover_from_preview`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert('Save failed: ' + data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Finalize request failed');
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Image modal function
|
||||
function showImage(src) {
|
||||
document.getElementById('modalImage').src = src;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
80
templates/actions/edit.html
Normal file
80
templates/actions/edit.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Edit Action: {{ action.name }}</h1>
|
||||
<a href="{{ url_for('action_detail', slug=action.slug) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('edit_action', slug=action.slug) }}" method="post" id="main-form">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<!-- Basic Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white">Basic Information</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="action_name" class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="action_name" name="action_name" value="{{ action.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="action_id" class="form-label">Action ID</label>
|
||||
<input type="text" class="form-control" id="action_id" name="action_id" value="{{ action.action_id }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ action.data.tags | join(', ') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LoRA -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">LoRA Settings</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<label for="lora_lora_name" class="form-label">LoRA Name</label>
|
||||
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
|
||||
<option value="">None</option>
|
||||
{% for lora in loras %}
|
||||
<option value="{{ lora }}" {% if action.data.lora and action.data.lora.lora_name == lora %}selected{% endif %}>{{ lora }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="lora_lora_weight" class="form-label">Weight</label>
|
||||
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="{{ action.data.lora.lora_weight if action.data.lora else 1.0 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="lora_lora_triggers" class="form-label">Triggers</label>
|
||||
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ action.data.lora.lora_triggers if action.data.lora else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Details Section -->
|
||||
{% set action_details = action.data.get('action', {}) %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light"><strong>Action Details</strong></div>
|
||||
<div class="card-body">
|
||||
{% for key, value in action_details.items() %}
|
||||
<div class="mb-3">
|
||||
<label for="action_{{ key }}" class="form-label text-capitalize">{{ key.replace('_', ' ') }}</label>
|
||||
<input type="text" class="form-control" id="action_{{ key }}" name="action_{{ key }}" value="{{ value }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('action_detail', slug=action.slug) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
templates/actions/index.html
Normal file
41
templates/actions/index.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Action Gallery</h2>
|
||||
<div class="d-flex">
|
||||
<a href="{{ url_for('create_action') }}" class="btn btn-success me-2">Create New Action</a>
|
||||
<form action="{{ url_for('rescan_actions') }}" method="post">
|
||||
<button type="submit" class="btn btn-outline-primary">Rescan Action Files</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
|
||||
{% for action in actions %}
|
||||
<div class="col" id="card-{{ action.slug }}">
|
||||
<div class="card h-100 character-card" onclick="window.location.href='/action/{{ action.slug }}'">
|
||||
<div class="img-container">
|
||||
{% if action.image_path %}
|
||||
<img id="img-{{ action.slug }}" src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}">
|
||||
<span id="no-img-{{ action.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ action.slug }}" src="" alt="{{ action.name }}" class="d-none">
|
||||
<span id="no-img-{{ action.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ action.name }}</h5>
|
||||
<p class="card-text small text-center text-muted">{{ action.data.tags | join(', ') }}</p>
|
||||
</div>
|
||||
{% if action.data.lora and action.data.lora.lora_name %}
|
||||
{% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||
<div class="card-footer text-center p-1">
|
||||
<small class="text-muted" title="{{ action.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,7 @@
|
||||
<div class="d-flex">
|
||||
<a href="/" class="btn btn-outline-light me-2">Characters</a>
|
||||
<a href="/outfits" class="btn btn-outline-light me-2">Outfits</a>
|
||||
<a href="/actions" class="btn btn-outline-light me-2">Actions</a>
|
||||
<a href="/create" class="btn btn-outline-success me-2">Create Character</a>
|
||||
<a href="/generator" class="btn btn-outline-light me-2">Generator</a>
|
||||
<a href="/settings" class="btn btn-outline-light">Settings</a>
|
||||
|
||||
Reference in New Issue
Block a user