Add files via upload
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
# PSWorkflowConfig.py
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import folder_paths
|
||||
import random
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
any_type = AnyType("*")
|
||||
|
||||
class PSGetNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
# 节点本身的单行文本,供 PS UI 做 Label 备用
|
||||
"ui_label": ("STRING", {"default": ""}),
|
||||
# 新增:布尔项,用于定义图像或mask的必要性(前端 JS 会根据连线类型动态显隐)
|
||||
"is_required": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
# 接受任意类型的输入(Image, Mask, Int, Float, String 等)
|
||||
"get": (any_type,),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (any_type,)
|
||||
RETURN_NAMES = ("get",)
|
||||
FUNCTION = "passthrough"
|
||||
CATEGORY = "PS_Connector"
|
||||
|
||||
def passthrough(self, ui_label="", is_required=True, get=None):
|
||||
# 运行时作为一个纯粹的直通节点,不修改任何数据
|
||||
return (get,)
|
||||
|
||||
class PSTextReceiver:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "receive_text"
|
||||
CATEGORY = "PS_Connector"
|
||||
|
||||
def receive_text(self, text):
|
||||
# 抛出 UI 字典,前端 JS websocket 会自动将其截获并识别
|
||||
return {"ui": {"text": [text]}}
|
||||
# ==========================================
|
||||
|
||||
class PSWorkflowConfig:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"lora": ("MODEL",),
|
||||
"prompt": ("STRING", {"forceInput": True}),
|
||||
"size_logic": ("BOOLEAN", {"default": True}),
|
||||
"width": ("INT", {"default": 512}),
|
||||
"height": ("INT", {"default": 512}),
|
||||
"long_side_pixels": ("INT", {"default": 1024, "min": 256, "max": 8192, "step": 8}),
|
||||
"img1_req": ("BOOLEAN", {"default": True}),
|
||||
"msk1_req": ("BOOLEAN", {"default": True}),
|
||||
"img2_req": ("BOOLEAN", {"default": True}),
|
||||
"msk2_req": ("BOOLEAN", {"default": True}),
|
||||
"img3_req": ("BOOLEAN", {"default": True}),
|
||||
"msk3_req": ("BOOLEAN", {"default": True}),
|
||||
"img4_req": ("BOOLEAN", {"default": True}),
|
||||
"msk4_req": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"image1": ("IMAGE",),
|
||||
"mask1": ("MASK",),
|
||||
"image2": ("IMAGE",),
|
||||
"mask2": ("MASK",),
|
||||
"image3": ("IMAGE",),
|
||||
"mask3": ("MASK",),
|
||||
"image4": ("IMAGE",),
|
||||
"mask4": ("MASK",),
|
||||
"neg_prompt": ("STRING", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "do_nothing"
|
||||
CATEGORY = "PS_Connector"
|
||||
def do_nothing(self, **kwargs):
|
||||
return ()
|
||||
|
||||
class PSImageAndMaskScaler:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 512}),
|
||||
"height": ("INT", {"default": 512})
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"mask": ("MASK",)
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
FUNCTION = "scale"
|
||||
CATEGORY = "PS_Connector"
|
||||
def scale(self, width, height, image=None, mask=None):
|
||||
out_image = image
|
||||
out_mask = mask
|
||||
|
||||
if image is not None:
|
||||
img = image.permute(0, 3, 1, 2)
|
||||
img = F.interpolate(img, size=(height, width), mode='bicubic', align_corners=False)
|
||||
out_image = img.permute(0, 2, 3, 1).clamp(0, 1)
|
||||
|
||||
if mask is not None:
|
||||
if len(mask.shape) == 2:
|
||||
msk = mask.unsqueeze(0).unsqueeze(0)
|
||||
elif len(mask.shape) == 3:
|
||||
msk = mask.unsqueeze(1)
|
||||
else:
|
||||
msk = mask
|
||||
msk = F.interpolate(msk, size=(height, width), mode='bicubic', align_corners=False)
|
||||
out_mask = msk.squeeze(1).clamp(0, 1)
|
||||
|
||||
if out_image is None:
|
||||
out_image = torch.zeros((1, height, width, 3), dtype=torch.float32)
|
||||
if out_mask is None:
|
||||
out_mask = torch.zeros((1, height, width), dtype=torch.float32)
|
||||
return (out_image, out_mask)
|
||||
|
||||
|
||||
# ======= 新增/修改:并列预览节点 =======
|
||||
class PSImagePreview:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_temp_directory()
|
||||
self.type = "temp"
|
||||
self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
"image1": ("IMAGE",),
|
||||
"image2": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "preview"
|
||||
CATEGORY = "PS_Connector"
|
||||
|
||||
def scale_tensor(self, tensor_img, target_width, target_height, is_mask=False):
|
||||
if is_mask:
|
||||
if len(tensor_img.shape) == 2:
|
||||
msk = tensor_img.unsqueeze(0).unsqueeze(0)
|
||||
elif len(tensor_img.shape) == 3:
|
||||
msk = tensor_img.unsqueeze(1)
|
||||
else:
|
||||
msk = tensor_img
|
||||
msk = F.interpolate(msk, size=(target_height, target_width), mode='bicubic', align_corners=False)
|
||||
return msk.squeeze(1).clamp(0, 1)
|
||||
else:
|
||||
img = tensor_img.permute(0, 3, 1, 2)
|
||||
img = F.interpolate(img, size=(target_height, target_width), mode='bicubic', align_corners=False)
|
||||
return img.permute(0, 2, 3, 1).clamp(0, 1)
|
||||
|
||||
def preview(self, image1=None, image2=None, mask=None):
|
||||
if image1 is None and image2 is None and mask is None:
|
||||
return {"ui": {"images": []}}
|
||||
|
||||
target_width = None
|
||||
target_height = None
|
||||
|
||||
# 尺寸逻辑判定
|
||||
if image2 is not None:
|
||||
target_height = image2.shape[1]
|
||||
target_width = image2.shape[2]
|
||||
elif image1 is not None:
|
||||
target_height = image1.shape[1]
|
||||
target_width = image1.shape[2]
|
||||
elif mask is not None:
|
||||
if len(mask.shape) == 2:
|
||||
target_height, target_width = mask.shape
|
||||
else:
|
||||
target_height, target_width = mask.shape[1], mask.shape[2]
|
||||
|
||||
processed_images = []
|
||||
|
||||
# 处理 image1
|
||||
if image1 is not None:
|
||||
if image1.shape[1] != target_height or image1.shape[2] != target_width:
|
||||
img1_scaled = self.scale_tensor(image1, target_width, target_height, is_mask=False)
|
||||
else:
|
||||
img1_scaled = image1
|
||||
processed_images.append(("img1", img1_scaled))
|
||||
|
||||
# 处理 image2
|
||||
if image2 is not None:
|
||||
processed_images.append(("img2", image2))
|
||||
|
||||
# 处理 mask
|
||||
if mask is not None:
|
||||
if len(mask.shape) == 2:
|
||||
h, w = mask.shape
|
||||
else:
|
||||
h, w = mask.shape[1], mask.shape[2]
|
||||
|
||||
if h != target_height or w != target_width:
|
||||
mask_scaled = self.scale_tensor(mask, target_width, target_height, is_mask=True)
|
||||
else:
|
||||
mask_scaled = mask
|
||||
|
||||
# 将 mask 转为 3 通道图以便正常显示
|
||||
if len(mask_scaled.shape) == 2:
|
||||
mask_scaled = mask_scaled.unsqueeze(0).unsqueeze(-1).repeat(1, 1, 1, 3)
|
||||
elif len(mask_scaled.shape) == 3:
|
||||
mask_scaled = mask_scaled.unsqueeze(-1).repeat(1, 1, 1, 3)
|
||||
processed_images.append(("mask", mask_scaled))
|
||||
|
||||
if not processed_images:
|
||||
return {"ui": {"images": []}}
|
||||
|
||||
results = []
|
||||
|
||||
# 分别保存每张图片,而不是拼合成一张。返回的多个图片会在 UI 中并列展示。
|
||||
for img_idx, (name, img_tensor) in enumerate(processed_images):
|
||||
for batch_number, image in enumerate(img_tensor):
|
||||
i = 255. * image.cpu().numpy()
|
||||
img_pil = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
|
||||
# 文件名包含原输入标识(img1/img2/mask)保证不会相互覆盖
|
||||
filename = f"_{name}.png"
|
||||
full_path = os.path.join(self.output_dir, filename)
|
||||
img_pil.save(full_path, compress_level=4)
|
||||
|
||||
results.append({
|
||||
"filename": filename,
|
||||
"subfolder": "",
|
||||
"type": self.type
|
||||
})
|
||||
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
# ======= 新增:获取图像尺寸及逻辑控制节点 =======
|
||||
class PSGetImageSize:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"width": ("INT", {"default": 512, "min": 1, "max": 16384, "step": 1}),
|
||||
"height": ("INT", {"default": 512, "min": 1, "max": 16384, "step": 1}),
|
||||
# 逻辑判断项:默认开启,开启时输出图像尺寸,关闭时输出输入框尺寸
|
||||
"use_image_size": ("BOOLEAN", {"default": True, "label": "Use Image Size"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "get_size"
|
||||
CATEGORY = "PS_Connector"
|
||||
|
||||
def get_size(self, image, width, height, use_image_size):
|
||||
if use_image_size:
|
||||
# ComfyUI 的图像 tensor 形状通常为 (batch, height, width, channels)
|
||||
img_height = image.shape[1]
|
||||
img_width = image.shape[2]
|
||||
return (int(img_width), int(img_height))
|
||||
else:
|
||||
return (int(width), int(height))
|
||||
|
||||
# ===============================================
|
||||
# 注册字典追加修改
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PSWorkflowConfig": PSWorkflowConfig,
|
||||
"PSImageAndMaskScaler": PSImageAndMaskScaler,
|
||||
"PSTextReceiver": PSTextReceiver,
|
||||
"PSGetNode": PSGetNode,
|
||||
"PSImagePreview": PSImagePreview,
|
||||
"PSGetImageSize": PSGetImageSize # 注册新节点
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PSWorkflowConfig": "PS Workflow Config (PS)",
|
||||
"PSImageAndMaskScaler": "PS Auto Scaler (Hidden)",
|
||||
"PSTextReceiver": "PS Text Receiver (LLM)",
|
||||
"PSGetNode": "PS Get (Custom UI)",
|
||||
"PSImagePreview": "PS Image Preview",
|
||||
"PSGetImageSize": "PS Get Image Size" # 注册新节点显示名称
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# __init__.py
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from server import PromptServer
|
||||
from .routes import get_workflows, read_txt, refresh_models
|
||||
from .PSWorkflowConfig import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# ======================================================================
|
||||
# 自动安装前置依赖:ComfyUI-Lora-Manager
|
||||
# ======================================================================
|
||||
def check_and_install_lora_manager():
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
custom_nodes_dir = os.path.dirname(current_dir)
|
||||
target_folder = "ComfyUI-Lora-Manager"
|
||||
target_path = os.path.join(custom_nodes_dir, target_folder)
|
||||
repo_url = "https://github.com/willmiao/ComfyUI-Lora-Manager.git"
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
print(f"\n[PS_Connector] 检测到缺失核心依赖: {target_folder}。正在自动为您安装...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "clone", repo_url, target_path],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
print(f"[PS_Connector] ✅ 依赖插件 {target_folder} 安装成功!")
|
||||
print(f"[PS_Connector] ⚠️ 注意:由于是首次安装依赖,建议重启 ComfyUI 以确保其完全加载。\n")
|
||||
except Exception as e:
|
||||
print(f"\n[PS_Connector] ❌ 自动安装失败!请手动前往 custom_nodes 目录执行:")
|
||||
print(f"git clone {repo_url}")
|
||||
print(f"错误详情: {e}\n")
|
||||
|
||||
check_and_install_lora_manager()
|
||||
# ======================================================================
|
||||
|
||||
server = PromptServer.instance
|
||||
server.routes.get("/ps_helper/workflows")(get_workflows)
|
||||
server.routes.post("/ps_helper/refresh")(refresh_models)
|
||||
server.routes.get("/ps_helper/read_txt")(read_txt)
|
||||
|
||||
# 告诉 ComfyUI 加载当前目录下的 js 文件夹作为前端插件
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
import json
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
def scan_folder(folder_name):
|
||||
base_paths = folder_paths.get_folder_paths(folder_name)
|
||||
if not base_paths: return []
|
||||
valid_exts = {".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin"}
|
||||
results = []
|
||||
for bp in base_paths:
|
||||
if not os.path.exists(bp): continue
|
||||
for root, dirs, files in os.walk(bp):
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext not in valid_exts: continue
|
||||
full_path = os.path.join(root, file)
|
||||
full_path_norm = os.path.normpath(full_path).replace("\\", "/")
|
||||
rel_path = os.path.relpath(full_path, bp).replace("\\", "/")
|
||||
base_path_no_ext, _ = os.path.splitext(full_path)
|
||||
json_path = base_path_no_ext + ".metadata.json" if os.path.exists(base_path_no_ext + ".metadata.json") else (base_path_no_ext + ".json" if os.path.exists(base_path_no_ext + ".json") else None)
|
||||
model_name, base_model, trained_words = "-", "-", "-"
|
||||
if json_path:
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
base_model = meta.get("base_model") or (meta.get("civitai", {}).get("baseModel") if "civitai" in meta else None) or "-"
|
||||
model_name = meta.get("model_name") or (meta.get("civitai", {}).get("model", {}).get("name") if "civitai" in meta else None) or "-"
|
||||
tw = meta.get("trainedWords") or (meta.get("civitai", {}).get("trainedWords") if "civitai" in meta else None)
|
||||
trained_words = ",".join([str(w) for w in tw]) if isinstance(tw, list) else (str(tw).strip() if isinstance(tw, str) and tw.strip() else "-")
|
||||
except: pass
|
||||
results.append({"file_name": rel_path, "model_name": model_name, "file_path": full_path_norm, "base_model": base_model, "trained_words": trained_words})
|
||||
return results
|
||||
|
||||
def update_txt(path, new_items, is_lora=False):
|
||||
# 1. 读取旧 TXT 数据,以相对路径(file_name)为 Key 建立索引
|
||||
existing_map = {}
|
||||
if os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
|
||||
# 提取干扰符号,但要记住用户的特殊标记
|
||||
has_star = line.startswith("*")
|
||||
has_amp = line.startswith("&")
|
||||
|
||||
clean_line = line.lstrip("*&")
|
||||
parts = clean_line.replace("|", "|").split("|")
|
||||
|
||||
if len(parts) >= 3:
|
||||
rel_path = parts[0].strip()
|
||||
old_base = parts[3].strip() if len(parts) > 3 else "-"
|
||||
existing_map[rel_path] = {
|
||||
"raw_line": line, # 核心:完整保留用户原来的那一行!
|
||||
"has_star": has_star,
|
||||
"has_amp": has_amp,
|
||||
"old_base": old_base
|
||||
}
|
||||
|
||||
# 2. 核心修复:基于绝对物理路径(file_path)对新扫描的数据进行去重!
|
||||
# 解决 ComfyUI scan_folder 扫描 overlapping 文件夹导致的天生重复问题
|
||||
unique_new_items = {}
|
||||
for item in new_items:
|
||||
unique_new_items[item["file_path"]] = item
|
||||
|
||||
final_lines = []
|
||||
|
||||
# 3. 构建最终写入的数据
|
||||
for item in unique_new_items.values():
|
||||
rel_path = item["file_name"]
|
||||
new_base = str(item["base_model"]).strip()
|
||||
is_new_unknown = (new_base == "-" or new_base.lower() == "unknown")
|
||||
|
||||
if rel_path in existing_map:
|
||||
# 如果是老文件,检查是否需要“智能更新底模信息”
|
||||
old_data = existing_map[rel_path]
|
||||
old_raw_line = old_data["raw_line"]
|
||||
old_base = old_data["old_base"]
|
||||
old_is_unknown = (old_base == "-" or old_base.lower() == "unknown" or old_data["has_amp"])
|
||||
|
||||
if old_is_unknown and not is_new_unknown:
|
||||
# 触发修复逻辑:以前不知道底模,现在扫到了同名 JSON 知道了底模
|
||||
prefix = "*" if old_data["has_star"] else "" # 继承用户的隐藏标记
|
||||
line = f"{prefix}{item['file_name']}|{item['model_name']}|{item['file_path']}|{item['base_model']}"
|
||||
if is_lora:
|
||||
tw = item['trained_words'] if item['trained_words'] else "-"
|
||||
line += f"|{tw}"
|
||||
final_lines.append(line)
|
||||
else:
|
||||
# 正常情况:原封不动地保留老数据!(完美保护你在 TXT 里手动写的内容和标记)
|
||||
final_lines.append(old_raw_line)
|
||||
else:
|
||||
# 这是一个全新下载的文件
|
||||
prefix = "&" if is_new_unknown else ""
|
||||
line = f"{prefix}{item['file_name']}|{item['model_name']}|{item['file_path']}|{item['base_model']}"
|
||||
if is_lora:
|
||||
tw = item['trained_words'] if item['trained_words'] else "-"
|
||||
line += f"|{tw}"
|
||||
final_lines.append(line)
|
||||
|
||||
# 4. 覆写到磁盘
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(final_lines) + "\n")
|
||||
|
||||
async def get_workflows(request):
|
||||
base_dir = os.path.join(folder_paths.base_path, "user", "default", "workflows", "PSflows")
|
||||
workflows = []
|
||||
if os.path.exists(base_dir):
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith(".json") and (file.startswith("(") or file.startswith("(")):
|
||||
try:
|
||||
with open(os.path.join(root, file), "r", encoding="utf-8") as f:
|
||||
workflows.append({"name": file[:-5], "json": json.load(f)})
|
||||
except: pass
|
||||
return web.json_response({"workflows": sorted(workflows, key=lambda x: x["name"])})
|
||||
|
||||
async def read_txt(request):
|
||||
psdate_dir = os.path.join(folder_paths.base_path, "user", "default", "PSdate")
|
||||
res = {"model_txt": "", "lora_txt": ""}
|
||||
for k in ["model", "lora"]:
|
||||
p = os.path.join(psdate_dir, f"{k}.txt")
|
||||
if os.path.exists(p):
|
||||
with open(p, "r", encoding="utf-8") as f: res[f"{k}_txt"] = f.read()
|
||||
return web.json_response(res)
|
||||
|
||||
async def refresh_models(request):
|
||||
psdate_dir = os.path.join(folder_paths.base_path, "user", "default", "PSdate")
|
||||
os.makedirs(psdate_dir, exist_ok=True)
|
||||
models = scan_folder("checkpoints") + scan_folder("diffusion_models") + scan_folder("unet")
|
||||
update_txt(os.path.join(psdate_dir, "model.txt"), models, False)
|
||||
update_txt(os.path.join(psdate_dir, "lora.txt"), scan_folder("loras"), True)
|
||||
return web.json_response({"status": "ok"})
|
||||
Reference in New Issue
Block a user