Initial release
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# ComfyUI-Toggle-Pass
|
||||
|
||||
DynamicRef 系列节点——多参考图动态编码,支持 1~10 张参考图,每张图独立编码输出。
|
||||
|
||||
---
|
||||
|
||||
## 节点说明
|
||||
|
||||
### DynamicRefIndependent — 多参考图独立编码(核心节点)
|
||||
|
||||
每张参考图独立 VAE 编码,输出各自独立的 CONDITIONING 和 LATENT,适合多图各自控制风格/内容的场景。
|
||||
|
||||
**输入**(动态,由 `num_images` 控制):
|
||||
- `clip` — CLIP 模型
|
||||
- `vae` — VAE 模型
|
||||
- `num_images` — 参考图数量(1~10)
|
||||
- `instruction` — 系统提示词(可留空使用默认)
|
||||
- `image1` ~ `imageN` — 参考图
|
||||
- `prompt1` ~ `promptN` — 每张图对应的提示词(控件在节点内,可折叠)
|
||||
|
||||
**输出**(动态,N×2 个):
|
||||
- `cond_1`, `latent1`, `cond_2`, `latent2`, ... `cond_N`, `latentN`
|
||||
|
||||
**用法**:
|
||||
1. 拖入节点,默认 1 张参考图
|
||||
2. 调整 `num_images` 到需要的数量(1~10),输入槽和输出槽自动增减
|
||||
3. 每张图连接自己的参考图,填写对应 prompt
|
||||
4. `cond_N` 连到 sampler 的 conditioning,`latentN` 连到 VAE Encode 的 latent(供参考图编码使用)
|
||||
|
||||
---
|
||||
|
||||
### DynamicRefImageEncode — 动态参考图编码
|
||||
|
||||
单张 prompt 控制,动态增删参考图输入槽。
|
||||
|
||||
**输入**:`clip`, `num_images`, `prompt`, `image1` ~ `imageN`
|
||||
**输出**:`CONDITIONING`, `LATENT`(所有图共享同一份 conditioning,分别输出 latent)
|
||||
|
||||
---
|
||||
|
||||
### DynamicRefLatentInput — 动态 Latent 输入
|
||||
|
||||
动态增删 LATENT 输入槽,配合 DynamicRef 系列使用。
|
||||
|
||||
---
|
||||
|
||||
### ImageBatcher — 图像批处理
|
||||
|
||||
动态增删 IMAGE 输入槽,将多张图打包成批次。
|
||||
|
||||
---
|
||||
|
||||
### OneImageMultiPrompt — 单图多提示词
|
||||
|
||||
一张参考图对应多个提示词,分别输出 conditioning。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `num_images` 调整时,输入/输出槽会自动增减,**已有连线不会被断开**
|
||||
- 切换页面再回来,节点会自动恢复到正确的槽位数量
|
||||
- 需要 Qwen2.5-VL 或兼容的 CLIP 模型支持
|
||||
- 默认 instruction 适用于 Qwen-Image-Edit 类模型,可按需修改
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
将本文件夹放到 `ComfyUI/custom_nodes/` 下,重启 ComfyUI 即可。
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
ComfyUI Toggle Pass - Toggle nodes + Image IO + Multi-Ref Encode
|
||||
Author: Clever
|
||||
"""
|
||||
from .multi_ref_encode import NODE_CLASS_MAPPINGS as MULTI_REF_NODES, NODE_DISPLAY_NAME_MAPPINGS as MULTI_REF_NAMES
|
||||
|
||||
import os
|
||||
import json
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, ImageSequence, ImageFile
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import comfy.model_management
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
import node_helpers
|
||||
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
_last_saved_path = {}
|
||||
|
||||
|
||||
class SaveLoadImage:
|
||||
"""Save image AND output it. Toggle OFF to auto-load last saved image instead."""
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"use_saved": (["Save", "Load"],),
|
||||
},
|
||||
"optional": {
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("IMAGE", "MASK")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "Toggle-Pass"
|
||||
DESCRIPTION = "Save image and output IMAGE. Toggle use_saved=ON to reuse last saved image."
|
||||
|
||||
def execute(self, images, use_saved="Save", filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None):
|
||||
if use_saved == "Load":
|
||||
return self._load_last()
|
||||
else:
|
||||
return self._save(images, filename_prefix, prompt, extra_pnginfo)
|
||||
|
||||
def _save(self, images, filename_prefix, prompt, extra_pnginfo):
|
||||
if images is None:
|
||||
images = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
results = list()
|
||||
for (batch_number, image) in enumerate(images):
|
||||
i = 255. * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
metadata = None
|
||||
if not comfy.utils.args.disable_metadata:
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
|
||||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||||
file = f"{filename_with_batch_num}_{counter:05}_.png"
|
||||
filepath = os.path.join(full_output_folder, file)
|
||||
img.save(filepath, pnginfo=metadata, compress_level=self.compress_level)
|
||||
results.append({
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": self.type
|
||||
})
|
||||
|
||||
if id(self) not in _last_saved_path:
|
||||
_last_saved_path[id(self)] = filepath
|
||||
|
||||
counter += 1
|
||||
|
||||
last_file = os.path.join(full_output_folder, results[0]["filename"])
|
||||
_last_saved_path[id(self)] = last_file
|
||||
|
||||
mask = torch.zeros((images.shape[0], images.shape[1], images.shape[2]), dtype=torch.float32)
|
||||
return {"result": (images, mask), "ui": {"images": results}}
|
||||
|
||||
def _load_last(self):
|
||||
node_id = id(self)
|
||||
if node_id in _last_saved_path and os.path.isfile(_last_saved_path[node_id]):
|
||||
filepath = _last_saved_path[node_id]
|
||||
img = node_helpers.pillow(Image.open, filepath)
|
||||
output_images = []
|
||||
output_masks = []
|
||||
w, h = None, None
|
||||
dtype = comfy.model_management.intermediate_dtype()
|
||||
|
||||
for i in ImageSequence.Iterator(img):
|
||||
i = node_helpers.pillow(ImageOps.exif_transpose, i)
|
||||
if i.mode == 'I':
|
||||
i = i.point(lambda x: x * (1 / 255))
|
||||
image = i.convert("RGB")
|
||||
if len(output_images) == 0:
|
||||
w = image.size[0]
|
||||
h = image.size[1]
|
||||
if image.size[0] != w or image.size[1] != h:
|
||||
continue
|
||||
image = np.array(image).astype(np.float32) / 255.0
|
||||
image = torch.from_numpy(image)[None,]
|
||||
if 'A' in i.getbands():
|
||||
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
|
||||
mask = 1. - torch.from_numpy(mask)
|
||||
elif i.mode == 'P' and 'transparency' in i.info:
|
||||
mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0
|
||||
mask = 1. - torch.from_numpy(mask)
|
||||
else:
|
||||
mask = torch.zeros((h, w), dtype=torch.float32, device="cpu")
|
||||
output_images.append(image.to(dtype=dtype))
|
||||
output_masks.append(mask.unsqueeze(0).to(dtype=dtype))
|
||||
if img.format == "MPO":
|
||||
break
|
||||
|
||||
if len(output_images) > 1:
|
||||
output_image = torch.cat(output_images, dim=0)
|
||||
output_mask = torch.cat(output_masks, dim=0)
|
||||
else:
|
||||
output_image = output_images[0]
|
||||
output_mask = output_masks[0]
|
||||
|
||||
return {"result": (output_image, output_mask), "ui": {"images": []}}
|
||||
|
||||
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
dummy_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
|
||||
return {"result": (dummy, dummy_mask), "ui": {"images": []}}
|
||||
|
||||
|
||||
class ToggleEmptyLatent:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 1024, "min": 16, "max": 16384, "step": 8}),
|
||||
"height": ("INT", {"default": 1024, "min": 16, "max": 16384, "step": 8}),
|
||||
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}),
|
||||
"enabled": (["ON", "OFF"],),
|
||||
},
|
||||
"optional": {
|
||||
"fallback_width": ("INT", {"default": 1024, "min": 16, "max": 16384, "step": 8}),
|
||||
"fallback_height": ("INT", {"default": 1024, "min": 16, "max": 16384, "step": 8}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT",)
|
||||
RETURN_NAMES = ("LATENT",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "Toggle-Pass"
|
||||
|
||||
def generate(self, width, height, batch_size=1, enabled="ON", fallback_width=None, fallback_height=None):
|
||||
if fallback_width is None:
|
||||
fallback_width = 1024
|
||||
if fallback_height is None:
|
||||
fallback_height = 1024
|
||||
if enabled != "ON":
|
||||
width = fallback_width
|
||||
height = fallback_height
|
||||
latent = torch.zeros([batch_size, 4, height // 8, width // 8],
|
||||
device=comfy.model_management.intermediate_device(),
|
||||
dtype=comfy.model_management.intermediate_dtype())
|
||||
return ({"samples": latent, "downscale_ratio_spacial": 8},)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
**MULTI_REF_NODES,
|
||||
"ToggleEmptyLatent": ToggleEmptyLatent,
|
||||
"SaveLoadImage": SaveLoadImage,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**MULTI_REF_NAMES,
|
||||
"ToggleEmptyLatent": "Toggle Empty Latent",
|
||||
"SaveLoadImage": "Save/Load Image",
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ComfyUI-Toggle-Pass 前端扩展
|
||||
* 支持动态输入/输出槽:
|
||||
* - DynamicRefLatentInput: 动态 LATENT 输入槽
|
||||
* - ImageBatcher: 动态 IMAGE 输入槽
|
||||
* - DynamicRefImageEncode: 动态 IMAGE 输入槽
|
||||
* - DynamicRefIndependent: 四项同步增删(IMAGE + prompt + cond + latent)
|
||||
*
|
||||
* 架构说明:
|
||||
* 使用 beforeRegisterNodeDef + onConfigure/onNodeCreated 模式,
|
||||
* 而非 nodeCreated/nodeAdded + setTimeout。
|
||||
* 原因:nodeCreated/nodeAdded 触发时 widget 值可能尚未从 JSON 恢复,
|
||||
* setTimeout 无法可靠保证时序,且切页面时可能与 onConfigure 打架。
|
||||
* onConfigure 在 widget 值恢复之后触发,可安全读取 w.value。
|
||||
*/
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const EXT_NAME = "ComfyUI-Toggle-Pass";
|
||||
|
||||
/* ============ 通用节点(DynamicRefLatentInput / ImageBatcher / DynamicRefImageEncode)============ */
|
||||
|
||||
const DYNAMIC_NODES = {
|
||||
"DynamicRefLatentInput": { widget: "num_latents", inputType: "LATENT", max: 10, prefix: "latent" },
|
||||
"ImageBatcher": { widget: "num_images", inputType: "IMAGE", max: 10, prefix: "image" },
|
||||
"DynamicRefImageEncode": { widget: "num_images", inputType: "IMAGE", max: 10, prefix: "image" },
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: EXT_NAME + "-generic",
|
||||
beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
const cfg = DYNAMIC_NODES[nodeData.name];
|
||||
if (!cfg) return;
|
||||
|
||||
// onConfigure:从 JSON 恢复后触发,widget 值已正确
|
||||
const origConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
origConfigure?.apply(this, arguments);
|
||||
requestAnimationFrame(() => {
|
||||
const w = this.widgets?.find(w => w.name === cfg.widget);
|
||||
if (w) syncInputs(this, cfg, Math.max(1, Math.min(cfg.max, w.value || 1)));
|
||||
});
|
||||
};
|
||||
|
||||
// onNodeCreated:新拖入的节点(无 JSON 恢复),用默认值初始化
|
||||
const origCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
origCreated?.apply(this, arguments);
|
||||
if (this._tpInit) return;
|
||||
this._tpInit = true;
|
||||
const w = this.widgets?.find(w => w.name === cfg.widget);
|
||||
if (!w) return;
|
||||
syncInputs(this, cfg, Math.max(1, Math.min(cfg.max, w.value || 1)));
|
||||
const prev = w.callback;
|
||||
w.callback = function (v) {
|
||||
if (prev) prev.call(this, v);
|
||||
syncInputs(this._node || this, cfg, Math.max(1, Math.min(cfg.max, v || 1)));
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function syncInputs(node, cfg, target) {
|
||||
const inputs = node.inputs || [];
|
||||
let count = 0;
|
||||
for (const inp of inputs) if (inp.type === cfg.inputType) count++;
|
||||
if (count === target) return;
|
||||
|
||||
if (count < target) {
|
||||
for (let i = count + 1; i <= target; i++) node.addInput(`${cfg.prefix}${i}`, cfg.inputType);
|
||||
} else {
|
||||
const idx = [];
|
||||
for (let i = 0; i < inputs.length; i++) if (inputs[i].type === cfg.inputType) idx.push(i);
|
||||
for (let i = 0; i < count - target; i++) node.removeInput(idx[idx.length - 1 - i]);
|
||||
}
|
||||
node.setSize(node.computeSize());
|
||||
app.graph.change();
|
||||
}
|
||||
|
||||
/* ============ Ref Independent(四项同步增删)============ */
|
||||
|
||||
app.registerExtension({
|
||||
name: EXT_NAME + "-refind",
|
||||
beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "DynamicRefIndependent") return;
|
||||
|
||||
// onConfigure:从 JSON 恢复后触发,此时 widget 值已正确,输出槽为后端全量(20个)
|
||||
// 用 w.value 裁剪到 N*2 个
|
||||
const origConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
origConfigure?.apply(this, arguments);
|
||||
requestAnimationFrame(() => {
|
||||
const w = this.widgets?.find(w => w.name === "num_images");
|
||||
if (!w) return;
|
||||
const n = Math.max(1, Math.min(10, w.value || 1));
|
||||
applyRefInd(this, n);
|
||||
});
|
||||
};
|
||||
|
||||
// onNodeCreated:新拖入的节点(首次创建,无 JSON 恢复)
|
||||
const origCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
origCreated?.apply(this, arguments);
|
||||
if (this._tpRefInd) return;
|
||||
this._tpRefInd = true;
|
||||
const w = this.widgets?.find(w => w.name === "num_images");
|
||||
if (!w) return;
|
||||
const n = Math.max(1, Math.min(10, w.value || 1));
|
||||
applyRefInd(this, n);
|
||||
const prev = w.callback;
|
||||
w.callback = function (v) {
|
||||
if (prev) prev.call(this, v);
|
||||
applyRefInd(this._node || this, Math.max(1, Math.min(10, v || 1)));
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ref Independent:同步增删
|
||||
* 1. IMAGE 输入槽:image1 ~ imageN(增删)
|
||||
* 2. prompt 控件:prompt1 ~ promptN(hidden 显示/隐藏)
|
||||
* 3. 输出槽:cond_1, latent1, cond_2, latent2, ..., cond_N, latentN(增删,交替排列)
|
||||
*
|
||||
* 关键:输出顺序必须和后端 RETURN_TYPES 交替排列一致:
|
||||
* RETURN_TYPES = (CONDITIONING, LATENT, CONDITIONING, LATENT, ...)
|
||||
* 后端固定 20 个输出,前端通过 removeOutput 裁剪到 N*2 个
|
||||
* 裁剪时从末尾移除,保留前 N*2 个(index 0~2N-1),这样已有连线不受影响
|
||||
*/
|
||||
function applyRefInd(node, n) {
|
||||
// ---- 1. 增删 IMAGE 输入槽 ----
|
||||
const inputs = node.inputs || [];
|
||||
let imgCount = 0;
|
||||
for (const inp of inputs) if (inp.type === "IMAGE") imgCount++;
|
||||
if (imgCount < n) {
|
||||
for (let i = imgCount + 1; i <= n; i++) node.addInput(`image${i}`, "IMAGE");
|
||||
} else if (imgCount > n) {
|
||||
const idx = [];
|
||||
for (let i = 0; i < inputs.length; i++) if (inputs[i].type === "IMAGE") idx.push(i);
|
||||
for (let i = 0; i < imgCount - n; i++) node.removeInput(idx[idx.length - 1 - i]);
|
||||
}
|
||||
|
||||
// ---- 2. 隐藏/显示 prompt 控件 ----
|
||||
for (const w of (node.widgets || [])) {
|
||||
if (w.name && w.name.startsWith("prompt")) {
|
||||
const num = parseInt(w.name.replace("prompt", ""), 10);
|
||||
w.hidden = num > n;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 3. 调整输出槽 ----
|
||||
// 后端固定 20 个输出(10 对 cond_X/latentX),需要裁剪到 N*2 个
|
||||
// 策略:只从末尾 removeOutput,不重建前面的,保护已有连线
|
||||
const expectedCount = n * 2;
|
||||
while (node.outputs && node.outputs.length > expectedCount) {
|
||||
node.removeOutput(node.outputs.length - 1);
|
||||
}
|
||||
// 如果不够(理论上不应该发生,除非后端定义改了),补齐
|
||||
while (node.outputs && node.outputs.length < expectedCount) {
|
||||
const i = Math.floor(node.outputs.length / 2) + 1;
|
||||
if (node.outputs.length % 2 === 0) {
|
||||
node.addOutput(`cond_${i}`, "CONDITIONING");
|
||||
} else {
|
||||
node.addOutput(`latent${i}`, "LATENT");
|
||||
}
|
||||
}
|
||||
|
||||
node.setSize(node.computeSize());
|
||||
app.graph.change();
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
Multi-Reference Image Encode Node
|
||||
Supports 3/5/10 reference images for Qwen2.5-VL / FLUX2 workflows.
|
||||
Each image is encoded into a reference latent and injected into conditioning.
|
||||
|
||||
Version: 3.0 - Multiple node classes (3/5/10 images) + Custom output size for img2img
|
||||
"""
|
||||
|
||||
import math
|
||||
import torch
|
||||
import comfy.utils
|
||||
import node_helpers
|
||||
|
||||
|
||||
class DynamicRefImageEncode:
|
||||
"""
|
||||
动态 Ref Image Encode 节点,合并原 III/V/X 三个节点。
|
||||
前端 JS 根据 num_images 增删 IMAGE 输入槽(1~10)。
|
||||
所有参考图共享同一个 prompt,输出 1 个 conditioning + 1 个 latent。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
opt = {}
|
||||
for i in range(1, 11):
|
||||
opt[f"image{i}"] = ("IMAGE",)
|
||||
return {
|
||||
"required": {
|
||||
"clip": ("CLIP",),
|
||||
"vae": ("VAE",),
|
||||
"num_images": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
|
||||
"prompt": ("STRING", {"multiline": True, "dynamicPrompts": True}),
|
||||
"instruction": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": (
|
||||
"Describe the key features of the input image (color, shape, size, "
|
||||
"texture, objects, background), then explain how the user's text "
|
||||
"instruction should alter or modify the image. Generate a new image "
|
||||
"that meets the user's requirements while maintaining consistency "
|
||||
"with the original input where appropriate."
|
||||
),
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
**opt,
|
||||
"模型类型": (["Flux2", "SD / SDXL"], {"default": "Flux2"}),
|
||||
"匹配原图尺寸": (["OFF", "ON"], {"default": "OFF"}),
|
||||
"输出宽度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
|
||||
"输出高度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING", "LATENT")
|
||||
RETURN_NAMES = ("conditioning", "latent")
|
||||
FUNCTION = "encode"
|
||||
CATEGORY = "Toggle-Pass"
|
||||
|
||||
def _get_system_prompt(self, instruction):
|
||||
prefix = "<|im_start|>system\n"
|
||||
suffix = "<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
|
||||
content = instruction if instruction.strip() else (
|
||||
"Describe the key features of the input image (color, shape, size, "
|
||||
"texture, objects, background), then explain how the user's text "
|
||||
"instruction should alter or modify the image. Generate a new image "
|
||||
"that meets the user's requirements while maintaining consistency "
|
||||
"with the original input where appropriate."
|
||||
)
|
||||
if prefix in content:
|
||||
content = content.split(prefix)[1]
|
||||
if suffix in content:
|
||||
content = content.split(suffix)[0]
|
||||
content = content.replace("{}", "")
|
||||
return prefix + content + suffix
|
||||
|
||||
def encode(self, clip, num_images, prompt, instruction="",
|
||||
vae=None, 模型类型="Flux2", 匹配原图尺寸="OFF",
|
||||
输出宽度=1024, 输出高度=1024, **kwargs):
|
||||
|
||||
n = max(1, min(10, int(num_images)))
|
||||
llama_template = self._get_system_prompt(instruction)
|
||||
|
||||
ref_latents = []
|
||||
vl_images = []
|
||||
image_prompt = ""
|
||||
|
||||
for i in range(1, n + 1):
|
||||
image = kwargs.get(f"image{i}", None)
|
||||
if image is None:
|
||||
continue
|
||||
|
||||
samples = image.movedim(-1, 1) # [B, C, H, W]
|
||||
current_total = samples.shape[3] * samples.shape[2]
|
||||
|
||||
if 匹配原图尺寸 == "ON":
|
||||
pixel_width = samples.shape[3]
|
||||
pixel_height = samples.shape[2]
|
||||
else:
|
||||
pixel_width = 输出宽度
|
||||
pixel_height = 输出高度
|
||||
|
||||
if vae is not None:
|
||||
s = comfy.utils.common_upscale(samples, pixel_width, pixel_height, "lanczos", "center")
|
||||
img_for_vae = s.movedim(1, -1)
|
||||
ref_latents.append(vae.encode(img_for_vae[:, :, :, :3]))
|
||||
|
||||
# VL image: resize down for vision-language token
|
||||
vl_total = 384 * 384
|
||||
vl_scale = math.sqrt(vl_total / current_total)
|
||||
vl_w = round(samples.shape[3] * vl_scale)
|
||||
vl_h = round(samples.shape[2] * vl_scale)
|
||||
s_vl = comfy.utils.common_upscale(samples, vl_w, vl_h, "lanczos", "center")
|
||||
img_vl = s_vl.movedim(1, -1)
|
||||
|
||||
vl_images.append(img_vl)
|
||||
# 注意:不要手动加 <|vision_start|><|image_pad|><|vision_end|>
|
||||
# ComfyUI 的 Qwen2.5-VL CLIP tokenizer 会在 images 参数传入时自动插入这些 token
|
||||
image_prompt += "Picture {}: ".format(len(vl_images))
|
||||
|
||||
full_prompt = image_prompt + prompt
|
||||
tokens = clip.tokenize(full_prompt, images=vl_images, llama_template=llama_template)
|
||||
conditioning = clip.encode_from_tokens_scheduled(tokens)
|
||||
|
||||
if len(ref_latents) > 0:
|
||||
conditioning = node_helpers.conditioning_set_values(
|
||||
conditioning,
|
||||
{"reference_latents": ref_latents},
|
||||
append=True,
|
||||
)
|
||||
latent_out = {"samples": ref_latents[0]}
|
||||
else:
|
||||
scale = 16 if 模型类型 == "Flux2" else 8
|
||||
lw = 输出宽度 // scale
|
||||
lh = 输出高度 // scale
|
||||
latent_out = {"samples": torch.zeros(1, 4, lh, lw)}
|
||||
|
||||
return (conditioning, latent_out)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DynamicRefImageEncode": DynamicRefImageEncode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DynamicRefImageEncode": "Ref Image Encode",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# ==================== Multi Ref Latent Input Nodes ====================
|
||||
# 接收多个 LATENT 输入,打包进 conditioning 的 reference_latents
|
||||
# 和图片版本不同:这些节点直接接收 latent,不需要 VAE 编码
|
||||
# ====================================================================
|
||||
|
||||
|
||||
class DynamicRefLatentInput:
|
||||
"""
|
||||
动态 LATENT 输入节点,前端 JS 提供 +/- 按钮增减输入槽。
|
||||
后端固定定义 latent1~latent10,由 num_latents 控制生效数量。
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
optional = {}
|
||||
for i in range(1, 11):
|
||||
optional[f"latent{i}"] = ("LATENT",)
|
||||
return {
|
||||
"required": {
|
||||
"conditioning": ("CONDITIONING",),
|
||||
"num_latents": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
|
||||
},
|
||||
"optional": optional,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
RETURN_NAMES = ("conditioning",)
|
||||
FUNCTION = "pack"
|
||||
CATEGORY = "Toggle-Pass"
|
||||
|
||||
def pack(self, conditioning, num_latents, **kwargs):
|
||||
ref_latents = []
|
||||
n = max(1, min(10, int(num_latents)))
|
||||
for i in range(1, n + 1):
|
||||
lat = kwargs.get(f"latent{i}", None)
|
||||
if lat is not None and "samples" in lat:
|
||||
ref_latents.append(lat["samples"])
|
||||
|
||||
if len(ref_latents) == 0:
|
||||
return (conditioning,)
|
||||
|
||||
return (node_helpers.conditioning_set_values(
|
||||
conditioning,
|
||||
{"reference_latents": ref_latents},
|
||||
append=True,
|
||||
),)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS["DynamicRefLatentInput"] = DynamicRefLatentInput
|
||||
NODE_DISPLAY_NAME_MAPPINGS["DynamicRefLatentInput"] = "Ref Latent Input"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ==================== Image Batcher ====================
|
||||
# 把多个独立 IMAGE 输入打包成 1 个 batch IMAGE 输出
|
||||
# ======================================================
|
||||
|
||||
|
||||
class ImageBatcher:
|
||||
"""
|
||||
动态 IMAGE 输入节点,前端 JS 提供 +/- 按钮增减输入槽。
|
||||
把多个独立 IMAGE 打包成一个 batch IMAGE 输出。
|
||||
后端固定定义 image1~image10,由 num_images 控制生效数量。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
optional = {}
|
||||
for i in range(1, 11):
|
||||
optional[f"image{i}"] = ("IMAGE",)
|
||||
return {
|
||||
"required": {
|
||||
"num_images": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
|
||||
},
|
||||
"optional": optional,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "batch"
|
||||
CATEGORY = "Toggle-Pass"
|
||||
|
||||
def batch(self, num_images, **kwargs):
|
||||
images = []
|
||||
n = max(1, min(10, int(num_images)))
|
||||
for i in range(1, n + 1):
|
||||
img = kwargs.get(f"image{i}", None)
|
||||
if img is not None:
|
||||
images.append(img)
|
||||
|
||||
if len(images) == 0:
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
# 把所有图片缩放成相同尺寸(以第一张图为基准)
|
||||
target_h = images[0].shape[1]
|
||||
target_w = images[0].shape[2]
|
||||
|
||||
resized = []
|
||||
for img in images:
|
||||
h, w = img.shape[1], img.shape[2]
|
||||
if h != target_h or w != target_w:
|
||||
img = img.movedim(-1, 1) # [B, H, W, C] -> [B, C, H, W]
|
||||
img = comfy.utils.common_upscale(img, target_w, target_h, "lanczos", "center")
|
||||
img = img.movedim(1, -1) # [B, C, H, W] -> [B, H, W, C]
|
||||
resized.append(img)
|
||||
|
||||
result = torch.cat(resized, dim=0)
|
||||
return (result,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS["ImageBatcher"] = ImageBatcher
|
||||
NODE_DISPLAY_NAME_MAPPINGS["ImageBatcher"] = "Image Batcher"
|
||||
|
||||
|
||||
# ==================== One Image Multi Prompt ====================
|
||||
# 1 张参考图 + 多个 prompt → 多组 conditioning + 1 个共享 latent
|
||||
# 顺序执行,避免显存爆炸
|
||||
# ================================================================
|
||||
|
||||
|
||||
class OneImageMultiPrompt:
|
||||
"""
|
||||
1 张参考图 + 多个 prompt → 多组独立 conditioning + 1 个共享 latent。
|
||||
图片只预处理一次,然后逐个 prompt 顺序编码(节省显存)。
|
||||
每组 conditioning 可外接独立的 Sampler 采样不同效果。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"clip": ("CLIP",),
|
||||
"image": ("IMAGE",),
|
||||
"prompt数量": ("INT", {"default": 4, "min": 2, "max": 8, "step": 1}),
|
||||
},
|
||||
"optional": {
|
||||
"vae": ("VAE",),
|
||||
"instruction": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": (
|
||||
"Describe the key features of the input image (color, shape, size, "
|
||||
"texture, objects, background), then explain how the user's text "
|
||||
"instruction should alter or modify the image. Generate a new image "
|
||||
"that meets the user's requirements while maintaining consistency "
|
||||
"with the original input where appropriate."
|
||||
),
|
||||
}),
|
||||
"模型类型": (["Flux2", "SD / SDXL"], {"default": "Flux2"}),
|
||||
"匹配原图尺寸": (["OFF", "ON"], {"default": "OFF"}),
|
||||
"输出宽度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
|
||||
"输出高度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
|
||||
"prompt1": ("STRING", {"multiline": True, "default": ""}),
|
||||
"prompt2": ("STRING", {"multiline": True, "default": ""}),
|
||||
"prompt3": ("STRING", {"multiline": True, "default": ""}),
|
||||
"prompt4": ("STRING", {"multiline": True, "default": ""}),
|
||||
"prompt5": ("STRING", {"multiline": True, "default": ""}),
|
||||
"prompt6": ("STRING", {"multiline": True, "default": ""}),
|
||||
"prompt7": ("STRING", {"multiline": True, "default": ""}),
|
||||
"prompt8": ("STRING", {"multiline": True, "default": ""}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",) * 8 + ("LATENT",)
|
||||
RETURN_NAMES = ("cond_1", "cond_2", "cond_3", "cond_4",
|
||||
"cond_5", "cond_6", "cond_7", "cond_8", "latent")
|
||||
FUNCTION = "encode"
|
||||
CATEGORY = "Toggle-Pass"
|
||||
|
||||
def _get_system_prompt(self, instruction):
|
||||
prefix = "<|im_start|>system\n"
|
||||
suffix = "<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
|
||||
content = instruction if instruction.strip() else (
|
||||
"Describe the key features of the input image (color, shape, size, "
|
||||
"texture, objects, background), then explain how the user's text "
|
||||
"instruction should alter or modify the image. Generate a new image "
|
||||
"that meets the user's requirements while maintaining consistency "
|
||||
"with the original input where appropriate."
|
||||
)
|
||||
if prefix in content:
|
||||
content = content.split(prefix)[1]
|
||||
if suffix in content:
|
||||
content = content.split(suffix)[0]
|
||||
content = content.replace("{}", "")
|
||||
return prefix + content + suffix
|
||||
|
||||
def encode(self, clip, image, prompt数量=4,
|
||||
vae=None, instruction="", 模型类型="Flux2",
|
||||
匹配原图尺寸="OFF", 输出宽度=1024, 输出高度=1024,
|
||||
**kwargs):
|
||||
|
||||
n = max(2, min(8, int(prompt数量)))
|
||||
llama_template = self._get_system_prompt(instruction)
|
||||
|
||||
# ---- 图片预处理(只做一次)----
|
||||
samples = image.movedim(-1, 1) # [B, C, H, W]
|
||||
current_total = samples.shape[3] * samples.shape[2]
|
||||
|
||||
if 匹配原图尺寸 == "ON":
|
||||
pixel_width = samples.shape[3]
|
||||
pixel_height = samples.shape[2]
|
||||
else:
|
||||
pixel_width = 输出宽度
|
||||
pixel_height = 输出高度
|
||||
|
||||
# VAE 编码参考图(图生图用)
|
||||
ref_latent = None
|
||||
if vae is not None:
|
||||
s = comfy.utils.common_upscale(samples, pixel_width, pixel_height, "lanczos", "center")
|
||||
img_for_vae = s.movedim(1, -1)
|
||||
ref_latent = vae.encode(img_for_vae[:, :, :, :3])
|
||||
|
||||
# VL 缩放(给 CLIP vision encoder 用的图)
|
||||
vl_total = 384 * 384
|
||||
vl_scale = math.sqrt(vl_total / current_total)
|
||||
vl_w = round(samples.shape[3] * vl_scale)
|
||||
vl_h = round(samples.shape[2] * vl_scale)
|
||||
s_vl = comfy.utils.common_upscale(samples, vl_w, vl_h, "lanczos", "center")
|
||||
vl_image = s_vl.movedim(1, -1) # [B, H, W, C]
|
||||
|
||||
# 图片 prompt 片段(固定不变)
|
||||
# 注意:不要手动加 <|vision_start|><|image_pad|><|vision_end|>
|
||||
# ComfyUI 的 Qwen2.5-VL CLIP tokenizer 会在 images 参数传入时自动插入这些 token
|
||||
image_prompt = "Picture 1: "
|
||||
|
||||
# ---- 顺序编码每个 prompt ----
|
||||
results = []
|
||||
for i in range(n):
|
||||
p = kwargs.get(f"prompt{i + 1}", "")
|
||||
full_prompt = image_prompt + p
|
||||
tokens = clip.tokenize(full_prompt, images=[vl_image], llama_template=llama_template)
|
||||
cond = clip.encode_from_tokens_scheduled(tokens)
|
||||
|
||||
if ref_latent is not None:
|
||||
cond = node_helpers.conditioning_set_values(
|
||||
cond,
|
||||
{"reference_latents": [ref_latent]},
|
||||
append=True,
|
||||
)
|
||||
|
||||
results.append(cond)
|
||||
|
||||
# ---- 共享 latent 输出 ----
|
||||
if ref_latent is not None:
|
||||
latent_out = {"samples": ref_latent}
|
||||
else:
|
||||
# 纯文生图
|
||||
scale = 16 if 模型类型 == "Flux2" else 8
|
||||
lw = 输出宽度 // scale
|
||||
lh = 输出高度 // scale
|
||||
latent_out = {"samples": torch.zeros(1, 4, lh, lw)}
|
||||
|
||||
# 补齐到 8 个 conditioning(未使用的填空 conditioning)
|
||||
while len(results) < 8:
|
||||
results.append((torch.zeros(1, 768), {}))
|
||||
|
||||
# 释放临时张量
|
||||
del samples, vl_image
|
||||
if ref_latent is not None:
|
||||
del ref_latent
|
||||
|
||||
return (*results, latent_out)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS["OneImageMultiPrompt"] = OneImageMultiPrompt
|
||||
NODE_DISPLAY_NAME_MAPPINGS["OneImageMultiPrompt"] = "一图多视角"
|
||||
# ==================== Ref Independent Output ====================
|
||||
# 每张图片独立编码,conditioning 和 latent 完全隔离,无交叉
|
||||
# 每张图走独立通道,适合需要完全独立控制每张参考图的场景
|
||||
# ================================================================
|
||||
|
||||
|
||||
|
||||
class DynamicRefIndependent:
|
||||
"""
|
||||
动态独立参考图编码节点。
|
||||
前端 JS 根据 num_images 同步增删:
|
||||
- IMAGE 输入槽 (image1~imageN)
|
||||
- prompt 控件 (prompt1~promptN)
|
||||
- CONDITIONING 输出槽 (cond_1~cond_N)
|
||||
- LATENT 输出槽 (latent1~latentN)
|
||||
每张图独立编码,输出自己的 CONDITIONING 和 LATENT。
|
||||
共 2*N 个输出:cond_1, latent1, cond_2, latent2, ..., cond_N, latentN
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
opt = {}
|
||||
for i in range(1, 11):
|
||||
opt[f"image{i}"] = ("IMAGE",)
|
||||
opt[f"prompt{i}"] = ("STRING", {"multiline": True, "default": ""})
|
||||
return {
|
||||
"required": {
|
||||
"clip": ("CLIP",),
|
||||
"vae": ("VAE",),
|
||||
"num_images": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
|
||||
"instruction": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": (
|
||||
"Describe the key features of the input image (color, shape, size, "
|
||||
"texture, objects, background), then explain how the user's text "
|
||||
"instruction should alter or modify the image. Generate a new image "
|
||||
"that meets the user's requirements while maintaining consistency "
|
||||
"with the original input where appropriate."
|
||||
),
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
**opt,
|
||||
},
|
||||
}
|
||||
|
||||
# 输出顺序:cond_1, latent1, cond_2, latent2, ..., cond_10, latent10(交替)
|
||||
# 每组:1个 CONDITIONING + 1个 LATENT(每张图各自独立的 latent)
|
||||
# 前端通过 removeOutput 只保留 N 组(N*2 个输出槽)
|
||||
RETURN_TYPES = tuple(
|
||||
item
|
||||
for i in range(10)
|
||||
for item in ("CONDITIONING", "LATENT")
|
||||
)
|
||||
RETURN_NAMES = tuple(
|
||||
item
|
||||
for i in range(1, 11)
|
||||
for item in (f"cond_{i}", f"latent{i}")
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
CATEGORY = "Toggle-Pass"
|
||||
|
||||
def _get_system_prompt(self, instruction):
|
||||
prefix = "<|im_start|>system\n"
|
||||
suffix = "<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
|
||||
content = instruction if instruction.strip() else (
|
||||
"Describe the key features of the input image (color, shape, size, "
|
||||
"texture, objects, background), then explain how the user's text "
|
||||
"instruction should alter or modify the image. Generate a new image "
|
||||
"that meets the user's requirements while maintaining consistency "
|
||||
"with the original input where appropriate."
|
||||
)
|
||||
if prefix in content:
|
||||
content = content.split(prefix)[1]
|
||||
if suffix in content:
|
||||
content = content.split(suffix)[0]
|
||||
content = content.replace("{}", "")
|
||||
return prefix + content + suffix
|
||||
|
||||
def encode(self, clip, num_images, instruction="", vae=None, **kwargs):
|
||||
n = max(1, min(10, int(num_images)))
|
||||
llama_template = self._get_system_prompt(instruction)
|
||||
conds = []
|
||||
latents = [] # 每张图自己的 latent
|
||||
first_latent = None # 第一张非空图的 latent,用于共享 latent_out
|
||||
|
||||
for i in range(1, n + 1):
|
||||
img = kwargs.get(f"image{i}", None)
|
||||
prompt = kwargs.get(f"prompt{i}", "")
|
||||
|
||||
if img is None:
|
||||
# 空槽:用纯文本 prompt 编码,不带图片,格式正确
|
||||
text = prompt or ""
|
||||
tokens = clip.tokenize(text, llama_template=llama_template)
|
||||
cond = clip.encode_from_tokens_scheduled(tokens)
|
||||
conds.append(cond)
|
||||
latents.append(None) # 空槽没有 latent
|
||||
continue
|
||||
|
||||
samples = img.movedim(-1, 1)
|
||||
current_total = samples.shape[3] * samples.shape[2]
|
||||
|
||||
# ---- VAE 编码(每张图独立编码自己的 latent)----
|
||||
img_latent = None
|
||||
if vae is not None:
|
||||
s = comfy.utils.common_upscale(samples, samples.shape[3], samples.shape[2], "lanczos", "center")
|
||||
img_for_vae = s.movedim(1, -1)
|
||||
img_latent = vae.encode(img_for_vae[:, :, :, :3])
|
||||
del s, img_for_vae
|
||||
if first_latent is None:
|
||||
first_latent = img_latent # 记录第一张图的 latent 作为 latent_out
|
||||
|
||||
# ---- VL 编码 ----
|
||||
vl_total = 384 * 384
|
||||
vl_scale = math.sqrt(vl_total / current_total)
|
||||
vl_w = round(samples.shape[3] * vl_scale)
|
||||
vl_h = round(samples.shape[2] * vl_scale)
|
||||
s_vl = comfy.utils.common_upscale(samples, vl_w, vl_h, "lanczos", "center")
|
||||
vl_image = s_vl.movedim(1, -1)
|
||||
|
||||
# ---- tokenize + encode ----
|
||||
image_tag = "Picture 1: "
|
||||
full_prompt = image_tag + (prompt or "")
|
||||
tokens = clip.tokenize(full_prompt, images=[vl_image], llama_template=llama_template)
|
||||
cond = clip.encode_from_tokens_scheduled(tokens)
|
||||
|
||||
# ---- 每张图的 conditioning 绑定自己的 latent ----
|
||||
if img_latent is not None:
|
||||
cond = node_helpers.conditioning_set_values(
|
||||
cond,
|
||||
{"reference_latents": [img_latent]},
|
||||
append=True,
|
||||
)
|
||||
|
||||
conds.append(cond)
|
||||
latents.append(img_latent)
|
||||
del samples, s_vl, vl_image
|
||||
|
||||
# 补齐到 10 个 conditioning
|
||||
while len(conds) < 10:
|
||||
conds.append((torch.zeros(1, 768), {}))
|
||||
while len(latents) < 10:
|
||||
latents.append(None)
|
||||
|
||||
# 共享 latent_out(取第一张非空图的 latent,供下游 Empty Latent 参考尺寸用)
|
||||
if first_latent is None:
|
||||
first_latent = torch.zeros(1, 4, 1, 1)
|
||||
latent_out = {"samples": first_latent}
|
||||
|
||||
# 返回:交替排列 cond_1, latent1, cond_2, latent2, ...
|
||||
# 每个 latentN 槽输出对应图的 latent(空槽用 latent_out 兜底)
|
||||
results = []
|
||||
for i in range(10):
|
||||
results.append(conds[i])
|
||||
lat_i = latents[i]
|
||||
results.append({"samples": lat_i} if lat_i is not None else latent_out)
|
||||
return (*results,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS["DynamicRefIndependent"] = DynamicRefIndependent
|
||||
NODE_DISPLAY_NAME_MAPPINGS["DynamicRefIndependent"] = "Ref Independent"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
把 example_workflows/ 目录下的 .json 工作流
|
||||
转成可直接拖入 ComfyUI 的 PNG 图片。
|
||||
|
||||
用法:
|
||||
python workflow_to_png.py
|
||||
|
||||
输出:每个 JSON 文件旁边生成同名 .png
|
||||
(PIL 未安装时会自动提示安装命令)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
WORKFLOWS_DIR = os.path.join(os.path.dirname(__file__), "example_workflows")
|
||||
|
||||
# ---------- 尝试导入 PIL ----------
|
||||
try:
|
||||
from PIL import Image, PngImagePlugin
|
||||
except ImportError:
|
||||
print("缺少 Pillow,请先运行:")
|
||||
print(" pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def json_to_png(json_path: str) -> str:
|
||||
"""把单个 JSON 工作流嵌入 PNG,返回输出路径。"""
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
workflow_text = f.read()
|
||||
|
||||
# 解析一次确保合法 JSON
|
||||
json.loads(workflow_text)
|
||||
|
||||
# 生成一张 1×1 的透明占位图(ComfyUI 只需要元数据,图像内容无关紧要)
|
||||
img = Image.new("RGB", (1, 1), color=(30, 30, 30))
|
||||
|
||||
meta = PngImagePlugin.PngInfo()
|
||||
meta.add_text("workflow", workflow_text)
|
||||
|
||||
out_path = os.path.splitext(json_path)[0] + ".png"
|
||||
img.save(out_path, pnginfo=meta, compress_level=9)
|
||||
return out_path
|
||||
|
||||
|
||||
def main():
|
||||
json_files = [
|
||||
os.path.join(WORKFLOWS_DIR, f)
|
||||
for f in os.listdir(WORKFLOWS_DIR)
|
||||
if f.endswith(".json")
|
||||
]
|
||||
|
||||
if not json_files:
|
||||
print("没有找到 .json 文件")
|
||||
return
|
||||
|
||||
print(f"共找到 {len(json_files)} 个工作流文件:\n")
|
||||
for jf in json_files:
|
||||
try:
|
||||
out = json_to_png(jf)
|
||||
print(f" ✓ {os.path.basename(jf)}")
|
||||
print(f" → {os.path.basename(out)}")
|
||||
except Exception as e:
|
||||
print(f" ✗ {os.path.basename(jf)} 错误: {e}")
|
||||
|
||||
print("\n完成!把生成的 .png 直接拖入 ComfyUI 即可恢复工作流。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user