4 Commits

Author SHA1 Message Date
Aodhan Collins
1b8a798c31 Add graceful fallback for MCP import in test script
- Add try/except block for MCP package import
- Provide helpful error message when MCP is not installed
- Exit gracefully with status code 1 on import failure

Addresses code review feedback.
2026-03-07 21:13:32 +00:00
Aodhan Collins
d95b81dde5 Multiple bug fixes. 2026-03-06 19:28:50 +00:00
Aodhan Collins
ec08eb5d31 Add Preset Library feature
Presets are saved generation recipes that combine all resource types
(character, outfit, action, style, scene, detailer, look, checkpoint)
with per-field on/off/random toggles. At generation time, entities
marked "random" are picked from the DB and fields marked "random" are
randomly included or excluded.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:49:24 +00:00
Aodhan Collins
2c1c3a7ed7 Merge branch 'presets'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:09:00 +00:00
31 changed files with 2279 additions and 132 deletions

View File

@@ -298,10 +298,10 @@ All generation routes use the background job queue. Frontend polls:
Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes. Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes.
### Utilities ### Utilities
- `POST /set_default_checkpoint` — save default checkpoint to session - `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json`
- `GET /get_missing_{characters,outfits,actions,scenes}` — AJAX: list items without cover images - `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name)
- `POST /generate_missing` — batch generate covers for all characters missing one (uses job queue) - `POST /generate_missing` — batch generate covers for all characters missing one (uses job queue)
- `POST /clear_all_covers` / `clear_all_{outfit,action,scene}_covers` - `POST /clear_all_covers` / `clear_all_{outfit,action,scene,style,detailer,look,checkpoint}_covers`
- `GET /gallery` — global image gallery browsing `static/uploads/` - `GET /gallery` — global image gallery browsing `static/uploads/`
- `GET/POST /settings` — LLM provider configuration - `GET/POST /settings` — LLM provider configuration
- `POST /resource/<category>/<slug>/delete` — soft (JSON only) or hard (JSON + safetensors) delete - `POST /resource/<category>/<slug>/delete` — soft (JSON only) or hard (JSON + safetensors) delete
@@ -321,6 +321,11 @@ Image retrieval is handled server-side by the `_make_finalize()` callback; there
- Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template. - Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template.
- **No `{% block head %}` exists** in layout.html — do not try to use it. - **No `{% block head %}` exists** in layout.html — do not try to use it.
- Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue/<job_id>/status` every ~1.5 seconds until `status == "done"`. The server-side worker handles all ComfyUI polling and image saving via the `_make_finalize()` callback. There are no client-facing finalize HTTP routes. - Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue/<job_id>/status` every ~1.5 seconds until `status == "done"`. The server-side worker handles all ComfyUI polling and image saving via the `_make_finalize()` callback. There are no client-facing finalize HTTP routes.
- **Batch generation** (library pages): Uses a two-phase pattern:
1. **Queue phase**: All jobs are submitted upfront via sequential fetch calls, collecting job IDs
2. **Poll phase**: All jobs are polled concurrently via `Promise.all()`, updating UI as each completes
3. **Progress tracking**: Displays currently processing items in real-time using a `Set` to track active jobs
4. **Sorting**: All batch operations sort items by display `name` (not `filename`) for better UX
--- ---

972
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
}, },
"4": { "4": {
"inputs": { "inputs": {
"ckpt_name": "Noob/oneObsession_v19Atypical.safetensors" "ckpt_name": ""
}, },
"class_type": "CheckpointLoaderSimple" "class_type": "CheckpointLoaderSimple"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"character_id": "delinquent_mother_flim13", "character_id": "delinquent_mother_flim13",
"character_name": "Delinquent Mother", "character_name": "Gyaru Mother",
"identity": { "identity": {
"base_specs": "1girl, milf, gyaru, tall", "base_specs": "1girl, milf, gyaru, tall",
"hair": "blonde hair, long hair", "hair": "blonde hair, long hair",
@@ -47,4 +47,4 @@
"Original", "Original",
"flim13" "flim13"
] ]
} }

View File

@@ -22,7 +22,7 @@
"default": { "default": {
"full_body": "", "full_body": "",
"headwear": "", "headwear": "",
"top": "black crop top, blue and silver motorcycle jacket", "top": "black crop top, blue and silver jacket",
"bottom": "black leather pants", "bottom": "black leather pants",
"legwear": "", "legwear": "",
"footwear": "blue sneakers", "footwear": "blue sneakers",
@@ -49,4 +49,4 @@
"KDA", "KDA",
"K-Pop" "K-Pop"
] ]
} }

View File

@@ -1,11 +1,11 @@
{ {
"checkpoint_path": "Illustrious/zukiNewCuteILL_newV20.safetensors",
"checkpoint_name": "zukiNewCuteILL_newV20.safetensors",
"base_positive": "anime",
"base_negative": "text, logo", "base_negative": "text, logo",
"steps": 25, "base_positive": "anime, cute, loli, moe",
"cfg": 5, "cfg": 5,
"checkpoint_name": "zukiNewCuteILL_newV20.safetensors",
"checkpoint_path": "Illustrious/zukiNewCuteILL_newV20.safetensors",
"sampler_name": "euler_ancestral",
"scheduler": "normal", "scheduler": "normal",
"sampler_name": "euler_ancestral", "steps": 25,
"vae": "integrated" "vae": "integrated"
} }

View File

@@ -1,23 +1,13 @@
{ {
"outfit_id": "golddripnunchaindresslingerieill",
"outfit_name": "Golddripnunchaindresslingerieill",
"wardrobe": {
"full_body": "revealing nun dress with gold drip accents",
"headwear": "nun veil, jewelry",
"top": "lingerie top, gold chains",
"bottom": "skirt, gold trim",
"legwear": "thighhighs, garter straps",
"footwear": "heels",
"hands": "",
"accessories": "gold chains, cross necklace, body chain"
},
"lora": { "lora": {
"lora_name": "Illustrious/Clothing/GoldDripNunChainDressLingerieILL.safetensors", "lora_name": "",
"lora_triggers": "",
"lora_weight": 0.8, "lora_weight": 0.8,
"lora_triggers": "GoldDripNunChainDressLingerieILL", "lora_weight_max": 0.8,
"lora_weight_min": 0.8, "lora_weight_min": 0.8
"lora_weight_max": 0.8
}, },
"outfit_id": "golddripnunchaindresslingerieill",
"outfit_name": "Nun (with Gold)",
"tags": [ "tags": [
"nun", "nun",
"veil", "veil",
@@ -33,5 +23,15 @@
"dripping", "dripping",
"gold", "gold",
"body_chain" "body_chain"
] ],
} "wardrobe": {
"accessories": "gold chains, cross necklace, body chain",
"bottom": "skirt, gold trim",
"footwear": "heels",
"full_body": "revealing nun dress with gold drip accents",
"hands": "",
"headwear": "nun veil, jewelry",
"legwear": "thighhighs, garter straps",
"top": "lingerie top, gold chains"
}
}

View File

@@ -1,9 +1,7 @@
{ {
"character_id": null,
"look_id": "jn_tron_bonne_illus", "look_id": "jn_tron_bonne_illus",
"look_name": "Jn Tron Bonne Illus", "look_name": "Jn Tron Bonne Illus",
"character_id": "",
"positive": "tron_bonne_(mega_man), brown_hair, short_hair, spiked_hair, goggles_on_head, pink_jacket, crop_top, midriff, navel, skull_print, pink_shorts, boots, large_earrings, servbot_(mega_man)",
"negative": "pubic hair, 3d, realistic, loli, censored, bad anatomy, sketch, monochrome",
"lora": { "lora": {
"lora_name": "Illustrious/Looks/JN_Tron_Bonne_Illus.safetensors", "lora_name": "Illustrious/Looks/JN_Tron_Bonne_Illus.safetensors",
"lora_weight": 0.8, "lora_weight": 0.8,
@@ -11,6 +9,8 @@
"lora_weight_min": 0.8, "lora_weight_min": 0.8,
"lora_weight_max": 0.8 "lora_weight_max": 0.8
}, },
"negative": "pubic hair, 3d, realistic, loli, censored, bad anatomy, sketch, monochrome",
"positive": "tron_bonne_(mega_man), brown_hair, short_hair, spiked_hair, purple cropped jacket, pantyhose, metal panties, short pink dress, boots, skull_earrings, servbot_(mega_man)",
"tags": [ "tags": [
"tron_bonne_(mega_man)", "tron_bonne_(mega_man)",
"goggles_on_head", "goggles_on_head",
@@ -26,4 +26,4 @@
"brown_hair", "brown_hair",
"short_hair" "short_hair"
] ]
} }

View File

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

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

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

View File

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

View File

@@ -2,8 +2,8 @@
"style_id": "7b_style", "style_id": "7b_style",
"style_name": "7B Dream", "style_name": "7B Dream",
"style": { "style": {
"artist_name": "7b_Dream", "artist_name": "7b",
"artistic_style": "3d" "artistic_style": "3d, blender, semi-realistic"
}, },
"lora": { "lora": {
"lora_name": "Illustrious/Styles/7b-style.safetensors", "lora_name": "Illustrious/Styles/7b-style.safetensors",
@@ -12,4 +12,4 @@
"lora_weight_min": 1.0, "lora_weight_min": 1.0,
"lora_weight_max": 1.0 "lora_weight_max": 1.0
} }
} }

View File

@@ -1,15 +0,0 @@
{
"style_id": "bckiwi_3d_style_il_2_7_rank16_fp16",
"style_name": "Bckiwi 3D Style Il 2 7 Rank16 Fp16",
"style": {
"artist_name": "",
"artistic_style": ""
},
"lora": {
"lora_name": "Illustrious/Styles/BCkiwi_3D_style_IL_2.7_rank16_fp16.safetensors",
"lora_weight": 1.0,
"lora_triggers": "BCkiwi_3D_style_IL_2.7_rank16_fp16",
"lora_weight_min": 1.0,
"lora_weight_max": 1.0
}
}

View File

@@ -13,6 +13,9 @@ services:
# ComfyUI runs on the Docker host # ComfyUI runs on the Docker host
COMFYUI_URL: http://10.0.0.200:8188 # Compose manages danbooru-mcp — skip the app's auto-start logic COMFYUI_URL: http://10.0.0.200:8188 # Compose manages danbooru-mcp — skip the app's auto-start logic
SKIP_MCP_AUTOSTART: "true" SKIP_MCP_AUTOSTART: "true"
# Enable debug logging
FLASK_DEBUG: "1"
LOG_LEVEL: "DEBUG"
volumes: volumes:
# Persistent data # Persistent data
- ./data:/app/data - ./data:/app/data

0
launch.sh Normal file → Executable file
View File

View File

@@ -125,6 +125,19 @@ class Checkpoint(db.Model):
def __repr__(self): def __repr__(self):
return f'<Checkpoint {self.checkpoint_id}>' return f'<Checkpoint {self.checkpoint_id}>'
class Preset(db.Model):
id = db.Column(db.Integer, primary_key=True)
preset_id = db.Column(db.String(100), unique=True, nullable=False)
slug = db.Column(db.String(100), unique=True, nullable=False)
filename = db.Column(db.String(255), nullable=True)
name = db.Column(db.String(100), nullable=False)
data = db.Column(db.JSON, nullable=False)
image_path = db.Column(db.String(255), nullable=True)
def __repr__(self):
return f'<Preset {self.preset_id}>'
class Settings(db.Model): class Settings(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
llm_provider = db.Column(db.String(50), default='openrouter') # 'openrouter', 'ollama', 'lmstudio' llm_provider = db.Column(db.String(50), default='openrouter') # 'openrouter', 'ollama', 'lmstudio'
@@ -141,6 +154,8 @@ class Settings(db.Model):
lora_dir_detailers = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Detailers') lora_dir_detailers = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Detailers')
# Checkpoint scan directories (comma-separated list of absolute paths) # 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') checkpoint_dirs = db.Column(db.String(1000), default='/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob')
# Default checkpoint path (persisted across server restarts)
default_checkpoint = db.Column(db.String(500), nullable=True)
def __repr__(self): def __repr__(self):
return '<Settings>' return '<Settings>'

View File

@@ -356,6 +356,34 @@ h5, h6 { color: var(--text); }
object-fit: cover; object-fit: cover;
} }
/* Assignment badge — shows count of characters using this resource */
.assignment-badge {
position: absolute;
top: 8px;
right: 8px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
min-width: 22px;
height: 22px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
line-height: 1;
z-index: 2;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
border: 1.5px solid rgba(255, 255, 255, 0.15);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.character-card:hover .assignment-badge {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(139, 126, 255, 0.5);
}
/* Generator result container */ /* Generator result container */
#result-container { #result-container {
background-color: var(--bg-raised) !important; background-color: var(--bg-raised) !important;

View File

@@ -160,7 +160,10 @@
statusText.textContent = `0 / ${jobs.length} done`; statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0; let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => { await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
itemNameText.textContent = `Processing: ${currentItem}`;
try { try {
const jobResult = await waitForJob(jobId); const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {

View File

@@ -115,53 +115,68 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
for (const ckpt of missing) { progressBar.style.width = '100%';
const percent = Math.round((completed / missing.length) * 100); progressBar.textContent = '';
progressBar.style.width = `${percent}%`; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = `${percent}%`; nodeStatus.textContent = 'Queuing…';
statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`;
ckptNameText.textContent = `Current: ${ckpt.name}`;
nodeStatus.textContent = 'Queuing…';
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = '';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
const jobs = [];
for (const ckpt of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try { try {
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, { const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ 'character_slug': '__random__' }), body: new URLSearchParams({ character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentJobId = genData.job_id; if (genData.job_id) jobs.push({ item: ckpt, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${ckpt.name}:`, err);
}
}
const jobResult = await waitForJob(currentJobId); // Phase 2: Poll all concurrently
currentJobId = null; progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
ckptNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${ckpt.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${ckpt.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); } if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${ckpt.name}:`, err); console.error(`Failed for ${item.name}:`, err);
currentJobId = null;
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = 'Batch Generation Complete!'; statusText.textContent = 'Batch Checkpoint Generation Complete!';
ckptNameText.textContent = ''; ckptNameText.textContent = '';
nodeStatus.textContent = 'Done'; nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -162,7 +162,10 @@
statusText.textContent = `0 / ${jobs.length} done`; statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0; let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => { await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
detailerNameText.textContent = `Processing: ${currentItem}`;
try { try {
const jobResult = await waitForJob(jobId); const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {

View File

@@ -161,7 +161,10 @@
statusText.textContent = `0 / ${jobs.length} done`; statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0; let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => { await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
charNameText.textContent = `Processing: ${currentItem}`;
try { try {
const jobResult = await waitForJob(jobId); const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {

View File

@@ -23,6 +23,7 @@
<a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a> <a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a>
<a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a> <a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a>
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a> <a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a> <a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a> <a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
@@ -44,6 +45,10 @@
<span class="status-dot status-checking"></span> <span class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">MCP</span> <span class="status-label d-none d-xl-inline">MCP</span>
</span> </span>
<span id="status-llm" class="service-status" title="LLM" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="LLM: checking…">
<span class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">LLM</span>
</span>
</div> </div>
</div> </div>
</nav> </nav>
@@ -342,6 +347,7 @@
const services = [ const services = [
{ id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' }, { id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' },
{ id: 'status-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' }, { id: 'status-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' },
{ id: 'status-llm', url: '/api/status/llm', label: 'LLM' },
]; ];
function setStatus(id, label, ok) { function setStatus(id, label, ok) {

View File

@@ -59,6 +59,9 @@
<img id="img-{{ look.slug }}" src="" alt="{{ look.name }}" class="d-none"> <img id="img-{{ look.slug }}" src="" alt="{{ look.name }}" class="d-none">
<span id="no-img-{{ look.slug }}" class="text-muted">No Image</span> <span id="no-img-{{ look.slug }}" class="text-muted">No Image</span>
{% endif %} {% endif %}
{% if look_assignments.get(look.look_id, 0) > 0 %}
<span class="assignment-badge" title="Assigned to {{ look_assignments.get(look.look_id, 0) }} character(s)">{{ look_assignments.get(look.look_id, 0) }}</span>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ look.name }}</h5> <h5 class="card-title text-center">{{ look.name }}</h5>
@@ -106,6 +109,22 @@
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
let currentJobId = null; let currentJobId = null;
let queuePollInterval = null;
async function updateCurrentJobLabel() {
try {
const resp = await fetch('/api/queue');
const data = await resp.json();
const processingJob = data.jobs.find(j => j.status === 'processing');
if (processingJob) {
itemNameText.textContent = `Processing: ${processingJob.label}`;
} else {
itemNameText.textContent = '';
}
} catch (err) {
console.error('Failed to fetch queue:', err);
}
}
async function waitForJob(jobId) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -136,30 +155,42 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
for (const item of missing) { progressBar.style.width = '100%';
const percent = Math.round((completed / missing.length) * 100); progressBar.textContent = '';
progressBar.style.width = `${percent}%`; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = `${percent}%`; nodeStatus.textContent = 'Queuing…';
statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`;
itemNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing…";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = '';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
const jobs = [];
for (const item of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try { try {
const genResp = await fetch(`/look/${item.slug}/generate`, { const genResp = await fetch(`/look/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ 'action': 'replace' }), body: new URLSearchParams({ action: 'replace' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentJobId = genData.job_id; if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
const jobResult = await waitForJob(currentJobId); // Phase 2: Poll all concurrently
currentJobId = null; progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
// Start polling queue for current job label
queuePollInterval = setInterval(updateCurrentJobLabel, 1000);
updateCurrentJobLabel(); // Initial update
let completed = 0;
await Promise.all(jobs.map(async ({ item, jobId }) => {
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
@@ -168,22 +199,31 @@
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
currentJobId = null;
} }
completed++; completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
// Stop polling queue
if (queuePollInterval) {
clearInterval(queuePollInterval);
queuePollInterval = null;
} }
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Look Generation Complete!"; statusText.textContent = 'Batch Look Generation Complete!';
itemNameText.textContent = ""; itemNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -59,6 +59,9 @@
<img id="img-{{ outfit.slug }}" src="" alt="{{ outfit.name }}" class="d-none"> <img id="img-{{ outfit.slug }}" src="" alt="{{ outfit.name }}" class="d-none">
<span id="no-img-{{ outfit.slug }}" class="text-muted">No Image</span> <span id="no-img-{{ outfit.slug }}" class="text-muted">No Image</span>
{% endif %} {% endif %}
{% if outfit.data.lora and outfit.data.lora.lora_name and lora_assignments.get(outfit.data.lora.lora_name, 0) > 0 %}
<span class="assignment-badge" title="Assigned to {{ lora_assignments.get(outfit.data.lora.lora_name, 0) }} character(s)">{{ lora_assignments.get(outfit.data.lora.lora_name, 0) }}</span>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ outfit.name }}</h5> <h5 class="card-title text-center">{{ outfit.name }}</h5>
@@ -160,7 +163,10 @@
statusText.textContent = `0 / ${jobs.length} done`; statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0; let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => { await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
itemNameText.textContent = `Processing: ${currentItem}`;
try { try {
const jobResult = await waitForJob(jobId); const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {

View File

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

View File

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

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

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

View File

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

View File

@@ -160,7 +160,10 @@
statusText.textContent = `0 / ${jobs.length} done`; statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0; let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => { await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
itemNameText.textContent = `Processing: ${currentItem}`;
try { try {
const jobResult = await waitForJob(jobId); const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {

View File

@@ -160,7 +160,10 @@
statusText.textContent = `0 / ${jobs.length} done`; statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0; let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => { await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
styleNameText.textContent = `Processing: ${currentItem}`;
try { try {
const jobResult = await waitForJob(jobId); const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) { if (jobResult.result && jobResult.result.image_url) {

187
test_character_mcp.py Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
Test script for the Character Details MCP server.
Tests all available tools to verify functionality.
"""
import asyncio
import json
import sys
# MCP dependency with graceful fallback
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
print("=" * 80)
print("ERROR: MCP package is not installed.")
print("Install it with: pip install mcp")
print("=" * 80)
sys.exit(1)
async def test_character_mcp():
"""Test the Character Details MCP server tools."""
# Server parameters - using uv to run the character-details server
server_params = StdioServerParameters(
command="uv",
args=[
"run",
"--directory",
"tools/character-mcp",
"character-details"
],
env=None
)
print("=" * 80)
print("Testing Character Details MCP Server")
print("=" * 80)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize the session
await session.initialize()
# List available tools
print("\n📋 Available Tools:")
print("-" * 80)
tools = await session.list_tools()
for tool in tools.tools:
print(f"{tool.name}: {tool.description}")
# Test 1: Get character details for Aerith Gainsborough
print("\n\n🔍 Test 1: Getting character details for Aerith Gainsborough")
print("-" * 80)
try:
result = await session.call_tool(
"get_character",
arguments={
"name": "Aerith Gainsborough",
"franchise": "Final Fantasy VII"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
# Print first 500 chars to see structure
text = content.text
print(f"\nReceived {len(text)} characters of data")
print("\nFirst 500 characters:")
print(text[:500])
print("...")
except Exception as e:
print(f"❌ Error: {e}")
# Test 2: List cached characters
print("\n\n📚 Test 2: Listing cached characters")
print("-" * 80)
try:
result = await session.call_tool("list_characters", arguments={})
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
print(content.text)
except Exception as e:
print(f"❌ Error: {e}")
# Test 3: Generate image prompt
print("\n\n🎨 Test 3: Generating image prompt for Aerith")
print("-" * 80)
try:
result = await session.call_tool(
"generate_image_prompt",
arguments={
"name": "Aerith Gainsborough",
"franchise": "Final Fantasy VII",
"style": "anime",
"scene": "tending flowers in the church",
"extra_tags": "soft lighting, peaceful atmosphere"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
print("\nGenerated prompt:")
print(content.text)
except Exception as e:
print(f"❌ Error: {e}")
# Test 4: Generate story context
print("\n\n📖 Test 4: Generating story context for Aerith")
print("-" * 80)
try:
result = await session.call_tool(
"generate_story_context",
arguments={
"name": "Aerith Gainsborough",
"franchise": "Final Fantasy VII",
"scenario": "Meeting Cloud for the first time in the Sector 5 church",
"include_abilities": True
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
text = content.text
print(f"\nReceived {len(text)} characters of story context")
print("\nFirst 800 characters:")
print(text[:800])
print("...")
except Exception as e:
print(f"❌ Error: {e}")
# Test 5: Try a different character - Princess Peach
print("\n\n👑 Test 5: Getting character details for Princess Peach")
print("-" * 80)
try:
result = await session.call_tool(
"get_character",
arguments={
"name": "Princess Peach",
"franchise": "Super Mario"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
text = content.text
print(f"\nReceived {len(text)} characters of data")
print("\nFirst 500 characters:")
print(text[:500])
print("...")
except Exception as e:
print(f"❌ Error: {e}")
# Test 6: Generate image prompt for Princess Peach
print("\n\n🎨 Test 6: Generating image prompt for Princess Peach")
print("-" * 80)
try:
result = await session.call_tool(
"generate_image_prompt",
arguments={
"name": "Princess Peach",
"franchise": "Super Mario",
"style": "anime",
"scene": "in her castle throne room",
"extra_tags": "elegant, royal, pink dress"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
print("\nGenerated prompt:")
print(content.text)
except Exception as e:
print(f"❌ Error: {e}")
print("\n" + "=" * 80)
print("Testing Complete!")
print("=" * 80)
if __name__ == "__main__":
asyncio.run(test_character_mcp())