Files

901 lines
31 KiB
Python

#!/usr/bin/env python3
"""
API endpoints for Preset Manager
"""
import json
import os
import base64
import folder_paths
from aiohttp import web
from PIL import Image
from io import BytesIO
import server
from . import common
# Get the config path
CONFIG_DIR = common.CONFIG_DIR
CHARACTERS_FILE = CONFIG_DIR / "characters.jsonc"
WILDCARDS_FILE = CONFIG_DIR / "wildcards.jsonc"
IMAGES_DIR = CONFIG_DIR / "character_images"
STYLE_IMAGES_DIR = CONFIG_DIR / "style_images"
STYLE_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
print(f"[Preset Manager] Config dir: {CONFIG_DIR}")
print(f"[Preset Manager] Characters file: {CHARACTERS_FILE}")
print(f"[Preset Manager] Images dir: {IMAGES_DIR}")
print(f"[Preset Manager] File exists: {CHARACTERS_FILE.exists()}")
# Ensure images directory exists
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
def load_characters():
"""Load characters from JSONC file"""
if not CHARACTERS_FILE.exists():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CHARACTERS_FILE.write_text("{}")
return {}
content = CHARACTERS_FILE.read_text(encoding='utf-8')
clean_content = common.strip_jsonc_comments(content)
return json.loads(clean_content)
def save_characters(characters):
"""Save characters to JSONC file"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
content = json.dumps(characters, indent=4, ensure_ascii=False)
CHARACTERS_FILE.write_text(content, encoding='utf-8')
@server.PromptServer.instance.routes.get('/character_editor')
async def get_characters(request):
"""Get all characters"""
try:
characters = load_characters()
return web.json_response(characters)
except Exception as e:
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.post('/character_editor')
async def update_characters(request):
"""Update characters"""
try:
data = await request.json()
save_characters(data)
return web.json_response({"success": True})
except Exception as e:
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.delete('/character_editor/{name}')
async def delete_character(request):
"""Delete a character"""
try:
name = request.match_info['name']
characters = load_characters()
if name in characters:
del characters[name]
save_characters(characters)
# Delete image if it exists
image_path = common.get_image_path(IMAGES_DIR, name)
if image_path.exists():
image_path.unlink()
return web.json_response({"success": True})
else:
return web.json_response(
{"error": "Character not found"},
status=404
)
except Exception as e:
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.post('/character_editor/rename')
async def rename_character(request):
"""Rename a character and its image"""
try:
data = await request.json()
old_name = data.get('oldName')
new_name = data.get('newName')
char_data = data.get('data')
if not old_name or not new_name:
return web.json_response(
{"error": "Missing old or new name"},
status=400
)
# Load current characters
characters = load_characters()
# Check if old name exists
if old_name not in characters:
return web.json_response(
{"error": "Character not found"},
status=404
)
# Check if new name already exists
if new_name in characters and new_name != old_name:
return web.json_response(
{"error": "Character with new name already exists"},
status=400
)
# Update character data
del characters[old_name]
characters[new_name] = char_data
save_characters(characters)
# Rename image if it exists
old_image_path = common.get_image_path(IMAGES_DIR, old_name)
if old_image_path.exists():
new_image_path = common.get_image_path(IMAGES_DIR, new_name)
old_image_path.rename(new_image_path)
print(f"Renamed image from {old_image_path} to {new_image_path}")
return web.json_response({"success": True})
except Exception as e:
print(f"Error renaming character: {e}")
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.get(
'/character_editor/image/{name}'
)
async def get_character_image(request):
"""Get character image"""
try:
name = common.decode_name(request.match_info['name'])
# print(f"Getting image for character: {repr(name)}")
image_path = common.get_image_path(IMAGES_DIR, name)
# print(f"Image path: {image_path}")
# print(f"File exists: {image_path.exists()}")
if not image_path.exists():
return web.Response(status=404)
return web.FileResponse(image_path)
except Exception as e:
print(f"Error getting image: {e}")
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.post(
'/character_editor/image/{name}'
)
async def upload_character_image(request):
"""Upload character image"""
try:
name = common.decode_name(request.match_info['name'])
print(f"Uploading image for character: {repr(name)}")
data = await request.json()
# Extract base64 image data
image_data = data.get('image', '')
if image_data.startswith('data:image'):
image_data = image_data.split(',', 1)[1]
# Decode and process image
image_bytes = base64.b64decode(image_data)
img = Image.open(BytesIO(image_bytes))
# Resize to 256x256 with center crop
size = 256
img = img.convert('RGB')
# Calculate crop dimensions
width, height = img.size
if width > height:
left = (width - height) / 2
img = img.crop((left, 0, left + height, height))
else:
top = (height - width) / 2
img = img.crop((0, top, width, top + width))
# Resize
img = img.resize((size, size), Image.Resampling.LANCZOS)
# Save as JPEG
image_path = common.get_image_path(IMAGES_DIR, name)
print(f"Saving image to: {image_path}")
img.save(image_path, 'JPEG', quality=85, optimize=True)
return web.json_response({"success": True})
except Exception as e:
print(f"Error uploading image: {e}")
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.delete(
'/character_editor/image/{name}'
)
async def delete_character_image(request):
"""Delete character image"""
try:
name = common.decode_name(request.match_info['name'])
print(f"Deleting image for character: {repr(name)}")
image_path = common.get_image_path(IMAGES_DIR, name)
if image_path.exists():
image_path.unlink()
return web.json_response({"success": True})
else:
return web.Response(status=404)
except Exception as e:
print(f"Error deleting image: {e}")
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.get('/style_editor/image/{name}')
async def get_style_image(request):
"""Get style image"""
try:
name = common.decode_name(request.match_info['name'])
image_path = common.get_image_path(STYLE_IMAGES_DIR, name)
if not image_path.exists():
return web.Response(status=404)
return web.FileResponse(image_path)
except Exception as e:
print(f"Error getting style image: {e}")
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.post('/style_editor/image/{name}')
async def upload_style_image(request):
"""Upload style image"""
try:
name = common.decode_name(request.match_info['name'])
print(f"Uploading image for style: {repr(name)}")
data = await request.json()
# Extract base64 image data
image_data = data.get('image', '')
if image_data.startswith('data:image'):
image_data = image_data.split(',', 1)[1]
# Decode and process image
image_bytes = base64.b64decode(image_data)
img = Image.open(BytesIO(image_bytes))
# Resize to 256x256 with center crop
size = 256
img = img.convert('RGB')
# Calculate crop dimensions
width, height = img.size
if width > height:
left = (width - height) / 2
img = img.crop((left, 0, left + height, height))
else:
top = (height - width) / 2
img = img.crop((0, top, width, top + width))
# Resize
img = img.resize((size, size), Image.Resampling.LANCZOS)
# Save as JPEG
image_path = common.get_image_path(STYLE_IMAGES_DIR, name)
print(f"Saving style image to: {image_path}")
img.save(image_path, 'JPEG', quality=85, optimize=True)
return web.json_response({"success": True})
except Exception as e:
print(f"Error uploading style image: {e}")
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.delete('/style_editor/image/{name}')
async def delete_style_image(request):
"""Delete style image"""
try:
name = common.decode_name(request.match_info['name'])
print(f"Deleting image for style: {repr(name)}")
image_path = common.get_image_path(STYLE_IMAGES_DIR, name)
if image_path.exists():
image_path.unlink()
return web.json_response({"success": True})
else:
return web.Response(status=404)
except Exception as e:
print(f"Error deleting style image: {e}")
return web.json_response({"error": str(e)}, status=500)
# Model editor endpoints
@server.PromptServer.instance.routes.get('/model_editor')
async def get_models(request):
"""Get all models"""
try:
models_file = CONFIG_DIR / "models.jsonc"
if not models_file.exists():
return web.json_response({})
content = models_file.read_text(encoding='utf-8')
clean_content = common.strip_jsonc_comments(content)
models = json.loads(clean_content)
return web.json_response(models)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.post('/model_editor')
async def update_models(request):
"""Update models"""
try:
data = await request.json()
models_file = CONFIG_DIR / "models.jsonc"
content = json.dumps(data, indent=4, ensure_ascii=False)
models_file.write_text(content, encoding='utf-8')
return web.json_response({"success": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Style editor endpoints
@server.PromptServer.instance.routes.get('/style_editor')
async def get_styles(request):
"""Get all styles"""
try:
styles_file = CONFIG_DIR / "styles.jsonc"
if not styles_file.exists():
return web.json_response({})
content = styles_file.read_text(encoding='utf-8')
clean_content = common.strip_jsonc_comments(content)
styles = json.loads(clean_content)
return web.json_response(styles)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.post('/style_editor')
async def update_styles(request):
"""Update styles"""
try:
data = await request.json()
styles_file = CONFIG_DIR / "styles.jsonc"
content = json.dumps(data, indent=4, ensure_ascii=False)
styles_file.write_text(content, encoding='utf-8')
return web.json_response({"success": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
print("Model and Style Editor API routes registered")
print("Character Editor API routes registered")
# Tag editor endpoints
@server.PromptServer.instance.routes.get('/tag_editor')
async def get_tags(request):
"""Get all tag presets"""
try:
tags_file = CONFIG_DIR / "tags.jsonc"
if not tags_file.exists():
return web.json_response({})
content = tags_file.read_text(encoding='utf-8')
clean_content = common.strip_jsonc_comments(content)
tags = json.loads(clean_content)
return web.json_response(tags)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.post('/tag_editor')
async def update_tags(request):
"""Update tag presets"""
try:
data = await request.json()
tags_file = CONFIG_DIR / "tags.jsonc"
content = json.dumps(data, indent=4, ensure_ascii=False)
tags_file.write_text(content, encoding='utf-8')
return web.json_response({"success": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
print("Tag Editor API routes registered")
@server.PromptServer.instance.routes.get("/wildcard_editor")
async def get_wildcards(request):
"""Get all wildcards."""
try:
if not WILDCARDS_FILE.exists():
return web.json_response({})
content = WILDCARDS_FILE.read_text(encoding='utf-8')
clean_content = common.strip_jsonc_comments(content)
wildcards = json.loads(clean_content)
return web.json_response(wildcards)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.post("/wildcard_editor")
async def save_wildcards(request):
"""Save wildcards."""
try:
data = await request.json()
with open(WILDCARDS_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response(
{"error": str(e)}, status=500
)
print("LoRA and Embedding list API routes registered")
# Preview image cache directory
PREVIEW_CACHE_DIR = CONFIG_DIR / "preview_cache"
PREVIEW_CACHE_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_SIZE = 256 # Size for cached preview images
def get_lora_preview_path(lora_name_or_path):
"""Get the preview image path for a LoRA"""
lora_file = None
# First, try using folder_paths.get_full_path which handles subdirectories
# properly. This is the most reliable method
try:
# Try with the path as-is (might already include extension)
full_path = folder_paths.get_full_path("loras", lora_name_or_path)
if full_path and os.path.exists(full_path):
lora_file = full_path
except: # noqa
pass
# If that didn't work, try adding common extensions
if not lora_file:
for ext in ['.safetensors', '.ckpt', '.pt', '.bin']:
try:
# Try with extension appended
full_path = folder_paths.get_full_path(
"loras", lora_name_or_path + ext)
if full_path and os.path.exists(full_path):
lora_file = full_path
break
except: # noqa
continue
# If still not found, try direct path search (fallback)
if not lora_file:
lora_paths = folder_paths.get_folder_paths("loras")
for lora_dir in lora_paths:
# Try as direct filename
for ext in ['.safetensors', '.ckpt', '.pt', '.bin']:
test_path = os.path.join(lora_dir, lora_name_or_path + ext)
if os.path.exists(test_path):
lora_file = test_path
break
if lora_file:
break
if lora_file:
# Check for preview files in the same directory as the LoRA file
base_name = os.path.splitext(os.path.basename(lora_file))[0]
preview_dir = os.path.dirname(lora_file)
# First check for .preview.* files
for ext in [
'.preview.png', '.preview.jpeg',
'.preview.jpg', '.preview.webp']:
preview_path = os.path.join(preview_dir, base_name + ext)
if os.path.exists(preview_path):
return preview_path
# Also check for image files with same base name (without .preview.
# prefix) using common image extensions
for ext in ['.png', '.jpeg', '.jpg', '.webp', '.gif', '.bmp']:
preview_path = os.path.join(preview_dir, base_name + ext)
if os.path.exists(preview_path):
# Make sure it's not the LoRA file itself
if preview_path != lora_file:
return preview_path
return None
def get_embedding_preview_path(embedding_path):
"""Get the preview image path for an embedding"""
embedding_paths = folder_paths.get_folder_paths("embeddings")
for emb_dir in embedding_paths:
# Try to find the embedding file
emb_file = None
for ext in ['.pt', '.safetensors', '.bin']:
test_path = os.path.join(emb_dir, embedding_path)
if os.path.exists(test_path):
emb_file = test_path
break
if emb_file:
# Check for preview files
base_name = os.path.splitext(os.path.basename(emb_file))[0]
preview_dir = os.path.dirname(emb_file)
# First check for .preview.* files
for ext in [
'.preview.png', '.preview.jpeg',
'.preview.jpg', '.preview.webp']:
preview_path = os.path.join(preview_dir, base_name + ext)
if os.path.exists(preview_path):
return preview_path
# Also check for image files with same base name (without .preview.
# prefix) using common image extensions
for ext in ['.png', '.jpeg', '.jpg', '.webp', '.gif', '.bmp']:
preview_path = os.path.join(preview_dir, base_name + ext)
if os.path.exists(preview_path):
# Make sure it's not the embedding file itself
if preview_path != emb_file:
return preview_path
return None
def get_cached_preview_path(original_path, cache_key):
"""Get or create a cached preview image"""
# Create a hash of the original path for cache filename
import hashlib
cache_hash = hashlib.md5(cache_key.encode()).hexdigest()
cache_path = PREVIEW_CACHE_DIR / f"{cache_hash}.jpg"
# Check if cache exists and is newer than original
if cache_path.exists():
try:
if os.path.getmtime(cache_path) >= os.path.getmtime(original_path):
return cache_path
except: # noqa
pass
# Create cached version
try:
img = Image.open(original_path)
img = img.convert('RGB')
# Resize with center crop to square
width, height = img.size
if width > height:
left = (width - height) / 2
img = img.crop((left, 0, left + height, height))
else:
top = (height - width) / 2
img = img.crop((0, top, width, top + width))
# Resize to preview size
img = img.resize((
PREVIEW_SIZE, PREVIEW_SIZE), Image.Resampling.LANCZOS)
# Save as JPEG
img.save(cache_path, 'JPEG', quality=85, optimize=True)
return cache_path
except Exception as e:
print(f"Error creating cached preview: {e}")
return original_path
@server.PromptServer.instance.routes.get('/lora_preview/{name}')
async def get_lora_preview(request):
"""Get LoRA preview image"""
try:
from urllib.parse import unquote
name = unquote(request.match_info['name'])
# Try to find preview using the name
preview_path = get_lora_preview_path(name)
# If not found, try to find the LoRA in the list and use its full path
if not preview_path or not os.path.exists(preview_path):
# Try to find the LoRA in the list to get its full path
try:
loras = folder_paths.get_filename_list("loras")
# Check if name matches any LoRA (by name or path)
for lora in loras:
lora_name = os.path.splitext(lora)[0]
if lora_name == name or lora == name:
# Try with the full path
preview_path = get_lora_preview_path(lora)
if preview_path and os.path.exists(preview_path):
break
except: # noqa
pass
if not preview_path or not os.path.exists(preview_path):
return web.Response(status=404)
# Use cached version if available
cache_path = get_cached_preview_path(preview_path, f"lora:{name}")
return web.FileResponse(cache_path)
except Exception as e:
print(f"Error getting LoRA preview: {e}")
import traceback
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.get('/embedding_preview/{path:.*}')
async def get_embedding_preview(request):
"""Get embedding preview image"""
try:
from urllib.parse import unquote
path = unquote(request.match_info['path'])
preview_path = get_embedding_preview_path(path)
if not preview_path or not os.path.exists(preview_path):
return web.Response(status=404)
# Use cached version if available
cache_path = get_cached_preview_path(preview_path, f"embedding:{path}")
return web.FileResponse(cache_path)
except Exception as e:
print(f"Error getting embedding preview: {e}")
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.get('/lora_trigger_words/{name:.*}')
async def get_lora_trigger_words(request):
"""Get trigger words for a LoRA from its civitai metadata."""
try:
from urllib.parse import unquote
name = unquote(request.match_info['name'])
print(f"[TriggerWords] Requested name: {repr(name)}")
# Resolve the lora file path. The name may already include
# a file extension if passed as a full relative path.
lora_file = None
try:
full_path = folder_paths.get_full_path("loras", name)
print(f"[TriggerWords] get_full_path result: {repr(full_path)}")
if full_path and os.path.exists(full_path):
lora_file = full_path
except Exception as e:
print(f"[TriggerWords] get_full_path exception: {e}")
if not lora_file:
for ext in ['.safetensors', '.ckpt', '.pt', '.bin']:
try:
full_path = folder_paths.get_full_path(
"loras", name + ext
)
if full_path and os.path.exists(full_path):
lora_file = full_path
print(
f"[TriggerWords] Found via ext fallback: "
f"{repr(lora_file)}"
)
break
except Exception:
continue
# Fall back to manually joining against each lora directory,
# since get_full_path can be unreliable with subdirectory paths.
if not lora_file:
lora_dirs = folder_paths.get_folder_paths("loras")
print(f"[TriggerWords] Lora dirs: {lora_dirs}")
for lora_dir in lora_dirs:
candidate = os.path.join(lora_dir, name)
print(f"[TriggerWords] Trying candidate: {repr(candidate)}")
if os.path.exists(candidate):
lora_file = candidate
break
for ext in ['.safetensors', '.ckpt', '.pt', '.bin']:
candidate = os.path.join(lora_dir, name + ext)
if os.path.exists(candidate):
lora_file = candidate
break
if lora_file:
print(
f"[TriggerWords] Found via manual join: "
f"{repr(lora_file)}"
)
break
if not lora_file:
print(f"[TriggerWords] Could not resolve lora file for "
f"{repr(name)}")
return web.json_response([])
# Look for sidecar metadata file — check .metadata.json first
# (lora manager format), then fall back to plain .json.
base = os.path.splitext(lora_file)[0]
for meta_suffix in ['.metadata.json', '.json']:
meta_path = base + meta_suffix
print(
f"[TriggerWords] Looking for metadata at: {repr(meta_path)}"
)
if os.path.exists(meta_path):
break
else:
print(f"[TriggerWords] Metadata file not found")
return web.json_response([])
print(f"[TriggerWords] Using metadata file: {repr(meta_path)}")
with open(meta_path, 'r', encoding='utf-8') as f:
meta = json.load(f)
# Extract civitai trainedWords
trained_words = (
meta.get('civitai', {}).get('trainedWords', [])
)
print(f"[TriggerWords] Returning words: {trained_words}")
if not isinstance(trained_words, list):
return web.json_response([])
return web.json_response(trained_words)
except Exception as e:
print(f"[TriggerWords] Error: {e}")
import traceback
traceback.print_exc()
return web.json_response([])
@server.PromptServer.instance.routes.get('/lora_list')
async def get_lora_list(request):
"""Get list of all available LoRAs"""
try:
loras = folder_paths.get_filename_list("loras")
# Return list of dicts with name, path, and hasPreview
lora_list = []
for lora in loras:
# Remove extension for name
name = os.path.splitext(lora)[0]
# Try both the name (without extension) and the full path for
# preview detection. The full path might include subdirectories
has_preview = (get_lora_preview_path(name) is not None or
get_lora_preview_path(lora) is not None)
lora_list.append({
"name": name,
"path": lora,
"hasPreview": has_preview
})
return web.json_response(lora_list)
except Exception as e:
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.get('/embedding_list')
async def get_embedding_list(request):
"""Get list of all available embeddings"""
try:
# Get embedding paths
embedding_paths = folder_paths.get_folder_paths("embeddings")
embeddings = []
# Scan each embeddings folder
for emb_path in embedding_paths:
if not os.path.exists(emb_path):
continue
for root, dirs, files in os.walk(emb_path):
for file in files:
# Common embedding extensions
if file.lower().endswith(
('.pt', '.safetensors', '.bin')
):
# Remove extension for cleaner names
name = os.path.splitext(file)[0]
# Get relative path from embedding root
rel_path = os.path.relpath(
os.path.join(root, file),
emb_path
)
has_preview = get_embedding_preview_path(
rel_path) is not None
embeddings.append({
"name": name,
"path": rel_path,
"hasPreview": has_preview
})
# Remove duplicates and sort
unique_embeddings = {
emb["name"]: emb for emb in embeddings
}.values()
sorted_embeddings = sorted(
unique_embeddings,
key=lambda x: x["name"].lower()
)
return web.json_response(list(sorted_embeddings))
except Exception as e:
return web.json_response(
{"error": str(e)},
status=500
)
@server.PromptServer.instance.routes.get('/tag_usage')
async def get_tag_usage(request):
"""Get tag usage counts for autocomplete"""
try:
usage_file = CONFIG_DIR / "tag_usage.json"
if not usage_file.exists():
return web.json_response({})
with open(usage_file, 'r', encoding='utf-8') as f:
usage = json.load(f)
return web.json_response(usage)
except Exception as e:
print(f"Error loading tag usage: {e}")
return web.json_response({}, status=500)
@server.PromptServer.instance.routes.get('/autocomplete_settings')
async def get_autocomplete_settings(request):
"""Get autocomplete settings"""
try:
settings_file = CONFIG_DIR / "autocomplete_settings.json"
if not settings_file.exists():
return web.json_response({"collect_tag_usage": True})
with open(settings_file, 'r', encoding='utf-8') as f:
settings = json.load(f)
return web.json_response(settings)
except Exception as e:
print(f"Error loading autocomplete settings: {e}")
return web.json_response(
{"collect_tag_usage": True},
status=500
)
@server.PromptServer.instance.routes.post('/autocomplete_settings')
async def update_autocomplete_settings(request):
"""Update autocomplete settings"""
try:
data = await request.json()
settings_file = CONFIG_DIR / "autocomplete_settings.json"
with open(settings_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
return web.json_response({"success": True})
except Exception as e:
print(f"Error saving autocomplete settings: {e}")
return web.json_response(
{"error": str(e)},
status=500
)
print("Tag usage API routes registered")