From 2f344e9e1a859dbf0437eea1bec6be1e2a136fac Mon Sep 17 00:00:00 2001 From: LAOGOU-666 <441059767@qq.com> Date: Thu, 20 Mar 2025 02:51:06 +0800 Subject: [PATCH] update --- __init__.py | 28 + py/lgutils.py | 140 +++++ py/trans.py | 498 ++++++++++++++++ web/groupexecutor.js | 527 +++++++++++++++++ web/groupexecutorUI.js | 969 +++++++++++++++++++++++++++++++ web/groupexecutorqueuemanager.js | 187 ++++++ web/groupexecutorsender.js | 284 +++++++++ 7 files changed, 2633 insertions(+) create mode 100644 __init__.py create mode 100644 py/lgutils.py create mode 100644 py/trans.py create mode 100644 web/groupexecutor.js create mode 100644 web/groupexecutorUI.js create mode 100644 web/groupexecutorqueuemanager.js create mode 100644 web/groupexecutorsender.js diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a90ab2c --- /dev/null +++ b/__init__.py @@ -0,0 +1,28 @@ +from .py.lgutils import * +from .py.trans import * + +WEB_DIRECTORY = "web" + +NODE_CLASS_MAPPINGS = { + "GroupExecutorSingle": GroupExecutorSingle, + "GroupExecutorSender": GroupExecutorSender, + "GroupExecutorRepeater": GroupExecutorRepeater, + "MemoryCleanup": MemoryCleanup, + "LG_ImageSender": LG_ImageSender, + "LG_ImageReceiver": LG_ImageReceiver, + "ImageListSplitter": ImageListSplitter, + "MaskListSplitter": MaskListSplitter, + "ImageListRepeater": ImageListRepeater, + "MaskListRepeater": MaskListRepeater, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "GroupExecutorSingle": "🎈GroupExecutorSingle", + "GroupExecutorSender": "🎈GroupExecutorSender", + "GroupExecutorRepeater": "🎈GroupExecutorRepeater", + "MemoryCleanup": "🎈Memory-Cleanup", + "LG_ImageSender": "🎈LG_ImageSender", + "LG_ImageReceiver": "🎈LG_ImageReceiver", + "ImageListSplitter": "🎈List-Image-Splitter", + "MaskListSplitter": "🎈List-Mask-Splitter", + "ImageListRepeater": "🎈List-Image-Repeater", +} diff --git a/py/lgutils.py b/py/lgutils.py new file mode 100644 index 0000000..b7f2ade --- /dev/null +++ b/py/lgutils.py @@ -0,0 +1,140 @@ +from server import PromptServer +class GroupExecutorSingle: + """单组执行节点""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "group_name": ("STRING", {"default": "", "multiline": False}), + "repeat_count": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}), + "delay_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 60.0, "step": 0.1}), + }, + "optional": { + "signal": ("SIGNAL",), + }, + "hidden": { + "unique_id": "UNIQUE_ID" + } + } + + RETURN_TYPES = ("SIGNAL",) + FUNCTION = "execute_group" + CATEGORY = "🎈LAOGOU" + + def execute_group(self, group_name, repeat_count, delay_seconds, unique_id, signal=None): + try: + + current_execution = { + "group_name": group_name, + "repeat_count": repeat_count, + "delay_seconds": delay_seconds + } + + # 如果有信号输入 + if signal is not None: + if isinstance(signal, list): + + signal.append(current_execution) + return (signal,) + else: + return ([signal, current_execution],) + + return (current_execution,) + + except Exception as e: + return ({"error": str(e)},) + +class GroupExecutorSender: + """执行信号发送节点""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "signal": ("SIGNAL",), + }, + "hidden": { + "unique_id": "UNIQUE_ID" + } + } + + RETURN_TYPES = () + FUNCTION = "execute" + CATEGORY = "🎈LAOGOU" + OUTPUT_NODE = True + + def execute(self, signal, unique_id): + try: + if not signal: + raise ValueError("没有收到执行信号") + + execution_list = signal if isinstance(signal, list) else [signal] + + PromptServer.instance.send_sync( + "execute_group_list", { + "node_id": unique_id, + "execution_list": execution_list + } + ) + + return () + + except Exception as e: + print(f"执行错误: {str(e)}") + return () + +class GroupExecutorRepeater: + """执行列表重复处理节点""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "signal": ("SIGNAL",), + "repeat_count": ("INT", { + "default": 1, + "min": 1, + "max": 100, + "step": 1 + }), + "group_delay": ("FLOAT", { + "default": 0.0, + "min": 0.0, + "max": 300.0, + "step": 0.1 + }), + }, + } + + RETURN_TYPES = ("SIGNAL",) + FUNCTION = "repeat" + CATEGORY = "🎈LAOGOU" + + def repeat(self, signal, repeat_count, group_delay): + try: + if not signal: + raise ValueError("没有收到执行信号") + + execution_list = signal if isinstance(signal, list) else [signal] + + repeated_list = [] + for i in range(repeat_count): + + repeated_list.extend(execution_list) + + if i < repeat_count - 1: + + repeated_list.append({ + "group_name": "__delay__", + "repeat_count": 1, + "delay_seconds": group_delay + }) + + return (repeated_list,) + + except Exception as e: + print(f"重复处理错误: {str(e)}") + return ([],) + + diff --git a/py/trans.py b/py/trans.py new file mode 100644 index 0000000..c9c289e --- /dev/null +++ b/py/trans.py @@ -0,0 +1,498 @@ +from server import PromptServer +import os +import sys +import time +import torch +import numpy as np +from PIL import Image +import folder_paths + +class AnyType(str): + """用于表示任意类型的特殊类,在类型比较时总是返回相等""" + def __eq__(self, _) -> bool: + return True + + def __ne__(self, __value: object) -> bool: + return False + +any = AnyType("*") + +class MemoryCleanup: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "anything": (any, {}), + "offload_model": ("BOOLEAN", {"default": True}), + "offload_cache": ("BOOLEAN", {"default": True}), + }, + "optional": {}, + "hidden": { + "unique_id": "UNIQUE_ID", + "extra_pnginfo": "EXTRA_PNGINFO", + } + } + + RETURN_TYPES = (any,) + RETURN_NAMES = ("output",) + OUTPUT_NODE = True + FUNCTION = "empty_cache" + CATEGORY = "Memory Management" + + def empty_cache(self, anything, offload_model, offload_cache, unique_id=None, extra_pnginfo=None): + try: + # 发送信号到前端 + PromptServer.instance.send_sync("memory_cleanup", { + "type": "cleanup_request", + "data": { + "unload_models": offload_model, + "free_memory": offload_cache + } + }) + print("已发送内存清理信号") + + except Exception as e: + print(f"发送内存清理信号出错: {str(e)}") + + return (anything,) + +class LG_ImageSender: + def __init__(self): + self.output_dir = folder_paths.get_temp_directory() + self.type = "temp" + self.compress_level = 1 + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE", {"tooltip": "要发送的图像"}), + "filename_prefix": ("STRING", {"default": "lg_send"}), + "link_id": ("INT", {"default": 1, "min": 0, "max": sys.maxsize, "step": 1, "tooltip": "发送端连接ID"}), + "trigger_always": ("BOOLEAN", {"default": False, "tooltip": "开启后每次都会触发"}) + }, + "optional": { + "masks": ("MASK", {"tooltip": "要发送的遮罩"}) + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "save_images" + CATEGORY = "🎈LAOGOU" + INPUT_IS_LIST = True + + @classmethod + def IS_CHANGED(s, images, filename_prefix, link_id, trigger_always, masks=None, prompt=None, extra_pnginfo=None): + if isinstance(trigger_always, list): + trigger_always = trigger_always[0] + + if trigger_always: + return float("NaN") + + # 同时考虑图像和遮罩的变化 + hash_value = hash(str(images) + str(masks)) + return hash_value + + def save_images(self, images, filename_prefix, link_id, trigger_always, masks=None, prompt=None, extra_pnginfo=None): + timestamp = int(time.time() * 1000) + results = list() + + # 获取实际的值 + filename_prefix = filename_prefix[0] if isinstance(filename_prefix, list) else filename_prefix + link_id = link_id[0] if isinstance(link_id, list) else link_id + trigger_always = trigger_always[0] if isinstance(trigger_always, list) else trigger_always + + for idx, image_batch in enumerate(images): + try: + image = image_batch.squeeze() + # 转换为PIL图像 + rgb_image = Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8)) + + # 获取对应的遮罩或创建空遮罩 + if masks is not None and idx < len(masks): + mask = masks[idx].squeeze() + mask_img = Image.fromarray(np.clip(255. * (1 - mask.cpu().numpy()), 0, 255).astype(np.uint8)) + else: + mask_img = Image.new('L', rgb_image.size, 255) + + # 合并RGB和遮罩为RGBA + r, g, b = rgb_image.convert('RGB').split() + rgba_image = Image.merge('RGBA', (r, g, b, mask_img)) + + # 保存RGBA图像 + filename = f"{filename_prefix}_{link_id}_{timestamp}_{idx}.png" + file_path = os.path.join(self.output_dir, filename) + print(f"[ImageSender] 发送图像: {filename}") + + rgba_image.save(file_path, compress_level=self.compress_level) + + results.append({ + "filename": filename, + "subfolder": "", + "type": self.type + }) + + except Exception as e: + print(f"[ImageSender] 处理图像 {idx+1} 时出错: {str(e)}") + import traceback + traceback.print_exc() + continue + + if results: + PromptServer.instance.send_sync("img-send", { + "link_id": link_id, + "images": results + }) + + return { "ui": { "images": results } } + +class LG_ImageReceiver: + @classmethod + def INPUT_TYPES(s): + temp_dir = folder_paths.get_temp_directory() + files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))] + + return { + "required": { + "image": ("STRING", {"default": "", "multiline": False, "tooltip": "多个文件名用逗号分隔"}), + "link_id": ("INT", {"default": 1, "min": 0, "max": sys.maxsize, "step": 1, "tooltip": "发送端连接ID"}), + } + } + + CATEGORY = "🎈LAOGOU" + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("images", "masks") + OUTPUT_IS_LIST = (True, True) + FUNCTION = "load_image" + + def load_image(self, image, link_id): + image_files = [x.strip() for x in image.split(',') if x.strip()] + print(f"[ImageReceiver] 加载图像: {image_files}") + + output_images = [] + output_masks = [] + + if not image_files: + empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32) + empty_mask = torch.zeros((1, 64, 64), dtype=torch.float32) + return ([empty_image], [empty_mask]) + + temp_dir = folder_paths.get_temp_directory() + + for img_file in image_files: + try: + img_path = os.path.join(temp_dir, img_file) + + if not os.path.exists(img_path): + continue + + # 加载RGBA图像 + img = Image.open(img_path) + + if img.mode == 'RGBA': + # 分离RGB和Alpha通道 + r, g, b, a = img.split() + # 合并RGB通道 + rgb_image = Image.merge('RGB', (r, g, b)) + # 转换为tensor + image = np.array(rgb_image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + # 转换alpha为mask,并确保维度是 (B, H, W) + mask = np.array(a).astype(np.float32) / 255.0 + mask = torch.from_numpy(mask)[None,] # 添加batch维度 + # 反转遮罩值 + mask = 1.0 - mask + else: + # 如果不是RGBA,按原来的方式处理 + image = np.array(img.convert('RGB')).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + mask = torch.zeros((1, image.shape[1], image.shape[2]), dtype=torch.float32, device="cpu") + + output_images.append(image) + output_masks.append(mask) + + except Exception as e: + print(f"[ImageReceiver] 处理文件 {img_file} 时出错: {str(e)}") + import traceback + traceback.print_exc() + continue + + return (output_images, output_masks) + +class ImageListSplitter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "indices": ("STRING", { + "default": "", + "multiline": False, + "tooltip": "输入要提取的图片索引,用逗号分隔,如:0,1,3,4" + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("images",) + FUNCTION = "split_images" + CATEGORY = "🎈LAOGOU" + + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) # (images,) + + def split_images(self, images, indices): + try: + # 解析索引字符串 + try: + if isinstance(indices, list): + indices = indices[0] if indices else "" + indices = [int(idx.strip()) for idx in indices.split(',') if idx.strip()] + except ValueError: + print("[ImageSplitter] 索引格式错误,请使用逗号分隔的数字") + return ([],) + + # 确保images是列表 + if not isinstance(images, list): + images = [images] + + # 处理批量图片的情况 + if len(images) == 1 and len(images[0].shape) == 4: # [B, H, W, C] + batch_images = images[0] + total_images = batch_images.shape[0] + print(f"[ImageSplitter] 检测到批量图片,总数: {total_images}") + + selected_images = [] + for idx in indices: + if 0 <= idx < total_images: + # 保持批次维度,使用unsqueeze确保维度为 [1, H, W, C] + img = batch_images[idx].unsqueeze(0) + selected_images.append(img) + print(f"[ImageSplitter] 从批量中选择第 {idx} 张图片") + else: + print(f"[ImageSplitter] 索引 {idx} 超出批量范围 0-{total_images-1}") + + if not selected_images: + return ([],) + return (selected_images,) + + # 处理图片列表的情况 + total_images = len(images) + print(f"[ImageSplitter] 检测到图片列表,总数: {total_images}") + + if total_images == 0: + print("[ImageSplitter] 没有输入图片") + return ([],) + + selected_images = [] + for idx in indices: + if 0 <= idx < total_images: + selected_image = images[idx] + # 确保输出维度为 [1, H, W, C] + if len(selected_image.shape) == 3: # [H, W, C] + selected_image = selected_image.unsqueeze(0) + selected_images.append(selected_image) + print(f"[ImageSplitter] 从列表中选择第 {idx} 张图片") + else: + print(f"[ImageSplitter] 索引 {idx} 超出列表范围 0-{total_images-1}") + + if not selected_images: + return ([],) + return (selected_images,) + + except Exception as e: + print(f"[ImageSplitter] 处理出错: {str(e)}") + return ([],) + +class MaskListSplitter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "masks": ("MASK",), + "indices": ("STRING", { + "default": "", + "multiline": False, + "tooltip": "输入要提取的遮罩索引,用逗号分隔,如:0,1,3,4" + }), + }, + } + + RETURN_TYPES = ("MASK",) + RETURN_NAMES = ("masks",) + FUNCTION = "split_masks" + CATEGORY = "🎈LAOGOU" + + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) # (masks,) + + def split_masks(self, masks, indices): + try: + # 解析索引字符串 + try: + if isinstance(indices, list): + indices = indices[0] if indices else "" + indices = [int(idx.strip()) for idx in indices.split(',') if idx.strip()] + except ValueError: + print("[MaskSplitter] 索引格式错误,请使用逗号分隔的数字") + return ([],) + + # 确保masks是列表 + if not isinstance(masks, list): + masks = [masks] + + # 处理批量遮罩的情况 + if len(masks) == 1 and len(masks[0].shape) == 3: # [B, H, W] + batch_masks = masks[0] + total_masks = batch_masks.shape[0] + print(f"[MaskSplitter] 检测到批量遮罩,总数: {total_masks}") + + selected_masks = [] + for idx in indices: + if 0 <= idx < total_masks: + selected_masks.append(batch_masks[idx].unsqueeze(0)) + print(f"[MaskSplitter] 从批量中选择第 {idx} 个遮罩") + else: + print(f"[MaskSplitter] 索引 {idx} 超出批量范围 0-{total_masks-1}") + + if not selected_masks: + return ([],) + return (selected_masks,) + + # 处理遮罩列表的情况 + total_masks = len(masks) + print(f"[MaskSplitter] 检测到遮罩列表,总数: {total_masks}") + + if total_masks == 0: + print("[MaskSplitter] 没有输入遮罩") + return ([],) + + selected_masks = [] + for idx in indices: + if 0 <= idx < total_masks: + selected_mask = masks[idx] + if len(selected_mask.shape) == 2: # [H, W] + selected_mask = selected_mask.unsqueeze(0) + elif len(selected_mask.shape) != 3: # 不是 [B, H, W] + print(f"[MaskSplitter] 不支持的遮罩维度: {selected_mask.shape}") + continue + selected_masks.append(selected_mask) + print(f"[MaskSplitter] 从列表中选择第 {idx} 个遮罩") + else: + print(f"[MaskSplitter] 索引 {idx} 超出列表范围 0-{total_masks-1}") + + if not selected_masks: + return ([],) + return (selected_masks,) + + except Exception as e: + print(f"[MaskSplitter] 处理出错: {str(e)}") + return ([],) + +class ImageListRepeater: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "repeat_times": ("INT", { + "default": 1, + "min": 1, + "max": 100, + "step": 1, + "tooltip": "每张图片重复的次数" + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("images",) + FUNCTION = "repeat_images" + CATEGORY = "🎈LAOGOU" + + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) + + def repeat_images(self, images, repeat_times): + try: + # 处理 repeat_times 参数 + if isinstance(repeat_times, list): + repeat_times = repeat_times[0] if repeat_times else 1 + + # 确保images是列表 + if not isinstance(images, list): + images = [images] + + if len(images) == 0: + print("[ImageRepeater] 没有输入图片") + return ([],) + + # 创建重复后的图片列表 + repeated_images = [] + for idx, img in enumerate(images): + for _ in range(int(repeat_times)): # 确保 repeat_times 是整数 + repeated_images.append(img) + print(f"[ImageRepeater] 图片 {idx} 重复 {repeat_times} 次") + + print(f"[ImageRepeater] 输入 {len(images)} 张图片,输出 {len(repeated_images)} 张图片") + return (repeated_images,) + + except Exception as e: + print(f"[ImageRepeater] 处理出错: {str(e)}") + return ([],) + +class MaskListRepeater: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "masks": ("MASK",), + "repeat_times": ("INT", { + "default": 1, + "min": 1, + "max": 100, + "step": 1, + "tooltip": "每张遮罩重复的次数" + }), + }, + } + + RETURN_TYPES = ("MASK",) + RETURN_NAMES = ("masks",) + FUNCTION = "repeat_masks" + CATEGORY = "🎈LAOGOU" + + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) + + def repeat_masks(self, masks, repeat_times): + try: + # 处理 repeat_times 参数 + if isinstance(repeat_times, list): + repeat_times = repeat_times[0] if repeat_times else 1 + + # 确保masks是列表 + if not isinstance(masks, list): + masks = [masks] + + if len(masks) == 0: + print("[MaskRepeater] 没有输入遮罩") + return ([],) + + # 创建重复后的遮罩列表 + repeated_masks = [] + for idx, mask in enumerate(masks): + for _ in range(int(repeat_times)): # 确保 repeat_times 是整数 + repeated_masks.append(mask) + print(f"[MaskRepeater] 遮罩 {idx} 重复 {repeat_times} 次") + + print(f"[MaskRepeater] 输入 {len(masks)} 个遮罩,输出 {len(repeated_masks)} 个遮罩") + return (repeated_masks,) + + except Exception as e: + print(f"[MaskRepeater] 处理出错: {str(e)}") + return ([],) + + diff --git a/web/groupexecutor.js b/web/groupexecutor.js new file mode 100644 index 0000000..e7f48bc --- /dev/null +++ b/web/groupexecutor.js @@ -0,0 +1,527 @@ +import { app } from "../../scripts/app.js"; +import { ComfyWidgets } from "../../scripts/widgets.js"; +import { api } from "../../scripts/api.js"; + +class BaseNode extends LGraphNode { + static defaultComfyClass = "BaseNode"; + constructor(title, comfyClass) { + super(title); + this.isVirtualNode = false; + this.configuring = false; + this.__constructed__ = false; + this.widgets = this.widgets || []; + this.properties = this.properties || {}; + + this.comfyClass = comfyClass || this.constructor.comfyClass || BaseNode.defaultComfyClass; + setTimeout(() => { + this.checkAndRunOnConstructed(); + }); + } + + checkAndRunOnConstructed() { + if (!this.__constructed__) { + this.onConstructed(); + } + return this.__constructed__; + } + + onConstructed() { + if (this.__constructed__) return false; + this.type = this.type ?? undefined; + this.__constructed__ = true; + return this.__constructed__; + } + + configure(info) { + this.configuring = true; + super.configure(info); + for (const w of this.widgets || []) { + w.last_y = w.last_y || 0; + } + this.configuring = false; + } + static setUp() { + if (!this.type) { + throw new Error(`Missing type for ${this.name}: ${this.title}`); + } + LiteGraph.registerNodeType(this.type, this); + if (this._category) { + this.category = this._category; + } + } +} + +class GroupExecutorNode extends BaseNode { + static type = "GroupExecutor"; + static title = "Group Executor"; + static category = "🎈LAOGOU"; + static _category = "🎈LAOGOU"; + + constructor(title = GroupExecutorNode.title) { + super(title, null); + + this.isVirtualNode = true; + this.addProperty("groupCount", 1, "int"); + this.addProperty("groups", [], "array"); + this.addProperty("isExecuting", false, "boolean"); + this.addProperty("repeatCount", 1, "int"); + this.addProperty("delaySeconds", 0, "number"); // 新增:延迟时间属性 + + // 组数量组件 + const groupCountWidget = ComfyWidgets["INT"](this, "groupCount", ["INT", { + min: 1, + max: 10, + step: 1, + default: 1 + }], app); + + // 重复次数组件 + const repeatCountWidget = ComfyWidgets["INT"](this, "repeatCount", ["INT", { + min: 1, + max: 100, + step: 1, + default: 1, + label: "Repeat Count", + tooltip: "执行重复次数" + }], app); + + // 新增:延迟时间组件 + const delayWidget = ComfyWidgets["FLOAT"](this, "delaySeconds", ["FLOAT", { + min: 0, + max: 300, // 最大300秒 + step: 0.1, + default: 0, + label: "Delay (s)", + tooltip: "队列之间的延迟时间(秒)" + }], app); + + // 调整组件顺序 + if (repeatCountWidget.widget && delayWidget.widget) { + const widgets = [repeatCountWidget.widget, delayWidget.widget]; + widgets.forEach((widget, index) => { + const widgetIndex = this.widgets.indexOf(widget); + if (widgetIndex !== -1) { + const w = this.widgets.splice(widgetIndex, 1)[0]; + this.widgets.splice(1 + index, 0, w); + } + }); + } + + // 监听值变化 + groupCountWidget.widget.callback = (v) => { + this.properties.groupCount = Math.max(1, Math.min(10, parseInt(v) || 1)); + this.updateGroupWidgets(); + }; + + repeatCountWidget.widget.callback = (v) => { + this.properties.repeatCount = Math.max(1, Math.min(100, parseInt(v) || 1)); + }; + + delayWidget.widget.callback = (v) => { + this.properties.delaySeconds = Math.max(0, Math.min(300, parseFloat(v) || 0)); + }; + + // 添加执行按钮 + this.addWidget("button", "Execute Groups", "Execute", () => { + this.executeGroups(); + }); + + // 添加取消按钮(放在执行按钮后面) + this.addWidget("button", "Cancel", "Cancel", () => { + this.cancelExecution(); + }); + + // 添加取消状态属性 + this.addProperty("isCancelling", false, "boolean"); + + // 初始化组选择下拉框 + this.updateGroupWidgets(); + + // 监听画布变化以更新组列表 + const self = this; + app.canvas.onDrawBackground = (() => { + const original = app.canvas.onDrawBackground; + return function() { + self.updateGroupList(); + return original?.apply(this, arguments); + }; + })(); + + // 保存原始标题 + this.originalTitle = title; + } + + // 获取所有组名称 + getGroupNames() { + return [...app.graph._groups].map(g => g.title).sort(); + } + + // 获取指定组的输出节点 + getGroupOutputNodes(groupName) { + // 找到指定名称的组 + const group = app.graph._groups.find(g => g.title === groupName); + if (!group) { + console.warn(`[GroupExecutor] 未找到名为 "${groupName}" 的组`); + return []; + } + + // 获取组内的所有节点 + const groupNodes = []; + for (const node of app.graph._nodes) { + if (!node || !node.pos) continue; + if (LiteGraph.overlapBounding(group._bounding, node.getBounding())) { + groupNodes.push(node); + } + } + group._nodes = groupNodes; + + // 获取输出节点 + return this.getOutputNodes(group._nodes); + } + + // 获取输出节点 + getOutputNodes(nodes) { + return nodes.filter((n) => { + return n.mode !== LiteGraph.NEVER && + n.constructor.nodeData?.output_node === true; + }); + } + + // 更新组选择下拉框 + updateGroupWidgets() { + // 保留已有的组选择,只初始化新增的 + const currentGroups = [...this.properties.groups]; + this.properties.groups = new Array(this.properties.groupCount).fill("").map((_, i) => + currentGroups[i] || "" + ); + + // 1. 首先移除所有组选择下拉框 + this.widgets = this.widgets.filter(w => + w.name === "groupCount" || + w.name === "repeatCount" || + w.name === "delaySeconds" || + w.name === "Execute Groups" || + w.name === "Cancel" + ); + + // 2. 获取执行按钮和取消按钮的引用 + const executeButton = this.widgets.find(w => w.name === "Execute Groups"); + const cancelButton = this.widgets.find(w => w.name === "Cancel"); + + // 3. 如果存在这些按钮,先从widgets中移除 + if (executeButton) { + this.widgets = this.widgets.filter(w => w.name !== "Execute Groups"); + } + if (cancelButton) { + this.widgets = this.widgets.filter(w => w.name !== "Cancel"); + } + + // 4. 添加组选择下拉框 + const groupNames = this.getGroupNames(); + for (let i = 0; i < this.properties.groupCount; i++) { + const widget = this.addWidget( + "combo", + `Group #${i + 1}`, + this.properties.groups[i] || "", + (v) => { + this.properties.groups[i] = v; + }, + { + values: groupNames + } + ); + } + + // 5. 最后重新添加执行按钮和取消按钮 + if (executeButton) { + this.widgets.push(executeButton); + } + if (cancelButton) { + this.widgets.push(cancelButton); + } + + // 6. 更新节点大小 + this.size = this.computeSize(); + } + + // 更新组列表 + updateGroupList() { + const groups = this.getGroupNames(); + this.widgets.forEach(w => { + if (w.type === "combo") { + w.options.values = groups; + } + }); + } + + // 添加延迟函数 + async delay(seconds) { + if (seconds <= 0) return; + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); + } + + // 更新状态显示 + updateStatus(text) { + // 更新节点标题来显示状态 + this.title = `${this.originalTitle} - ${text}`; + this.setDirtyCanvas(true, true); + } + + // 重置状态显示 + resetStatus() { + this.title = this.originalTitle; + this.setDirtyCanvas(true, true); + } + + // 修改取消方法 + async cancelExecution() { + if (!this.properties.isExecuting) { + console.warn('[GroupExecutor] 没有正在执行的任务'); + return; + } + + try { + this.properties.isCancelling = true; + this.updateStatus("已取消"); + + // 中断当前执行的队列 + await fetch('/interrupt', { method: 'POST' }); + + // 不需要清理待执行队列,因为通过 isCancelling 标志会阻止新队列的添加 + + } catch (error) { + console.error('[GroupExecutor] 取消执行时出错:', error); + this.updateStatus(`取消失败: ${error.message}`); + } + } + + // 修改执行方法,添加取消检查 + async executeGroups() { + if (this.properties.isExecuting) { + console.warn('[GroupExecutor] 已有执行任务在进行中'); + return; + } + + this.properties.isExecuting = true; + this.properties.isCancelling = false; + const totalSteps = this.properties.repeatCount * this.properties.groupCount; + let currentStep = 0; + + try { + for (let repeat = 0; repeat < this.properties.repeatCount; repeat++) { + for (let i = 0; i < this.properties.groupCount; i++) { + // 检查是否已取消 + if (this.properties.isCancelling) { + console.log('[GroupExecutor] 执行被用户取消'); + // 中断当前执行的队列 + await fetch('/interrupt', { method: 'POST' }); + this.updateStatus("已取消"); + setTimeout(() => this.resetStatus(), 2000); + return; + } + + const groupName = this.properties.groups[i]; + if (!groupName) continue; + + currentStep++; + this.updateStatus( + `${currentStep}/${totalSteps} - ${groupName}` + ); + + const outputNodes = this.getGroupOutputNodes(groupName); + if (outputNodes && outputNodes.length > 0) { + try { + if (!rgthree || !rgthree.queueOutputNodes) { + throw new Error('rgthree.queueOutputNodes 不可用'); + } + + const nodeIds = outputNodes.map(n => n.id); + + try { + // 在每个队列执行前检查取消状态 + if (this.properties.isCancelling) { + return; + } + await rgthree.queueOutputNodes(nodeIds); + await this.waitForQueue(); + } catch (queueError) { + // 在回退执行前检查取消状态 + if (this.properties.isCancelling) { + return; + } + console.warn(`[GroupExecutorSender] rgthree执行失败,使用默认方式:`, queueError); + for (const n of outputNodes) { + if (this.properties.isCancelling) { + return; + } + if (n.triggerQueue) { + await n.triggerQueue(); + await this.waitForQueue(); + } + } + } + + if (i < this.properties.groupCount - 1) { + // 延迟前检查是否取消 + if (this.properties.isCancelling) { + return; + } + this.updateStatus( + `等待 ${this.properties.delaySeconds}s...` + ); + await this.delay(this.properties.delaySeconds); + } + } catch (error) { + console.error(`[GroupExecutor] 执行组 ${groupName} 时发生错误:`, error); + throw error; + } + } + } + + if (repeat < this.properties.repeatCount - 1) { + // 重复前检查是否取消 + if (this.properties.isCancelling) { + return; + } + await this.delay(this.properties.delaySeconds); + } + } + + if (!this.properties.isCancelling) { + this.updateStatus("完成"); + setTimeout(() => this.resetStatus(), 2000); + } + } catch (error) { + console.error('[GroupExecutor] 执行错误:', error); + this.updateStatus(`错误: ${error.message}`); + app.ui.dialog.show(`执行错误: ${error.message}`); + } finally { + this.properties.isExecuting = false; + this.properties.isCancelling = false; + } + } + + // 获取队列状态 + async getQueueStatus() { + try { + const response = await fetch('/queue'); + const data = await response.json(); + + return { + isRunning: data.queue_running.length > 0, + isPending: data.queue_pending.length > 0, + runningCount: data.queue_running.length, + pendingCount: data.queue_pending.length, + // 保存原始数据用于调试 + rawRunning: data.queue_running, + rawPending: data.queue_pending + }; + } catch (error) { + console.error('[GroupExecutor] 获取队列状态失败:', error); + // 返回默认状态而不是 null,避免空值判断 + return { + isRunning: false, + isPending: false, + runningCount: 0, + pendingCount: 0, + rawRunning: [], + rawPending: [] + }; + } + } + + // 等待队列完成 + async waitForQueue() { + return new Promise((resolve, reject) => { + const checkQueue = async () => { + try { + const status = await this.getQueueStatus(); + + // 只有当运行队列和等待队列都为空时才算完成 + if (!status.isRunning && !status.isPending) { + // 额外等待100ms确保状态完全更新 + setTimeout(resolve, 100); + return; + } + + // 继续检查队列状态,使用较短的间隔以提高响应性 + setTimeout(checkQueue, 500); + } catch (error) { + console.warn(`[GroupExecutor] 检查队列状态失败:`, error); + // 发生错误时继续检查,而不是中断 + setTimeout(checkQueue, 500); + } + }; + + // 开始检查队列 + checkQueue(); + }); + } + + // 修改计算节点大小方法以适应新按钮 + computeSize() { + const widgetHeight = 28; + const padding = 4; + const width = 200; + const height = (this.properties.groupCount + 4) * widgetHeight + padding * 2; // +4 包含重复次数组件和取消按钮 + return [width, height]; + } + + static setUp() { + LiteGraph.registerNodeType(this.type, this); + this.category = this._category; + } + + // 序列化节点数据 + serialize() { + const data = super.serialize(); + data.properties = { + ...data.properties, + groupCount: parseInt(this.properties.groupCount) || 1, + groups: [...this.properties.groups], + isExecuting: this.properties.isExecuting, + repeatCount: parseInt(this.properties.repeatCount) || 1, + delaySeconds: parseFloat(this.properties.delaySeconds) || 0 + }; + return data; + } + + // 反序列化节点数据 + configure(info) { + super.configure(info); + + if (info.properties) { + this.properties.groupCount = parseInt(info.properties.groupCount) || 1; + this.properties.groups = info.properties.groups ? [...info.properties.groups] : []; + this.properties.isExecuting = info.properties.isExecuting ?? false; + this.properties.repeatCount = parseInt(info.properties.repeatCount) || 1; + this.properties.delaySeconds = parseFloat(info.properties.delaySeconds) || 0; + } + + this.widgets.forEach(w => { + if (w.name === "groupCount") { + w.value = this.properties.groupCount; + } else if (w.name === "repeatCount") { + w.value = this.properties.repeatCount; + } else if (w.name === "delaySeconds") { + w.value = this.properties.delaySeconds; + } + }); + + if (!this.configuring) { + this.updateGroupWidgets(); + } + } +} + + +app.registerExtension({ + name: "GroupExecutor", + registerCustomNodes() { + GroupExecutorNode.setUp(); + } +}); + + + + diff --git a/web/groupexecutorUI.js b/web/groupexecutorUI.js new file mode 100644 index 0000000..56669ee --- /dev/null +++ b/web/groupexecutorUI.js @@ -0,0 +1,969 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + + +class GroupExecutorUI { + // 使用默认值初始化静态属性 + static DOCK_MARGIN_X = 0; + static DOCK_MARGIN_Y = 40; + + constructor() { + this.container = null; + this.isExecuting = false; + this.isCancelling = false; + this.groups = []; + this.position = { x: 0, y: 0 }; + this.isDragging = false; + this.dragOffset = { x: 0, y: 0 }; + // 从静态属性获取边距值 + this.DOCK_MARGIN_X = GroupExecutorUI.DOCK_MARGIN_X; + this.DOCK_MARGIN_Y = GroupExecutorUI.DOCK_MARGIN_Y; + + this.createUI(); + this.attachEvents(); + + // 保存实例引用 + this.container.instance = this; + } + + createUI() { + // 创建主容器 + this.container = document.createElement('div'); + this.container.className = 'group-executor-ui'; + + // 设置初始位置,使用当前的边距值 + this.container.style.top = `${this.DOCK_MARGIN_Y}px`; + this.container.style.right = `${this.DOCK_MARGIN_X}px`; + + this.container.innerHTML = ` +
+ 组执行管理器 +
+ + + +
+
+
+
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + // 添加样式 + const style = document.createElement('style'); + style.textContent = ` + .group-executor-ui { + position: fixed; + top: 20px; + right: 20px; + width: 300px !important; /* 强制固定宽度 */ + min-width: 300px; + max-width: 300px; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + z-index: 1000; + font-family: Arial, sans-serif; + color: #fff; + user-select: none; /* 禁止文字选择 */ + } + + .ge-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #333; + border-radius: 8px 8px 0 0; + cursor: move; + width: 100%; + box-sizing: border-box; + } + + .ge-controls button { + background: none; + border: none; + color: #fff; + margin-left: 8px; + cursor: pointer; + font-size: 16px; + } + + .ge-content { + padding: 12px; + } + + .ge-row { + display: flex; + align-items: center; + margin-bottom: 12px; + } + + .ge-row label { + flex: 1; + margin-right: 12px; + } + + .ge-row input { + width: 100px; + padding: 4px 8px; + background: #333; + border: 1px solid #444; + color: #fff; + border-radius: 4px; + } + + .ge-groups-container { + margin-bottom: 12px; + } + + .ge-group-select { + width: 100%; + margin-bottom: 8px; + padding: 4px 8px; + background: #333; + border: 1px solid #444; + color: #fff; + border-radius: 4px; + } + + .ge-buttons { + display: flex; + gap: 8px; + } + + .ge-buttons button { + flex: 1; + padding: 8px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + } + + .ge-execute-btn { + background: #4CAF50; + color: white; + } + + .ge-execute-btn:disabled { + background: #2a5a2d; + cursor: not-allowed; + } + + .ge-cancel-btn { + background: #f44336; + color: white; + } + + .ge-cancel-btn:disabled { + background: #7a2520; + cursor: not-allowed; + } + + .ge-status { + margin: 12px 0; + padding: 8px; + background: #333; + border-radius: 4px; + min-height: 20px; + text-align: center; + position: relative; + overflow: hidden; /* 确保进度条不会溢出 */ + } + + .ge-status::before { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: var(--progress, 0%); /* 使用CSS变量控制宽度 */ + background: rgba(36, 145, 235, 0.8); /* 绿色半透明背景 */ + transition: width 0.3s ease; + z-index: 0; + } + + .ge-status span { + position: relative; + z-index: 1; /* 确保文字在进度条上层 */ + } + + .ge-minimized { + width: auto !important; + min-width: auto; + } + + .ge-minimized .ge-content { + display: none; + } + + .ge-dock-menu { + position: absolute; + background: #333; + border: 1px solid #444; + border-radius: 4px; + padding: 4px 0; + z-index: 1001; // 确保菜单在最上层 + visibility: hidden; // 改用 visibility 代替 display + opacity: 0; + transition: opacity 0.2s; + } + + .ge-dock-menu.visible { + visibility: visible; + opacity: 1; + } + + .ge-dock-menu button { + display: block; + width: 100%; + padding: 4px 12px; + background: none; + border: none; + color: #fff; + text-align: left; + cursor: pointer; + } + + .ge-dock-menu button:hover { + background: #444; + } + + .ge-title { + flex: 1; + pointer-events: none; /* 让鼠标事件穿透到父元素 */ + white-space: nowrap; /* 防止文字换行 */ + overflow: hidden; /* 防止文字溢出 */ + text-overflow: ellipsis; /* 文字溢出时显示省略号 */ + } + + .ge-config-row { + display: flex; + gap: 8px; + margin-bottom: 12px; + } + + .ge-config-select { + flex: 1; + padding: 4px 8px; + background: #333; + border: 1px solid #444; + color: #fff; + border-radius: 4px; + } + + .ge-save-config, + .ge-delete-config { + background: #333; + border: 1px solid #444; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + } + + .ge-save-config:hover, + .ge-delete-config:hover { + background: #444; + } + + .ge-delete-config:disabled { + opacity: 0.5; + cursor: not-allowed; + } + `; + + document.head.appendChild(style); + document.body.appendChild(this.container); + } + + attachEvents() { + // 拖拽功能 + const header = this.container.querySelector('.ge-header'); + header.addEventListener('mousedown', (e) => { + // 检查点击的不是控制按钮 + if (!e.target.matches('.ge-controls button')) { + this.isDragging = true; + const rect = this.container.getBoundingClientRect(); + this.dragOffset = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + } + }); + + document.addEventListener('mousemove', (e) => { + if (this.isDragging) { + const x = e.clientX - this.dragOffset.x; + const y = e.clientY - this.dragOffset.y; + this.container.style.left = `${x}px`; + this.container.style.top = `${y}px`; + } + }); + + document.addEventListener('mouseup', () => { + this.isDragging = false; + }); + + // 停靠按钮 + const dockBtn = this.container.querySelector('.ge-dock-btn'); + dockBtn.addEventListener('click', () => { + this.showDockMenu(dockBtn); + }); + + // 最小化按钮 + const minimizeBtn = this.container.querySelector('.ge-minimize-btn'); + minimizeBtn.addEventListener('click', () => { + this.container.classList.toggle('ge-minimized'); + minimizeBtn.textContent = this.container.classList.contains('ge-minimized') ? '+' : '-'; + }); + + // 关闭按钮 + const closeBtn = this.container.querySelector('.ge-close-btn'); + closeBtn.addEventListener('click', () => { + this.container.remove(); + }); + + // 组数量变化 + const groupCountInput = this.container.querySelector('.ge-group-count'); + groupCountInput.addEventListener('change', () => { + this.updateGroupSelects(parseInt(groupCountInput.value)); + }); + + // 执行按钮 + const executeBtn = this.container.querySelector('.ge-execute-btn'); + executeBtn.addEventListener('click', () => { + this.executeGroups(); + }); + + // 取消按钮 + const cancelBtn = this.container.querySelector('.ge-cancel-btn'); + cancelBtn.addEventListener('click', () => { + this.cancelExecution(); + }); + + // 初始化组选择 + this.updateGroupSelects(1); + + // 添加窗口大小变化的监听 + window.addEventListener('resize', () => { + this.ensureInViewport(); + }); + + // 配置相关事件 + const deleteConfigBtn = this.container.querySelector('.ge-delete-config'); + const saveConfigBtn = this.container.querySelector('.ge-save-config'); + const configSelect = this.container.querySelector('.ge-config-select'); + + // 更新删除按钮状态 + const updateDeleteButton = () => { + deleteConfigBtn.disabled = !configSelect.value; + }; + + // 配置选择事件 + configSelect.addEventListener('change', () => { + updateDeleteButton(); + if (configSelect.value) { + this.loadConfig(configSelect.value); + } + }); + + // 保存配置事件 + saveConfigBtn.addEventListener('click', () => { + this.saveCurrentConfig(); + }); + + // 删除配置事件 + deleteConfigBtn.addEventListener('click', () => { + const configName = configSelect.value; + if (configName) { + this.deleteConfig(configName); + } + }); + + // 初始化删除按钮状态 + updateDeleteButton(); + + // 初始化加载配置列表 + this.loadConfigs(); + } + + showDockMenu(button) { + // 移除已存在的菜单 + const existingMenu = document.querySelector('.ge-dock-menu'); + if (existingMenu) { + existingMenu.remove(); + return; + } + + const menu = document.createElement('div'); + menu.className = 'ge-dock-menu'; + menu.innerHTML = ` + + + + + `; + + // 将菜单添加到容器内部而不是 body + this.container.appendChild(menu); + + // 计算菜单位置 + const buttonRect = button.getBoundingClientRect(); + const containerRect = this.container.getBoundingClientRect(); + + menu.style.left = `${buttonRect.left - containerRect.left}px`; + menu.style.top = `${buttonRect.bottom - containerRect.top + 5}px`; + + // 使用 requestAnimationFrame 确保过渡动画正常工作 + requestAnimationFrame(() => { + menu.classList.add('visible'); + }); + + menu.addEventListener('click', (e) => { + const position = e.target.dataset.position; + if (position) { + this.dockTo(position); + menu.classList.remove('visible'); + setTimeout(() => menu.remove(), 200); // 等待过渡动画完成后移除 + } + }); + + // 点击其他地方关闭菜单 + const closeMenu = (e) => { + if (!menu.contains(e.target) && e.target !== button) { + menu.classList.remove('visible'); + setTimeout(() => menu.remove(), 200); + document.removeEventListener('click', closeMenu); + } + }; + + // 延迟添加点击监听,避免立即触发 + setTimeout(() => { + document.addEventListener('click', closeMenu); + }, 0); + } + + dockTo(position) { + const style = this.container.style; + style.transition = 'all 0.3s ease'; + + // 使用实例的边距值 + const marginX = this.DOCK_MARGIN_X; + const marginY = this.DOCK_MARGIN_Y; + + switch (position) { + case 'top-left': + style.top = `${marginY}px`; + style.left = `${marginX}px`; + style.right = 'auto'; + style.bottom = 'auto'; + break; + case 'top-right': + style.top = `${marginY}px`; + style.right = `${marginX}px`; + style.left = 'auto'; + style.bottom = 'auto'; + break; + case 'bottom-left': + style.bottom = `${marginY}px`; + style.left = `${marginX}px`; + style.right = 'auto'; + style.top = 'auto'; + break; + case 'bottom-right': + style.bottom = `${marginY}px`; + style.right = `${marginX}px`; + style.left = 'auto'; + style.top = 'auto'; + break; + } + + setTimeout(() => { + style.transition = ''; + }, 300); + } + + updateGroupSelects(count) { + const container = this.container.querySelector('.ge-groups-container'); + container.innerHTML = ''; + + const groupNames = this.getGroupNames(); + + for (let i = 0; i < count; i++) { + const select = document.createElement('select'); + select.className = 'ge-group-select'; + select.innerHTML = ` + + ${groupNames.map(name => ``).join('')} + `; + container.appendChild(select); + } + } + + getGroupNames() { + return [...app.graph._groups].map(g => g.title).sort(); + } + + updateStatus(text, progress = null) { + const status = this.container.querySelector('.ge-status'); + status.innerHTML = `${text}`; // 将文本包装在 span 中 + + if (progress !== null) { + status.style.setProperty('--progress', `${progress}%`); + } + } + + async executeGroups() { + if (this.isExecuting) { + console.warn('[GroupExecutorUI] 已有执行任务在进行中'); + return; + } + + const executeBtn = this.container.querySelector('.ge-execute-btn'); + const cancelBtn = this.container.querySelector('.ge-cancel-btn'); + const groupSelects = [...this.container.querySelectorAll('.ge-group-select')]; + const repeatCount = parseInt(this.container.querySelector('.ge-repeat-count').value); + const delaySeconds = parseFloat(this.container.querySelector('.ge-delay').value); + + this.isExecuting = true; + this.isCancelling = false; + executeBtn.disabled = true; + cancelBtn.disabled = false; + + const selectedGroups = groupSelects.map(select => select.value).filter(Boolean); + const totalSteps = repeatCount * selectedGroups.length; + let currentStep = 0; + + try { + for (let repeat = 0; repeat < repeatCount; repeat++) { + for (let i = 0; i < selectedGroups.length; i++) { + if (this.isCancelling) { + console.log('[GroupExecutorUI] 执行被用户取消'); + await fetch('/interrupt', { method: 'POST' }); + this.updateStatus("已取消"); + break; + } + + const groupName = selectedGroups[i]; + currentStep++; + const progress = (currentStep / totalSteps) * 100; + this.updateStatus(`${currentStep}/${totalSteps} - ${groupName}`, progress); + + try { + await this.executeGroup(groupName); + + if (i < selectedGroups.length - 1 && delaySeconds > 0) { + this.updateStatus(`等待 ${delaySeconds}s...`); + await this.delay(delaySeconds); + } + } catch (error) { + throw new Error(`执行组 "${groupName}" 失败: ${error.message}`); + } + } + + if (repeat < repeatCount - 1 && !this.isCancelling) { + await this.delay(delaySeconds); + } + } + + if (!this.isCancelling) { + this.updateStatus("完成"); + } + } catch (error) { + console.error('[GroupExecutorUI] 执行错误:', error); + this.updateStatus(`错误: ${error.message}`); + app.ui.dialog.show(`执行错误: ${error.message}`); + } finally { + this.isExecuting = false; + this.isCancelling = false; + executeBtn.disabled = false; + cancelBtn.disabled = true; + } + } + + async executeGroup(groupName) { + const group = app.graph._groups.find(g => g.title === groupName); + if (!group) { + throw new Error(`未找到名为 "${groupName}" 的组`); + } + + const outputNodes = []; + for (const node of app.graph._nodes) { + if (!node || !node.pos) continue; + if (LiteGraph.overlapBounding(group._bounding, node.getBounding())) { + if (node.mode !== LiteGraph.NEVER && node.constructor.nodeData?.output_node === true) { + outputNodes.push(node); + } + } + } + + if (outputNodes.length === 0) { + throw new Error(`组 "${groupName}" 中没有找到输出节点`); + } + + const nodeIds = outputNodes.map(n => n.id); + + try { + if (!rgthree || !rgthree.queueOutputNodes) { + throw new Error('rgthree.queueOutputNodes 不可用'); + } + await rgthree.queueOutputNodes(nodeIds); + await this.waitForQueue(); + } catch (queueError) { + console.warn(`[GroupExecutorUI] rgthree执行失败,使用默认方式:`, queueError); + for (const n of outputNodes) { + if (this.isCancelling) return; + if (n.triggerQueue) { + await n.triggerQueue(); + await this.waitForQueue(); + } + } + } + } + + async cancelExecution() { + if (!this.isExecuting) { + console.warn('[GroupExecutorUI] 没有正在执行的任务'); + return; + } + + try { + this.isCancelling = true; + this.updateStatus("已取消", 0); // 重置进度条为0 + await fetch('/interrupt', { method: 'POST' }); + } catch (error) { + console.error('[GroupExecutorUI] 取消执行时出错:', error); + this.updateStatus(`取消失败: ${error.message}`, 0); // 错误时也重置进度条 + } + } + + async getQueueStatus() { + try { + const response = await fetch('/queue'); + const data = await response.json(); + + return { + isRunning: data.queue_running.length > 0, + isPending: data.queue_pending.length > 0, + runningCount: data.queue_running.length, + pendingCount: data.queue_pending.length, + // 保存原始数据用于调试 + rawRunning: data.queue_running, + rawPending: data.queue_pending + }; + } catch (error) { + console.error('[GroupExecutor] 获取队列状态失败:', error); + // 返回默认状态而不是 null,避免空值判断 + return { + isRunning: false, + isPending: false, + runningCount: 0, + pendingCount: 0, + rawRunning: [], + rawPending: [] + }; + } + } + + // 等待队列完成 + async waitForQueue() { + return new Promise((resolve, reject) => { + const checkQueue = async () => { + try { + const status = await this.getQueueStatus(); + + // 只有当运行队列和等待队列都为空时才算完成 + if (!status.isRunning && !status.isPending) { + // 额外等待100ms确保状态完全更新 + setTimeout(resolve, 100); + return; + } + + // 继续检查队列状态,使用较短的间隔以提高响应性 + setTimeout(checkQueue, 500); + } catch (error) { + console.warn(`[GroupExecutor] 检查队列状态失败:`, error); + // 发生错误时继续检查,而不是中断 + setTimeout(checkQueue, 500); + } + }; + + // 开始检查队列 + checkQueue(); + }); + } + + async delay(seconds) { + if (seconds <= 0) return; + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); + } + + // 添加一个方法来确保UI不会超出视窗 + ensureInViewport() { + const rect = this.container.getBoundingClientRect(); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + // 应用边距限制 + if (this.container.style.right !== 'auto') { + this.container.style.right = `${this.DOCK_MARGIN_X}px`; + } + if (this.container.style.left !== 'auto') { + this.container.style.left = `${this.DOCK_MARGIN_X}px`; + } + if (this.container.style.top !== 'auto') { + this.container.style.top = `${this.DOCK_MARGIN_Y}px`; + } + if (this.container.style.bottom !== 'auto') { + this.container.style.bottom = `${this.DOCK_MARGIN_Y}px`; + } + } + + // 添加配置相关方法 + async loadConfigs() { + try { + const response = await api.fetchApi('/group_executor/configs', { + method: 'GET' + }); + + const result = await response.json(); + if (result.status === "error") { + throw new Error(result.message); + } + + const select = this.container.querySelector('.ge-config-select'); + select.innerHTML = ` + + ${result.configs.map(config => ``).join('')} + `; + } catch (error) { + console.error('[GroupExecutor] 加载配置失败:', error); + app.ui.dialog.show('加载配置失败: ' + error.message); + } + } + + async saveCurrentConfig() { + const configName = prompt('请输入配置名称:', '新配置'); + if (!configName) return; + + const config = { + name: configName, + groups: [...this.container.querySelectorAll('.ge-group-select')] + .map(select => select.value) + .filter(Boolean), + repeatCount: parseInt(this.container.querySelector('.ge-repeat-count').value), + delay: parseFloat(this.container.querySelector('.ge-delay').value) + }; + + try { + const response = await api.fetchApi('/group_executor/configs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }); + + const result = await response.json(); + if (result.status === "error") { + throw new Error(result.message); + } + + await this.loadConfigs(); + app.ui.dialog.show('配置保存成功'); + } catch (error) { + console.error('[GroupExecutor] 保存配置失败:', error); + app.ui.dialog.show('保存配置失败: ' + error.message); + } + } + + async loadConfig(configName) { + try { + const response = await api.fetchApi(`/group_executor/configs/${configName}`, { + method: 'GET', + cache: 'no-store' // 禁用缓存 + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const config = await response.json(); + + // 更新组数量 + const groupCountInput = this.container.querySelector('.ge-group-count'); + groupCountInput.value = config.groups.length; + await this.updateGroupSelects(config.groups.length); + + // 设置组选择 + const selects = this.container.querySelectorAll('.ge-group-select'); + config.groups.forEach((group, index) => { + if (selects[index]) selects[index].value = group; + }); + + // 设置其他参数 + this.container.querySelector('.ge-repeat-count').value = config.repeatCount; + this.container.querySelector('.ge-delay').value = config.delay; + } catch (error) { + console.error('加载配置失败:', error); + app.ui.dialog.show('加载配置失败: ' + error.message); + } + } + + // 添加删除配置方法 + async deleteConfig(configName) { + if (!configName) return; + + if (!confirm(`确定要删除配置 "${configName}" 吗?`)) { + return; + } + + try { + const response = await api.fetchApi(`/group_executor/configs/${configName}`, { + method: 'DELETE' + }); + + const result = await response.json(); + if (result.status === "error") { + throw new Error(result.message); + } + + await this.loadConfigs(); + app.ui.dialog.show('配置已删除'); + } catch (error) { + console.error('[GroupExecutor] 删除配置失败:', error); + app.ui.dialog.show('删除配置失败: ' + error.message); + } + } +} + +// 修改扩展注册部分 +app.registerExtension({ + name: "GroupExecutorUI", + async setup() { + // 等待 UI 设置初始化完成 + await app.ui.settings.setup; + + // 添加设置选项 + app.ui.settings.addSetting({ + id: "GroupExecutor.enabled", + name: "显示组执行器按钮", + type: "boolean", + defaultValue: true, + onChange: (value) => { + const btn = document.querySelector('.group-executor-btn'); + if (btn) { + btn.style.display = value ? 'block' : 'none'; + } + } + }); + + app.ui.settings.addSetting({ + id: "GroupExecutor.marginX", + name: "组执行器水平边距", + type: "number", + defaultValue: 0, + min: 0, + max: 100, + step: 1, + onChange: (value) => { + GroupExecutorUI.DOCK_MARGIN_X = value; + document.querySelectorAll('.group-executor-ui').forEach(el => { + const instance = el.instance; + if (instance) { + instance.DOCK_MARGIN_X = value; + instance.ensureInViewport(); + } + }); + } + }); + + app.ui.settings.addSetting({ + id: "GroupExecutor.marginY", + name: "组执行器垂直边距", + type: "number", + defaultValue: 20, + min: 0, + max: 100, + step: 1, + onChange: (value) => { + GroupExecutorUI.DOCK_MARGIN_Y = value; + document.querySelectorAll('.group-executor-ui').forEach(el => { + const instance = el.instance; + if (instance) { + instance.DOCK_MARGIN_Y = value; + instance.ensureInViewport(); + } + }); + } + }); + + try { + // 尝试使用新版UI + const btn = new (await import("../../scripts/ui/components/button.js")).ComfyButton({ + icon: "layers-outline", + action: () => { + new GroupExecutorUI(); + }, + tooltip: "组执行器", + content: "组执行器", + classList: "comfyui-button comfyui-menu-mobile-collapse group-executor-btn" + }).element; + + // 根据设置决定是否显示按钮 + const enabled = app.ui.settings.getSettingValue("GroupExecutor.enabled", true); + btn.style.display = enabled ? 'block' : 'none'; + + app.menu?.actionsGroup.element.after(btn); + } catch { + // 传统UI方式 + const menu = document.querySelector(".comfy-menu"); + const clearButton = document.getElementById("comfy-clear-button"); + + const groupExecutorButton = document.createElement("button"); + groupExecutorButton.textContent = "组执行器"; + groupExecutorButton.classList.add("group-executor-btn"); + + // 根据设置决定是否显示按钮 + const enabled = app.ui.settings.getSettingValue("GroupExecutor.enabled", true); + groupExecutorButton.style.display = enabled ? 'block' : 'none'; + + groupExecutorButton.addEventListener("click", () => { + new GroupExecutorUI(); + }); + + if (clearButton) { + menu.insertBefore(groupExecutorButton, clearButton); + } else { + menu.appendChild(groupExecutorButton); + } + } + } +}); + + diff --git a/web/groupexecutorqueuemanager.js b/web/groupexecutorqueuemanager.js new file mode 100644 index 0000000..f50526c --- /dev/null +++ b/web/groupexecutorqueuemanager.js @@ -0,0 +1,187 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "GroupExecutorQueueManager", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + // 检查是否已经重写过 fetchApi + if (api.fetchApi._isGroupExecutorQueueManager) { + return; + } + + // 保存原始的 api.fetchApi + const originalFetchApi = api.fetchApi; + + // 递归收集相关节点 + function collectRelatedNodes(prompt, nodeId, relevantNodes) { + if (!prompt[nodeId] || relevantNodes.has(nodeId)) return; + relevantNodes.add(nodeId); + + // 收集输入节点 + const node = prompt[nodeId]; + if (node.inputs) { + Object.values(node.inputs).forEach(input => { + if (input && input.length > 0) { + collectRelatedNodes(prompt, input[0], relevantNodes); + } + }); + } + } + + // 重写 api.fetchApi + const newFetchApi = async function(url, options = {}) { + // 只拦截 POST /prompt 请求 + if (url === '/prompt' && options.method === 'POST') { + const requestData = JSON.parse(options.body); + + // 如果是 GroupExecutorSender 的内部请求,直接放行 + if (requestData.extra_data?.isGroupExecutorRequest) { + return originalFetchApi.call(api, url, options); + } + + const prompt = requestData.prompt; + + // 检查是否存在 GroupExecutorSender 节点 + const hasGroupExecutor = Object.values(prompt).some(node => + node.class_type === "GroupExecutorSender" + ); + + if (hasGroupExecutor) { + // 找出所有 GroupExecutorSender 相关的节点 + const relevantNodes = new Set(); + + for (const [nodeId, node] of Object.entries(prompt)) { + if (node.class_type === "GroupExecutorSender") { + collectRelatedNodes(prompt, nodeId, relevantNodes); + } + } + + // 创建过滤后的 prompt + const filteredPrompt = {}; + for (const nodeId of relevantNodes) { + if (prompt[nodeId]) { + filteredPrompt[nodeId] = prompt[nodeId]; + } + } + + // 修改请求数据,添加标记表示这是内部请求 + const modifiedOptions = { + ...options, + body: JSON.stringify({ + ...requestData, + prompt: filteredPrompt, + extra_data: { + ...requestData.extra_data, + isGroupExecutorRequest: true + } + }) + }; + + return originalFetchApi.call(api, url, modifiedOptions); + } + } + + return originalFetchApi.call(api, url, options); + }; + + // 标记新的 fetchApi 函数 + newFetchApi._isGroupExecutorQueueManager = true; + + // 替换 api.fetchApi + api.fetchApi = newFetchApi; + } +}); + + + +api.addEventListener("img-send", async ({ detail }) => { + if (detail.images.length === 0) return; + + // 构建所有图像的文件名列表,用逗号连接 + const filenames = detail.images.map(data => data.filename).join(', '); + + // 查找所有LG_ImageReceiver节点 + for (const node of app.graph._nodes) { + if (node.type === "LG_ImageReceiver") { + let isLinked = false; + + // 检查link_id是否匹配 + const linkWidget = node.widgets.find(w => w.name === "link_id"); + if (linkWidget.value === detail.link_id) { + isLinked = true; + } + + if (isLinked) { + // 更新image widget的值 + if (node.widgets[0]) { + node.widgets[0].value = filenames; + if (node.widgets[0].callback) { + node.widgets[0].callback(filenames); + } + } + + // 加载所有预览图像 + Promise.all(detail.images.map(imageData => { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.src = `/view?filename=${encodeURIComponent(imageData.filename)}&type=${imageData.type}${app.getPreviewFormatParam()}`; + }); + })).then(loadedImages => { + node.imgs = loadedImages; + node.size[1] = Math.max(200, 100 + loadedImages.length * 100); + app.canvas.setDirty(true); + }); + } + } + } +}); + +// 注册节点扩展 +app.registerExtension({ + name: "Comfy.LG_Image", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "LG_ImageReceiver") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + }; + } + }, +}); + + + + + +app.registerExtension({ + name: "memory.cleanup", + init() { + // 监听来自后端的内存清理信号 + api.addEventListener("memory_cleanup", ({ detail }) => { + if (detail.type === "cleanup_request") { + console.log("收到内存清理请求"); + + // 发送清理请求到 /free 接口 + fetch("/free", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(detail.data) + }) + .then(response => { + if (response.ok) { + console.log("内存清理请求已发送"); + } else { + console.error("内存清理请求失败"); + } + }) + .catch(error => { + console.error("发送内存清理请求出错:", error); + }); + } + }); + } +}); + diff --git a/web/groupexecutorsender.js b/web/groupexecutorsender.js new file mode 100644 index 0000000..b17f6ea --- /dev/null +++ b/web/groupexecutorsender.js @@ -0,0 +1,284 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "GroupExecutorSender", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "GroupExecutorSender") { + // 添加状态显示相关的属性 + nodeType.prototype.onNodeCreated = function() { + this.properties = { + ...this.properties, + isExecuting: false, + statusText: "", + showStatus: false + }; + this.size = this.computeSize(); + }; + + // 重写绘制方法 + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function(ctx) { + const r = onDrawForeground?.apply?.(this, arguments); + + if (!this.flags.collapsed && this.properties.showStatus) { + const text = this.properties.statusText; + if (text) { + ctx.save(); + + // 设置字体 + ctx.font = "bold 30px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + // 设置颜色 + ctx.fillStyle = this.properties.isExecuting ? "dodgerblue" : "limegreen"; + + // 计算中心位置 + const centerX = this.size[0] / 2; + const centerY = this.size[1] / 2 + 10; // 稍微向下偏移以避开输入端口 + + // 绘制文本 + ctx.fillText(text, centerX, centerY); + + ctx.restore(); + } + } + + return r; + }; + + // 更新节点大小计算 + nodeType.prototype.computeSize = function() { + return [400, 100]; // 固定宽度和高度 + }; + + // 更新状态显示方法 + nodeType.prototype.updateStatus = function(text) { + this.properties.statusText = text; + this.properties.showStatus = true; + this.setDirtyCanvas(true, true); + }; + + // 重置状态显示方法 + nodeType.prototype.resetStatus = function() { + this.properties.statusText = ""; + this.properties.showStatus = false; + this.setDirtyCanvas(true, true); + }; + + // 获取指定组的输出节点 + nodeType.prototype.getGroupOutputNodes = function(groupName) { + // 找到指定名称的组 + const group = app.graph._groups.find(g => g.title === groupName); + if (!group) { + console.warn(`[GroupExecutorSender] 未找到名为 "${groupName}" 的组`); + return []; + } + + // 获取组内的所有节点 + const groupNodes = []; + for (const node of app.graph._nodes) { + if (!node || !node.pos) continue; + if (LiteGraph.overlapBounding(group._bounding, node.getBounding())) { + groupNodes.push(node); + } + } + group._nodes = groupNodes; + + // 获取输出节点 + return this.getOutputNodes(group._nodes); + }; + + // 获取输出节点 + nodeType.prototype.getOutputNodes = function(nodes) { + return nodes.filter((n) => { + return n.mode !== LiteGraph.NEVER && + n.constructor.nodeData?.output_node === true; + }); + }; + + // 获取队列状态 + nodeType.prototype.getQueueStatus = async function() { + try { + const response = await api.fetchApi('/queue'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + + // 确保数据包含必要的字段 + const queueRunning = data.queue_running || []; + const queuePending = data.queue_pending || []; + + return { + isRunning: queueRunning.length > 0, + isPending: queuePending.length > 0, + runningCount: queueRunning.length, + pendingCount: queuePending.length, + rawRunning: queueRunning, + rawPending: queuePending + }; + } catch (error) { + console.error('[GroupExecutorSender] 获取队列状态失败:', error); + // 返回默认状态而不是 null + return { + isRunning: false, + isPending: false, + runningCount: 0, + pendingCount: 0, + rawRunning: [], + rawPending: [] + }; + } + }; + + // 等待队列完成 + nodeType.prototype.waitForQueue = async function() { + return new Promise((resolve, reject) => { + const checkQueue = async () => { + try { + const status = await this.getQueueStatus(); + + // 只有当运行队列和等待队列都为空时才算完成 + if (!status.isRunning && !status.isPending) { + // 额外等待100ms确保状态完全更新 + setTimeout(resolve, 100); + return; + } + + // 继续检查队列状态,使用较短的间隔以提高响应性 + setTimeout(checkQueue, 500); + } catch (error) { + console.warn(`[GroupExecutorSender] 检查队列状态失败:`, error); + // 发生错误时继续检查,而不是中断 + setTimeout(checkQueue, 500); + } + }; + + // 开始检查队列 + checkQueue(); + }); + }; + + // 监听执行列表请求 + api.addEventListener("execute_group_list", async ({ detail }) => { + if (!detail || !detail.node_id || !Array.isArray(detail.execution_list)) { + console.error('[GroupExecutorSender] 收到无效的执行数据:', detail); + return; + } + + const node = app.graph._nodes_by_id[detail.node_id]; + if (!node) { + console.error(`[GroupExecutorSender] 未找到节点: ${detail.node_id}`); + return; + } + + try { + const executionList = detail.execution_list; + console.log(`[GroupExecutorSender] 收到执行列表:`, executionList); + + if (node.properties.isExecuting) { + console.warn('[GroupExecutorSender] 已有执行任务在进行中'); + return; + } + + node.properties.isExecuting = true; + // 计算实际任务数(排除延迟组) + let totalTasks = executionList.filter(item => item.group_name !== "__delay__").length; + let currentTask = 0; + + try { + for (const execution of executionList) { + const group_name = execution.group_name || ''; + const repeat_count = parseInt(execution.repeat_count) || 1; + const delay_seconds = parseFloat(execution.delay_seconds) || 0; + + if (!group_name) { + console.warn('[GroupExecutorSender] 跳过无效的组名称:', execution); + continue; + } + + // 处理延迟组 + if (group_name === "__delay__") { + if (delay_seconds > 0) { + node.updateStatus( + `等待下一组 ${delay_seconds}s...` + ); + await new Promise(resolve => setTimeout(resolve, delay_seconds * 1000)); + } + continue; + } + + // 更新当前任务计数 + currentTask++; + const progress = (currentTask / totalTasks) * 100; + node.updateStatus( + `执行组: ${group_name} (${currentTask}/${totalTasks})`, + progress + ); + + try { + const outputNodes = node.getGroupOutputNodes(group_name); + if (!outputNodes || !outputNodes.length) { + throw new Error(`组 "${group_name}" 中没有找到输出节点`); + } + + const nodeIds = outputNodes.map(n => n.id); + + if (rgthree?.queueOutputNodes) { + try { + await rgthree.queueOutputNodes(nodeIds); + await node.waitForQueue(); + } catch (queueError) { + console.warn(`[GroupExecutorSender] rgthree执行失败,使用默认方式:`, queueError); + for (const n of outputNodes) { + if (n.triggerQueue) { + await n.triggerQueue(); + await node.waitForQueue(); + } + } + } + } else { + for (const n of outputNodes) { + if (n.triggerQueue) { + await n.triggerQueue(); + await node.waitForQueue(); + } + } + } + + if (delay_seconds > 0 && currentTask < totalTasks) { + node.updateStatus( + `执行组: ${group_name} (${currentTask}/${totalTasks}) - 等待 ${delay_seconds}s`, + progress + ); + await new Promise(resolve => setTimeout(resolve, delay_seconds * 1000)); + } + } catch (error) { + throw new Error(`执行组 "${group_name}" 失败: ${error.message}`); + } + } + + // 执行完成后保留最后的状态显示 + node.updateStatus(`执行完成 (${totalTasks}/${totalTasks})`, 100); + + } catch (error) { + console.error('[GroupExecutorSender] 执行错误:', error); + node.updateStatus(`错误: ${error.message}`); + app.ui.dialog.show(`执行错误: ${error.message}`); + } finally { + node.properties.isExecuting = false; + } + + } catch (error) { + console.error(`[GroupExecutorSender] 执行失败:`, error); + app.ui.dialog.show(`执行错误: ${error.message}`); + node.updateStatus(`错误: ${error.message}`); + node.properties.isExecuting = false; + } + }); + } + } +}); +