初始化
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ComfyUI 参考图管理器
|
||||||
|
|
||||||
|
一个轻量的 ComfyUI 自定义节点,用一个节点管理多张参考图,避免堆很多 `Load Image` 节点。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 标准 `IMAGE` 输出。
|
||||||
|
- 原生图片下拉和上传控件会隐藏,只显示自定义中文管理面板。
|
||||||
|
- 图片列表默认为空,只保存你主动添加到管理器里的图片。
|
||||||
|
- 支持多选添加、替换当前、点击缩略图切换输出。
|
||||||
|
- 支持编辑显示名称、原始路径、文件夹。
|
||||||
|
- 支持搜索、同文件夹筛选、收藏筛选、删除单张、清空列表。
|
||||||
|
- 不移动、不删除 ComfyUI input 缓存文件,只管理工作流里的图片引用和元数据。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
重启 ComfyUI 后添加:
|
||||||
|
|
||||||
|
`image/reference -> 参考图管理器`
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
Reference Image Manager for ComfyUI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .nodes import ReferenceImageManager
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"Reference Image Manager": ReferenceImageManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"Reference Image Manager": "参考图管理器",
|
||||||
|
}
|
||||||
|
|
||||||
|
WEB_DIRECTORY = "./web"
|
||||||
|
|
||||||
|
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image, ImageOps, ImageSequence
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
import node_helpers
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceImageManager:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
input_dir = folder_paths.get_input_directory()
|
||||||
|
files = []
|
||||||
|
if os.path.isdir(input_dir):
|
||||||
|
files = [
|
||||||
|
f
|
||||||
|
for f in os.listdir(input_dir)
|
||||||
|
if os.path.isfile(os.path.join(input_dir, f))
|
||||||
|
]
|
||||||
|
files = folder_paths.filter_files_content_types(files, ["image"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"image": ([""] + sorted(files), {"image_upload": True}),
|
||||||
|
"managed_images": ("STRING", {"default": "[]", "multiline": True}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY = "image/reference"
|
||||||
|
RETURN_TYPES = ("IMAGE",)
|
||||||
|
RETURN_NAMES = ("image",)
|
||||||
|
FUNCTION = "load_image"
|
||||||
|
SEARCH_ALIASES = [
|
||||||
|
"reference image",
|
||||||
|
"image manager",
|
||||||
|
"image switcher",
|
||||||
|
"load image",
|
||||||
|
"managed image",
|
||||||
|
]
|
||||||
|
|
||||||
|
def load_image(self, image, managed_images="[]"):
|
||||||
|
if not image:
|
||||||
|
raise ValueError("参考图管理器未选择图片。")
|
||||||
|
|
||||||
|
image_path = folder_paths.get_annotated_filepath(image)
|
||||||
|
img = node_helpers.pillow(Image.open, image_path)
|
||||||
|
|
||||||
|
output_images = []
|
||||||
|
width, height = None, None
|
||||||
|
|
||||||
|
for frame in ImageSequence.Iterator(img):
|
||||||
|
frame = node_helpers.pillow(ImageOps.exif_transpose, frame)
|
||||||
|
|
||||||
|
if frame.mode == "I":
|
||||||
|
frame = frame.point(lambda i: i * (1 / 255))
|
||||||
|
|
||||||
|
image_rgb = frame.convert("RGB")
|
||||||
|
|
||||||
|
if not output_images:
|
||||||
|
width, height = image_rgb.size
|
||||||
|
|
||||||
|
if image_rgb.size != (width, height):
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_np = np.array(image_rgb).astype(np.float32) / 255.0
|
||||||
|
output_images.append(torch.from_numpy(image_np)[None,])
|
||||||
|
|
||||||
|
if img.format == "MPO":
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(output_images) > 1:
|
||||||
|
return (torch.cat(output_images, dim=0),)
|
||||||
|
|
||||||
|
return (output_images[0],)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def IS_CHANGED(cls, image, managed_images="[]"):
|
||||||
|
if not image:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
image_path = folder_paths.get_annotated_filepath(image)
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
hasher.update(image_file.read())
|
||||||
|
return hasher.digest().hex()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, image, managed_images="[]"):
|
||||||
|
if not image:
|
||||||
|
return "参考图管理器未选择图片。"
|
||||||
|
|
||||||
|
if not folder_paths.exists_annotated_filepath(image):
|
||||||
|
return "Invalid image file: {}".format(image)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
.rim-panel {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 320px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #e8edf2;
|
||||||
|
background: #171b20;
|
||||||
|
border: 1px solid #2c333b;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-toolbar,
|
||||||
|
.rim-path-row,
|
||||||
|
.rim-editor,
|
||||||
|
.rim-tabs,
|
||||||
|
.rim-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-toolbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-btn,
|
||||||
|
.rim-tab {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #dfe8f0;
|
||||||
|
background: #222932;
|
||||||
|
border: 1px solid #3b4652;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-btn:hover,
|
||||||
|
.rim-tab:hover {
|
||||||
|
background: #2d3742;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-btn-primary {
|
||||||
|
color: #0d141b;
|
||||||
|
background: #8fd0ff;
|
||||||
|
border-color: #8fd0ff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-btn-danger {
|
||||||
|
color: #ffd9d9;
|
||||||
|
border-color: #704040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-preview {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 180px;
|
||||||
|
min-height: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, #20262d 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #20262d 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #20262d 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #20262d 75%);
|
||||||
|
background-color: #11151a;
|
||||||
|
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
border: 1px solid #323b45;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
color: #92a0ad;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-path-row input,
|
||||||
|
.rim-editor input,
|
||||||
|
.rim-search {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 26px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #e8edf2;
|
||||||
|
background: #11151a;
|
||||||
|
border: 1px solid #333d48;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-editor {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-tab-active {
|
||||||
|
color: #0d141b;
|
||||||
|
background: #b7dfb0;
|
||||||
|
border-color: #b7dfb0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(86px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
padding-right: 2px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 5px;
|
||||||
|
background: #20262e;
|
||||||
|
border: 1px solid #303a44;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-card-active {
|
||||||
|
border-color: #8fd0ff;
|
||||||
|
box-shadow: 0 0 0 1px #8fd0ff inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #101418;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-name,
|
||||||
|
.rim-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-name {
|
||||||
|
color: #edf3f8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-meta {
|
||||||
|
color: #9ba8b4;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-star {
|
||||||
|
color: #f3d27a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-card-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-mini {
|
||||||
|
width: 24px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rim-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
const NODE_NAME = "Reference Image Manager";
|
||||||
|
const MAX_ITEMS = 120;
|
||||||
|
const CONFIG_VALUES = "__rim_config_widgets_values";
|
||||||
|
|
||||||
|
function isItemsJson(value) {
|
||||||
|
return typeof value === "string" && value.trim().startsWith("[");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStyles() {
|
||||||
|
const id = "reference-image-manager-css";
|
||||||
|
if (document.getElementById(id)) return;
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.id = id;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = "extensions/ComfyUI-ReferenceImageManager/reference_image_manager.css";
|
||||||
|
document.head.append(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEl(tag, className, text) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
if (className) el.className = className;
|
||||||
|
if (text != null) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(path) {
|
||||||
|
return (path || "").split(/[\\/]/).filter(Boolean).pop() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirname(path) {
|
||||||
|
const slash = (path || "").includes("\\") ? "\\" : "/";
|
||||||
|
const parts = (path || "").split(/[\\/]/).filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
return parts.join(slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseItems(value) {
|
||||||
|
try {
|
||||||
|
const items = JSON.parse(value || "[]");
|
||||||
|
return Array.isArray(items) ? items : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanItems(items) {
|
||||||
|
const seen = new Set();
|
||||||
|
return items
|
||||||
|
.filter((item) => item?.image)
|
||||||
|
.filter((item) => {
|
||||||
|
const key = item.id || `${item.image}|${item.original_path || ""}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotatedImageName(upload) {
|
||||||
|
const prefix = upload.subfolder ? `${upload.subfolder}/` : "";
|
||||||
|
const type = upload.type && upload.type !== "input" ? ` [${upload.type}]` : "";
|
||||||
|
return `${prefix}${upload.name}${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewUrl(item) {
|
||||||
|
if (!item?.image) return "";
|
||||||
|
const filename = item.subfolder ? `${item.subfolder}/${item.name}` : item.name || item.image;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
filename,
|
||||||
|
type: item.type || "input",
|
||||||
|
});
|
||||||
|
return api.apiURL(`/view?${params.toString()}${app.getPreviewFormatParam?.() || ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidget(node, name) {
|
||||||
|
return node.widgets?.find((w) => w.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetValue(node, name) {
|
||||||
|
return getWidget(node, name)?.value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfiguredItems(node, fallbackValues) {
|
||||||
|
const values = fallbackValues || node?.[CONFIG_VALUES] || node?.widgets_values || [];
|
||||||
|
return values.find(isItemsJson) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readManagedJson(node, fallbackValues) {
|
||||||
|
const configuredItems = readConfiguredItems(node, fallbackValues);
|
||||||
|
if (node?.__rim_config_dirty) {
|
||||||
|
return configuredItems || getWidgetValue(node, "managed_images") || node?.properties?.rim_items || node?.__rim_state_json || "[]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
node?.__rim_state_json ||
|
||||||
|
getWidgetValue(node, "managed_images") ||
|
||||||
|
configuredItems ||
|
||||||
|
node?.properties?.rim_items ||
|
||||||
|
"[]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSelectedImage(node, fallbackValues) {
|
||||||
|
const values = fallbackValues || node?.[CONFIG_VALUES] || node?.widgets_values || [];
|
||||||
|
if (node?.__rim_config_dirty) {
|
||||||
|
return values[0] || getWidgetValue(node, "image") || "";
|
||||||
|
}
|
||||||
|
return getWidgetValue(node, "image") || values[0] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSerializableState(node, workflowNode) {
|
||||||
|
const existingValues = workflowNode?.widgets_values || node?.[CONFIG_VALUES] || node?.widgets_values || [];
|
||||||
|
if (!node?.__rim_config_dirty) {
|
||||||
|
node?.__rim_persist?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = readSelectedImage(node, existingValues);
|
||||||
|
const json = readManagedJson(node, existingValues);
|
||||||
|
const selectedId = node?.properties?.rim_selected_id || "";
|
||||||
|
|
||||||
|
if (workflowNode) {
|
||||||
|
workflowNode.widgets_values = [image, json];
|
||||||
|
if (!workflowNode.properties) workflowNode.properties = {};
|
||||||
|
workflowNode.properties.rim_items = json;
|
||||||
|
workflowNode.properties.rim_selected_id = selectedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
node.__rim_state_json = json;
|
||||||
|
node[CONFIG_VALUES] = [image, json];
|
||||||
|
if (!node.properties) node.properties = {};
|
||||||
|
node.properties.rim_items = json;
|
||||||
|
node.properties.rim_selected_id = selectedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNodeProperty(node, name, value) {
|
||||||
|
if (!node.properties) node.properties = {};
|
||||||
|
if (typeof node.setProperty === "function") {
|
||||||
|
node.setProperty(name, value);
|
||||||
|
} else {
|
||||||
|
node.properties[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidgetValue(node, name, value) {
|
||||||
|
const widget = getWidget(node, name);
|
||||||
|
if (!widget) return;
|
||||||
|
widget.value = value;
|
||||||
|
widget.callback?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWidget(node, name) {
|
||||||
|
const widget = getWidget(node, name);
|
||||||
|
if (!widget) return;
|
||||||
|
if (!widget.options) widget.options = {};
|
||||||
|
widget.options.serialize = true;
|
||||||
|
widget.hidden = true;
|
||||||
|
widget.computeSize = () => [0, -4];
|
||||||
|
widget.serialize = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWidgetByName(node, name) {
|
||||||
|
const widget = getWidget(node, name);
|
||||||
|
if (!widget) return;
|
||||||
|
widget.onRemove?.();
|
||||||
|
const index = node.widgets?.indexOf(widget) ?? -1;
|
||||||
|
if (index >= 0) node.widgets.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManager(node) {
|
||||||
|
node.serialize_widgets = true;
|
||||||
|
removeWidgetByName(node, "upload");
|
||||||
|
hideWidget(node, "image");
|
||||||
|
hideWidget(node, "managed_images");
|
||||||
|
node.imgs = [];
|
||||||
|
|
||||||
|
const imageWidget = getWidget(node, "image");
|
||||||
|
const itemsWidget = getWidget(node, "managed_images");
|
||||||
|
if (!imageWidget || !itemsWidget) return;
|
||||||
|
|
||||||
|
const panel = makeEl("div", "rim-panel");
|
||||||
|
const fileInput = makeEl("input", "rim-hidden");
|
||||||
|
fileInput.type = "file";
|
||||||
|
fileInput.accept = "image/*";
|
||||||
|
fileInput.multiple = true;
|
||||||
|
|
||||||
|
const toolbar = makeEl("div", "rim-toolbar");
|
||||||
|
const title = makeEl("div", "rim-title", "参考图管理");
|
||||||
|
const addBtn = makeEl("button", "rim-btn rim-btn-primary", "添加图片");
|
||||||
|
const replaceBtn = makeEl("button", "rim-btn", "替换当前");
|
||||||
|
toolbar.append(title, addBtn, replaceBtn);
|
||||||
|
|
||||||
|
const preview = makeEl("div", "rim-preview");
|
||||||
|
const previewImg = document.createElement("img");
|
||||||
|
const emptyPreview = makeEl("div", "rim-empty", "先添加图片,然后点击缩略图切换输出。");
|
||||||
|
preview.append(emptyPreview);
|
||||||
|
|
||||||
|
const editor = makeEl("div", "rim-editor");
|
||||||
|
const nameInput = document.createElement("input");
|
||||||
|
nameInput.placeholder = "显示名称";
|
||||||
|
const pathInput = document.createElement("input");
|
||||||
|
pathInput.placeholder = "原始路径";
|
||||||
|
const folderInput = document.createElement("input");
|
||||||
|
folderInput.placeholder = "文件夹";
|
||||||
|
editor.append(nameInput, pathInput, folderInput);
|
||||||
|
|
||||||
|
const controls = makeEl("div", "rim-toolbar");
|
||||||
|
const search = document.createElement("input");
|
||||||
|
search.className = "rim-search";
|
||||||
|
search.placeholder = "搜索名称 / 路径 / 文件夹";
|
||||||
|
const showAllBtn = makeEl("button", "rim-tab rim-tab-active", "全部");
|
||||||
|
const showFolderBtn = makeEl("button", "rim-tab", "同文件夹");
|
||||||
|
const showStarBtn = makeEl("button", "rim-tab", "收藏");
|
||||||
|
controls.append(showAllBtn, showFolderBtn, showStarBtn);
|
||||||
|
|
||||||
|
const actions = makeEl("div", "rim-toolbar");
|
||||||
|
const saveMetaBtn = makeEl("button", "rim-btn", "保存信息");
|
||||||
|
const starBtn = makeEl("button", "rim-btn", "收藏");
|
||||||
|
const deleteBtn = makeEl("button", "rim-btn rim-btn-danger", "删除");
|
||||||
|
const clearBtn = makeEl("button", "rim-btn", "清空列表");
|
||||||
|
actions.append(saveMetaBtn, starBtn, deleteBtn, clearBtn);
|
||||||
|
|
||||||
|
const list = makeEl("div", "rim-list");
|
||||||
|
panel.append(toolbar, preview, editor, controls, search, actions, list, fileInput);
|
||||||
|
|
||||||
|
const configuredValues = node[CONFIG_VALUES] || node.widgets_values || [];
|
||||||
|
const configuredImage = configuredValues[0] || imageWidget.value || "";
|
||||||
|
const configuredItems = readConfiguredItems(node, configuredValues);
|
||||||
|
const storedItems = configuredItems || itemsWidget.value || node.properties?.rim_items || "[]";
|
||||||
|
let items = cleanItems(parseItems(storedItems));
|
||||||
|
let selectedId =
|
||||||
|
node.properties?.rim_selected_id ||
|
||||||
|
items.find((item) => item.image === configuredImage)?.id ||
|
||||||
|
items[0]?.id ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (configuredImage) imageWidget.value = configuredImage;
|
||||||
|
itemsWidget.value = JSON.stringify(items);
|
||||||
|
node.__rim_state_json = itemsWidget.value;
|
||||||
|
imageWidget.serializeValue = () => imageWidget.value || "";
|
||||||
|
itemsWidget.serializeValue = () => node.__rim_state_json || itemsWidget.value || "[]";
|
||||||
|
let filterMode = "all";
|
||||||
|
let uploadMode = "add";
|
||||||
|
|
||||||
|
function selectedItem() {
|
||||||
|
return items.find((item) => item.id === selectedId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
items = cleanItems(items);
|
||||||
|
const json = JSON.stringify(items);
|
||||||
|
node.__rim_state_json = json;
|
||||||
|
node[CONFIG_VALUES] = [imageWidget.value || "", json];
|
||||||
|
setWidgetValue(node, "managed_images", json);
|
||||||
|
setNodeProperty(node, "rim_items", json);
|
||||||
|
setNodeProperty(node, "rim_selected_id", selectedId || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStateFromWidgets() {
|
||||||
|
const json = getWidgetValue(node, "managed_images") || node.__rim_state_json || node.properties?.rim_items || "[]";
|
||||||
|
items = cleanItems(parseItems(json));
|
||||||
|
node.__rim_state_json = JSON.stringify(items);
|
||||||
|
node[CONFIG_VALUES] = [imageWidget.value || "", node.__rim_state_json];
|
||||||
|
node.__rim_config_dirty = false;
|
||||||
|
selectedId =
|
||||||
|
node.properties?.rim_selected_id ||
|
||||||
|
items.find((item) => item.image === imageWidget.value)?.id ||
|
||||||
|
items[0]?.id ||
|
||||||
|
"";
|
||||||
|
if (selectedId) {
|
||||||
|
const item = items.find((entry) => entry.id === selectedId);
|
||||||
|
if (item) imageWidget.value = item.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item) {
|
||||||
|
if (!item) return;
|
||||||
|
selectedId = item.id;
|
||||||
|
setWidgetValue(node, "image", item.image);
|
||||||
|
node.imgs = [];
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtons() {
|
||||||
|
showAllBtn.classList.toggle("rim-tab-active", filterMode === "all");
|
||||||
|
showFolderBtn.classList.toggle("rim-tab-active", filterMode === "folder");
|
||||||
|
showStarBtn.classList.toggle("rim-tab-active", filterMode === "star");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor(item) {
|
||||||
|
nameInput.value = item?.label || "";
|
||||||
|
pathInput.value = item?.original_path || "";
|
||||||
|
folderInput.value = item?.folder || "";
|
||||||
|
title.textContent = item ? basename(item.label || item.original_path || item.image) : "参考图管理";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(item) {
|
||||||
|
preview.replaceChildren();
|
||||||
|
if (!item) {
|
||||||
|
preview.append(emptyPreview);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewImg.src = viewUrl(item);
|
||||||
|
preview.append(previewImg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredItems() {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
const currentFolder = selectedItem()?.folder || "";
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (filterMode === "folder" && currentFolder && item.folder !== currentFolder) return false;
|
||||||
|
if (filterMode === "star" && !item.starred) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return `${item.label} ${item.name} ${item.original_path} ${item.folder}`.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const active = selectedItem();
|
||||||
|
list.replaceChildren();
|
||||||
|
|
||||||
|
const visibleItems = filteredItems();
|
||||||
|
for (const item of visibleItems) {
|
||||||
|
const card = makeEl("div", `rim-card${item.id === selectedId ? " rim-card-active" : ""}`);
|
||||||
|
const thumb = makeEl("div", "rim-thumb");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.src = viewUrl(item);
|
||||||
|
thumb.append(img);
|
||||||
|
|
||||||
|
const name = makeEl("div", "rim-name", item.label || basename(item.original_path || item.name || item.image));
|
||||||
|
const meta = makeEl("div", "rim-meta", item.folder || item.type || "input");
|
||||||
|
const badge = makeEl("div", "rim-meta", item.starred ? "已收藏" : "点击选择");
|
||||||
|
card.append(thumb, name, meta, badge);
|
||||||
|
card.onclick = () => selectItem(item);
|
||||||
|
list.append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visibleItems.length) {
|
||||||
|
list.append(makeEl("div", "rim-empty", items.length ? "当前筛选没有图片。" : "列表为空。点击“添加图片”创建你的参考图库。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPreview(active);
|
||||||
|
renderEditor(active);
|
||||||
|
starBtn.textContent = active?.starred ? "取消收藏" : "收藏";
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
persist();
|
||||||
|
renderList();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
node.setSize([Math.max(node.size[0], 460), Math.max(node.size[1], 560)]);
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files) {
|
||||||
|
const selectedBefore = selectedItem();
|
||||||
|
const uploads = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("image", file);
|
||||||
|
body.append("type", "input");
|
||||||
|
const response = await api.fetchApi("/upload/image", { method: "POST", body });
|
||||||
|
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
||||||
|
const upload = await response.json();
|
||||||
|
const originalPath = file.path || file.webkitRelativePath || file.name;
|
||||||
|
uploads.push({
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
image: annotatedImageName(upload),
|
||||||
|
name: upload.name,
|
||||||
|
label: basename(originalPath || upload.name),
|
||||||
|
subfolder: upload.subfolder || "",
|
||||||
|
type: upload.type || "input",
|
||||||
|
original_path: originalPath,
|
||||||
|
folder: dirname(originalPath),
|
||||||
|
starred: false,
|
||||||
|
added_at: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploads.length) return;
|
||||||
|
if (uploadMode === "replace" && selectedBefore) {
|
||||||
|
const index = items.findIndex((item) => item.id === selectedBefore.id);
|
||||||
|
items.splice(index, 1, uploads[0]);
|
||||||
|
selectedId = uploads[0].id;
|
||||||
|
if (uploads.length > 1) items.splice(index + 1, 0, ...uploads.slice(1));
|
||||||
|
} else {
|
||||||
|
items.push(...uploads);
|
||||||
|
selectedId = uploads[0].id;
|
||||||
|
}
|
||||||
|
selectItem(items.find((item) => item.id === selectedId));
|
||||||
|
}
|
||||||
|
|
||||||
|
addBtn.onclick = () => {
|
||||||
|
uploadMode = "add";
|
||||||
|
fileInput.multiple = true;
|
||||||
|
fileInput.click();
|
||||||
|
};
|
||||||
|
replaceBtn.onclick = () => {
|
||||||
|
uploadMode = "replace";
|
||||||
|
fileInput.multiple = false;
|
||||||
|
fileInput.click();
|
||||||
|
};
|
||||||
|
fileInput.onchange = async () => {
|
||||||
|
try {
|
||||||
|
await uploadFiles([...fileInput.files]);
|
||||||
|
} finally {
|
||||||
|
fileInput.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
showAllBtn.onclick = () => {
|
||||||
|
filterMode = "all";
|
||||||
|
renderList();
|
||||||
|
};
|
||||||
|
showFolderBtn.onclick = () => {
|
||||||
|
filterMode = "folder";
|
||||||
|
renderList();
|
||||||
|
};
|
||||||
|
showStarBtn.onclick = () => {
|
||||||
|
filterMode = "star";
|
||||||
|
renderList();
|
||||||
|
};
|
||||||
|
search.oninput = renderList;
|
||||||
|
saveMetaBtn.onclick = () => {
|
||||||
|
const item = selectedItem();
|
||||||
|
if (!item) return;
|
||||||
|
item.label = nameInput.value.trim();
|
||||||
|
item.original_path = pathInput.value.trim();
|
||||||
|
item.folder = folderInput.value.trim() || dirname(item.original_path);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
starBtn.onclick = () => {
|
||||||
|
const item = selectedItem();
|
||||||
|
if (!item) return;
|
||||||
|
item.starred = !item.starred;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
deleteBtn.onclick = () => {
|
||||||
|
const item = selectedItem();
|
||||||
|
if (!item) return;
|
||||||
|
items = items.filter((entry) => entry.id !== item.id);
|
||||||
|
selectedId = items[0]?.id || "";
|
||||||
|
if (selectedId) selectItem(items[0]);
|
||||||
|
else {
|
||||||
|
setWidgetValue(node, "image", "");
|
||||||
|
node.imgs = [];
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
clearBtn.onclick = () => {
|
||||||
|
items = [];
|
||||||
|
selectedId = "";
|
||||||
|
setWidgetValue(node, "image", "");
|
||||||
|
node.imgs = [];
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageCallback = imageWidget.callback;
|
||||||
|
imageWidget.callback = function () {
|
||||||
|
imageCallback?.apply(this, arguments);
|
||||||
|
node.imgs = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
node.__rim_persist = persist;
|
||||||
|
|
||||||
|
const widget = node.addDOMWidget("manager", "reference-image-manager", panel, {
|
||||||
|
getValue() {
|
||||||
|
return itemsWidget.value;
|
||||||
|
},
|
||||||
|
setValue(value) {
|
||||||
|
const stored = node.properties?.rim_items || value;
|
||||||
|
items = cleanItems(parseItems(stored));
|
||||||
|
selectedId =
|
||||||
|
node.properties?.rim_selected_id ||
|
||||||
|
items.find((item) => item.image === imageWidget.value)?.id ||
|
||||||
|
items[0]?.id ||
|
||||||
|
"";
|
||||||
|
renderList();
|
||||||
|
},
|
||||||
|
serialize: false,
|
||||||
|
getMinHeight() {
|
||||||
|
return 470;
|
||||||
|
},
|
||||||
|
getMaxHeight() {
|
||||||
|
return 900;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
widget.serialize = false;
|
||||||
|
|
||||||
|
const serialize = node.onSerialize;
|
||||||
|
node.onSerialize = function (workflowNode) {
|
||||||
|
if (node.__rim_config_dirty) {
|
||||||
|
loadStateFromWidgets();
|
||||||
|
} else {
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
serialize?.apply(this, arguments);
|
||||||
|
writeSerializableState(node, workflowNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.__rim_reload = () => {
|
||||||
|
loadStateFromWidgets();
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStateFromWidgets();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "Comfy.ReferenceImageManager",
|
||||||
|
init() {
|
||||||
|
ensureStyles();
|
||||||
|
},
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
if (nodeData.name !== NODE_NAME) return;
|
||||||
|
|
||||||
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
onNodeCreated?.apply(this, arguments);
|
||||||
|
buildManager(this);
|
||||||
|
};
|
||||||
|
|
||||||
|
const configure = nodeType.prototype.configure;
|
||||||
|
nodeType.prototype.configure = function (info) {
|
||||||
|
this[CONFIG_VALUES] = info?.widgets_values ? [...info.widgets_values] : [];
|
||||||
|
this.__rim_config_dirty = !!this[CONFIG_VALUES].length;
|
||||||
|
return configure?.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = function () {
|
||||||
|
onConfigure?.apply(this, arguments);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const itemsWidget = getWidget(this, "managed_images");
|
||||||
|
if (itemsWidget && this[CONFIG_VALUES]?.length) {
|
||||||
|
const configuredItems = readConfiguredItems(this);
|
||||||
|
if (configuredItems) {
|
||||||
|
itemsWidget.value = configuredItems;
|
||||||
|
this.__rim_state_json = configuredItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.__rim_reload?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clone = nodeType.prototype.clone;
|
||||||
|
nodeType.prototype.clone = function () {
|
||||||
|
const cloned = clone?.apply(this, arguments);
|
||||||
|
if (cloned) {
|
||||||
|
cloned[CONFIG_VALUES] = [
|
||||||
|
getWidgetValue(this, "image"),
|
||||||
|
readManagedJson(this),
|
||||||
|
];
|
||||||
|
cloned.__rim_state_json = cloned[CONFIG_VALUES][1];
|
||||||
|
const imageWidget = getWidget(cloned, "image");
|
||||||
|
const itemsWidget = getWidget(cloned, "managed_images");
|
||||||
|
if (imageWidget) imageWidget.value = cloned[CONFIG_VALUES][0];
|
||||||
|
if (itemsWidget) itemsWidget.value = cloned[CONFIG_VALUES][1];
|
||||||
|
if (!cloned.properties) cloned.properties = {};
|
||||||
|
cloned.properties.rim_items = cloned[CONFIG_VALUES][1];
|
||||||
|
cloned.properties.rim_selected_id = this.properties?.rim_selected_id || "";
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSerialize = nodeType.prototype.onSerialize;
|
||||||
|
nodeType.prototype.onSerialize = function (workflowNode) {
|
||||||
|
onSerialize?.apply(this, arguments);
|
||||||
|
writeSerializableState(this, workflowNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrawBackground = nodeType.prototype.onDrawBackground;
|
||||||
|
nodeType.prototype.onDrawBackground = function () {
|
||||||
|
const oldImgs = this.imgs;
|
||||||
|
this.imgs = [];
|
||||||
|
const result = onDrawBackground?.apply(this, arguments);
|
||||||
|
this.imgs = oldImgs?.length ? [] : oldImgs;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user