Add custom Web UI gallery with search and pagination

This commit is contained in:
wrt122311
2026-05-26 20:19:26 +08:00
parent 100698d00b
commit d0dff576c9
4 changed files with 385 additions and 9 deletions
+2 -1
View File
@@ -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
View File
@@ -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)
+144
View File
@@ -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;
}
+197
View File
@@ -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);
}