Add custom Web UI gallery with search and pagination
This commit is contained in:
+2
-1
@@ -1,3 +1,4 @@
|
||||
from .animadex_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
WEB_DIRECTORY = "./web"
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
|
||||
+42
-8
@@ -5,6 +5,8 @@ from io import BytesIO
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from aiohttp import web
|
||||
import server
|
||||
|
||||
# Load the scraped characters
|
||||
current_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -19,23 +21,60 @@ except Exception as e:
|
||||
# Map character name + copyright to character dict
|
||||
char_dict = {}
|
||||
for c in char_data:
|
||||
# Use name and copyright to create a unique display name
|
||||
display_name = f"{c.get('name', 'Unknown')} ({c.get('copyright_name', c.get('copyright', 'Unknown'))})"
|
||||
c['_display_name'] = display_name
|
||||
char_dict[display_name] = c
|
||||
|
||||
char_names = list(char_dict.keys())
|
||||
if not char_names:
|
||||
char_names = ["No data loaded"]
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# API Route for Frontend Gallery
|
||||
# -------------------------------------------------------------
|
||||
@server.PromptServer.instance.routes.get("/animadex/search")
|
||||
async def search_characters(request):
|
||||
query = request.rel_url.query.get("q", "").lower()
|
||||
page = int(request.rel_url.query.get("page", "1"))
|
||||
page_size = 50
|
||||
|
||||
# Filter characters based on search query
|
||||
if query:
|
||||
filtered = [
|
||||
c for c in char_data
|
||||
if query in c.get('name', '').lower()
|
||||
or query in c.get('copyright_name', '').lower()
|
||||
or query in c.get('trigger', '').lower()
|
||||
]
|
||||
else:
|
||||
filtered = char_data
|
||||
|
||||
# Pagination
|
||||
total = len(filtered)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
results = filtered[start_idx:end_idx]
|
||||
|
||||
return web.json_response({
|
||||
"results": results,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + page_size - 1) // page_size
|
||||
})
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Node Class
|
||||
# -------------------------------------------------------------
|
||||
class AnimadexCharacterNode:
|
||||
"""
|
||||
A ComfyUI custom node that provides a dropdown of Animadex characters,
|
||||
outputting their image, trigger words, and prompt tags.
|
||||
A ComfyUI custom node that provides a custom WebUI gallery of Animadex characters.
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
# The dropdown list is still required for the backend graph logic,
|
||||
# but we will hide it in the frontend using JS.
|
||||
"character": (char_names,),
|
||||
"use_high_res_image": (["false", "true"], {"default": "false"}),
|
||||
},
|
||||
@@ -48,7 +87,6 @@ class AnimadexCharacterNode:
|
||||
|
||||
def get_character(self, character, use_high_res_image):
|
||||
if character not in char_dict:
|
||||
# Fallback for empty data
|
||||
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
return (empty_image, "", "")
|
||||
|
||||
@@ -57,24 +95,20 @@ class AnimadexCharacterNode:
|
||||
tags_list = c.get("tags", [])
|
||||
tags = ", ".join(tags_list)
|
||||
|
||||
# Determine image URL
|
||||
url = c.get("img_url") if use_high_res_image == "true" else c.get("thumb_url")
|
||||
if not url:
|
||||
url = c.get("thumb_url") or c.get("img_url")
|
||||
|
||||
# Download image
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
image_data = response.read()
|
||||
i = Image.open(BytesIO(image_data))
|
||||
i = i.convert("RGB")
|
||||
# Convert PIL image to ComfyUI tensor format (Batch, Height, Width, Channels)
|
||||
image = np.array(i).astype(np.float32) / 255.0
|
||||
image = torch.from_numpy(image)[None,]
|
||||
except Exception as e:
|
||||
print(f"[Animadex] Error downloading image from {url}: {e}")
|
||||
# Return a blank 64x64 image on failure
|
||||
image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
|
||||
return (image, trigger, tags)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* 弹窗遮罩 */
|
||||
.animadex-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #e2e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 弹窗内容区 */
|
||||
.animadex-modal {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #313244;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 1000px;
|
||||
height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.animadex-modal-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #313244;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.animadex-search-input {
|
||||
flex: 1;
|
||||
background: #11111b;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: #cdd6f4;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.animadex-search-input:focus {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
.animadex-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a6adc8;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.animadex-close-btn:hover { color: #f38ba8; }
|
||||
|
||||
/* 网格内容区 */
|
||||
.animadex-modal-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.animadex-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.animadex-card {
|
||||
background: #313244;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.animadex-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
.animadex-card-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
object-fit: cover;
|
||||
background: #11111b;
|
||||
}
|
||||
|
||||
.animadex-card-title {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
/* 底部翻页 */
|
||||
.animadex-modal-footer {
|
||||
padding: 15px;
|
||||
border-top: 1px solid #313244;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.animadex-page-btn {
|
||||
background: #89b4fa;
|
||||
color: #11111b;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.animadex-page-btn:hover { opacity: 0.8; }
|
||||
.animadex-page-btn:disabled {
|
||||
background: #45475a;
|
||||
color: #a6adc8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.animadex-page-info {
|
||||
color: #a6adc8;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Load CSS dynamically
|
||||
const cssId = 'animadex-css';
|
||||
if (!document.getElementById(cssId)) {
|
||||
const head = document.getElementsByTagName('head')[0];
|
||||
const link = document.createElement('link');
|
||||
link.id = cssId;
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
// Path relative to the ComfyUI web root
|
||||
link.href = 'extensions/ComfyUI-Animadex-Node/animadex.css';
|
||||
link.media = 'all';
|
||||
head.appendChild(link);
|
||||
}
|
||||
|
||||
let currentModal = null;
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.Animadex.Node",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "AnimadexCharacterNode") return;
|
||||
|
||||
// Find the original combo box
|
||||
const charWidget = node.widgets.find(w => w.name === "character");
|
||||
if (charWidget) {
|
||||
// We can't perfectly hide it in LiteGraph without breaking serialization easily,
|
||||
// but we can add a massive visual button to make it obvious.
|
||||
}
|
||||
|
||||
// Add a button to open the gallery
|
||||
const btnWidget = node.addWidget("button", "Open Gallery 🖼️", "open", () => {
|
||||
openAnimadexModal(charWidget, btnWidget, node);
|
||||
});
|
||||
|
||||
// Init button name if loaded from workflow
|
||||
if (charWidget && charWidget.value) {
|
||||
const shortName = charWidget.value.split(" (")[0];
|
||||
btnWidget.name = "🖼️ " + shortName;
|
||||
}
|
||||
|
||||
// Keep the button visually prominent
|
||||
node.setSize(node.computeSize());
|
||||
}
|
||||
});
|
||||
|
||||
function openAnimadexModal(charWidget, btnWidget, node) {
|
||||
if (currentModal) return;
|
||||
|
||||
let currentPage = 1;
|
||||
let currentQuery = "";
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'animadex-modal-overlay';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'animadex-modal';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'animadex-modal-header';
|
||||
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'text';
|
||||
searchInput.className = 'animadex-search-input';
|
||||
searchInput.placeholder = 'Search 36000+ characters... (Press Enter)';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'animadex-close-btn';
|
||||
closeBtn.innerHTML = '✖';
|
||||
closeBtn.onclick = () => close();
|
||||
|
||||
header.appendChild(searchInput);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
// Body
|
||||
const body = document.createElement('div');
|
||||
body.className = 'animadex-modal-body';
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'animadex-grid';
|
||||
body.appendChild(grid);
|
||||
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'animadex-modal-footer';
|
||||
|
||||
const prevBtn = document.createElement('button');
|
||||
prevBtn.className = 'animadex-page-btn';
|
||||
prevBtn.innerHTML = '◀ Prev';
|
||||
|
||||
const pageInfo = document.createElement('div');
|
||||
pageInfo.className = 'animadex-page-info';
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'animadex-page-btn';
|
||||
nextBtn.innerHTML = 'Next ▶';
|
||||
|
||||
footer.appendChild(prevBtn);
|
||||
footer.appendChild(pageInfo);
|
||||
footer.appendChild(nextBtn);
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
currentModal = overlay;
|
||||
|
||||
// Actions
|
||||
const close = () => {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
currentModal = null;
|
||||
};
|
||||
|
||||
// Close on background click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
grid.innerHTML = '<div style="grid-column: 1/-1; text-align:center; padding: 50px; font-size: 18px;">Loading characters...</div>';
|
||||
try {
|
||||
const resp = await fetch(`/animadex/search?q=${encodeURIComponent(currentQuery)}&page=${currentPage}`);
|
||||
const data = await resp.json();
|
||||
|
||||
grid.innerHTML = '';
|
||||
data.results.forEach(char => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'animadex-card';
|
||||
|
||||
const imgUrl = char.thumb_url || char.img_url;
|
||||
const img = document.createElement('img');
|
||||
img.className = 'animadex-card-img';
|
||||
img.src = imgUrl;
|
||||
// Fallback placeholder
|
||||
img.onerror = () => { img.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="%2345475a"/></svg>'; };
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'animadex-card-title';
|
||||
title.textContent = char.name;
|
||||
title.title = char._display_name;
|
||||
|
||||
card.appendChild(img);
|
||||
card.appendChild(title);
|
||||
|
||||
card.onclick = () => {
|
||||
if (charWidget) {
|
||||
charWidget.value = char._display_name;
|
||||
btnWidget.name = "🖼️ " + char.name;
|
||||
|
||||
// Force update canvas
|
||||
if (app.graph) {
|
||||
app.graph.setDirtyCanvas(true);
|
||||
}
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
pageInfo.textContent = `Page ${data.page} of ${data.pages} (${data.total} total)`;
|
||||
prevBtn.disabled = data.page <= 1;
|
||||
nextBtn.disabled = data.page >= data.pages;
|
||||
} catch (e) {
|
||||
console.error("Animadex fetch error:", e);
|
||||
grid.innerHTML = `<div style="grid-column: 1/-1; text-align:center; color:#f38ba8;">Error loading data. Is the backend running?</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
currentQuery = searchInput.value.trim();
|
||||
currentPage = 1;
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
prevBtn.onclick = () => {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
nextBtn.onclick = () => {
|
||||
currentPage++;
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Initial load
|
||||
setTimeout(() => {
|
||||
searchInput.focus();
|
||||
loadData();
|
||||
}, 100);
|
||||
}
|
||||
Reference in New Issue
Block a user