From d0dff576c984fa10faffc01aa6d6d76aaa7fe8f1 Mon Sep 17 00:00:00 2001 From: wrt122311 <1720122311@qq.com> Date: Tue, 26 May 2026 20:19:26 +0800 Subject: [PATCH] Add custom Web UI gallery with search and pagination --- __init__.py | 3 +- animadex_node.py | 50 ++++++++++-- web/animadex.css | 144 +++++++++++++++++++++++++++++++++ web/animadex_ui.js | 197 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 web/animadex.css create mode 100644 web/animadex_ui.js diff --git a/__init__.py b/__init__.py index 35e8ca4..a1c8303 100644 --- a/__init__.py +++ b/__init__.py @@ -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'] diff --git a/animadex_node.py b/animadex_node.py index d907001..631ab24 100644 --- a/animadex_node.py +++ b/animadex_node.py @@ -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) diff --git a/web/animadex.css b/web/animadex.css new file mode 100644 index 0000000..76ace08 --- /dev/null +++ b/web/animadex.css @@ -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; +} diff --git a/web/animadex_ui.js b/web/animadex_ui.js new file mode 100644 index 0000000..1483680 --- /dev/null +++ b/web/animadex_ui.js @@ -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 = '
Loading characters...
'; + 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,'; }; + + 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 = `
Error loading data. Is the backend running?
`; + } + }; + + 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); +}