From 38a3697272033a590209c26bbbc0d53711e72f7a Mon Sep 17 00:00:00 2001 From: Ruinan Date: Thu, 25 Sep 2025 19:18:18 +0800 Subject: [PATCH] Add files via upload --- ComfyUI_PromptTool/LICENSE | 23 +++ ComfyUI_PromptTool/README.md | 77 +++++++++ ComfyUI_PromptTool/__init__.py | 3 + ComfyUI_PromptTool/nodes.py | 288 +++++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+) create mode 100644 ComfyUI_PromptTool/LICENSE create mode 100644 ComfyUI_PromptTool/README.md create mode 100644 ComfyUI_PromptTool/__init__.py create mode 100644 ComfyUI_PromptTool/nodes.py diff --git a/ComfyUI_PromptTool/LICENSE b/ComfyUI_PromptTool/LICENSE new file mode 100644 index 0000000..8adf163 --- /dev/null +++ b/ComfyUI_PromptTool/LICENSE @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2025 Ruinan (cyan9977) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/ComfyUI_PromptTool/README.md b/ComfyUI_PromptTool/README.md new file mode 100644 index 0000000..513b9bc --- /dev/null +++ b/ComfyUI_PromptTool/README.md @@ -0,0 +1,77 @@ +## 安装 + +将本仓库克隆到 ComfyUI 的 `custom_nodes` 目录,然后重启 ComfyUI。 + +``` +cd /custom_nodes +git clone https://github.com/cyan9977/ComfyUI_PromptQueue.git +``` + + +## Nodes + +### Prompt Queue +- 将多行提示词视为队列,或从 txt 文件按行读取,批量输出到 `CONDITIONING`。 +- 参数 + - `clip`: 连接到任意 CLIP 节点 + - `multiline_text`: 多行输入,每行一个提示词 + - `use_file`: 勾选后从文件读取 + - `file_path`: 指向 `.txt` 文件,按行读取(UTF-8,忽略无法解码字符) + - `template`: 可选模板,使用 `{p}` 作为占位符,例如:`"best quality, {p}, 4k"` +- 输出 + - `COND`: 已按行批量堆叠好的 conditioning,可直接连接到采样器正向提示 + +示例: + +``` +multiline_text: + A cat on the beach + A dog in the park + A bird on a tree + + template: + best quality, {p}, 4k +``` + + +## Quick Start + +1) 放置 `CLIP Text Encode`(或任意提供 `CLIP` 的上游) +2) 放置 `Prompt Queue` 并连接 `clip` +3) 在 `multiline_text` 输入多行提示词,或勾选 `use_file` 并选择 `file_path` + 4) 可选:设置 `template`(例如 `best quality, {p}, 4k`) +5) 将 `Prompt Queue` 的 `COND` 输出连接到采样器(正向提示) +6) 运行,即可对每一行提示词批量出图 + + +## 使用说明与注意事项 + +- 批量合并:内部对 `cond` 做了 padding,解决不同提示词长度导致的合并错误 +- 兼容性:若上游 `clip.encode_from_tokens(..., return_pooled=True)` 不返回 pooled,会自动降级并以空字符串占位,保证下游不出错 +- 文件编码:从 `file_path` 读取 `.txt` 时使用 UTF-8 并忽略无法解码字符;读取失败会回退到 `multiline_text` +- 空输入:当没有有效行时会输出占位 `COND` +- 模板占位:`template` 中使用 `{p}` 作为单行替换占位符 + + +## 示例(从文件读取) + +- 准备 `prompts.txt`: + +``` +A cat on the beach +A dog in the park +A bird on a tree +``` + +- 节点设置: + - `use_file`: 勾选 + - `file_path`: 指向 `prompts.txt` + - `template`: `best quality, {p}, 4k`(可选) + +- 连接采样器并运行,即可一次性按行批量生成。 + + +## FAQ + + - 为什么只改了模板就全部生效? + - 模板会对每一行 `{p}` 替换后再统一编码,因此能批量生效。 \ No newline at end of file diff --git a/ComfyUI_PromptTool/__init__.py b/ComfyUI_PromptTool/__init__.py new file mode 100644 index 0000000..5e2e13c --- /dev/null +++ b/ComfyUI_PromptTool/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/ComfyUI_PromptTool/nodes.py b/ComfyUI_PromptTool/nodes.py new file mode 100644 index 0000000..27d0920 --- /dev/null +++ b/ComfyUI_PromptTool/nodes.py @@ -0,0 +1,288 @@ +import json +import os +import hashlib +import time +import torch +import torch.nn.functional as F + +""" +最简批量帧文本编码(仅正向),增强兼容性: +- 解决:不同 prompt 生成的 cond 长度不一致导致 torch.cat 报错 -> 统一 padding +- 解决:某些 clip.encode_from_tokens 在 return_pooled=True 时返回 (cond, None) 或根本不支持 pooled 的情况 +- 若 pooled 全部为 None,则不放 pooled_output 或放占位空字符串 +""" + +def parse_frame_prompts(raw_text: str): + """ + 解析输入为 {frame_index: prompt} 的升序字典。 + 允许缺少最外层 {};允许末尾多余逗号。 + """ + txt = raw_text.strip() + if not txt: + return {} + if not txt.startswith("{"): + txt = "{" + txt + if not txt.endswith("}"): + txt = txt + "}" + txt = txt.replace(",}", "}") + data = json.loads(txt) + out = {} + for k, v in data.items(): + try: + idx = int(k) + except: + continue + # 非数字 key 忽略 + out[idx] = str(v) + return dict(sorted(out.items(), key=lambda x: x[0])) + + +def encode_prompts_sequential(clip, ordered_items): + """ + 逐条编码;对 cond 做长度统一;pooled 可能为 None。 + ordered_items: List[(frame_index, prompt)] + 返回: cond_final, pooled_final(或 None), has_pooled(bool) + """ + cond_list = [] + pooled_list = [] + pooled_available = True # 先假设有 pooled + max_tokens = 0 + + with torch.no_grad(): + for frame_idx, prompt in ordered_items: + tokens = clip.tokenize(prompt) + # 兼容:有的实现如果 return_pooled=True 仍返回单值 + enc = clip.encode_from_tokens(tokens, return_pooled=True) + if isinstance(enc, (list, tuple)) and len(enc) == 2: + cond, pooled = enc + else: + cond, pooled = enc, None + + if cond.dim() != 3: + # 期望 shape: [B(=1), T, C]; 若是 [T, C] 补一个 batch 维 + if cond.dim() == 2: + cond = cond.unsqueeze(0) + else: + raise ValueError(f"Unexpected cond tensor shape: {cond.shape}") + + if cond.shape[1] > max_tokens: + max_tokens = cond.shape[1] + + cond_list.append(cond) + + if pooled is None: + pooled_available = False + pooled_list.append(None) + else: + # 期望 [B(=1), D] 或 [D]; 若 [D] -> [1,D] + if pooled.dim() == 1: + pooled = pooled.unsqueeze(0) + pooled_list.append(pooled) + + if not cond_list: + dummy = torch.zeros(1, 1) + return dummy, None, False + + # 对 cond 做 padding + padded = [] + for c in cond_list: + if c.shape[1] < max_tokens: + pad_len = max_tokens - c.shape[1] + # shape [1, T, C],pad 第二维 (tokens):(last_dim_left, last_dim_right, second_dim_left, second_dim_right) + c = F.pad(c, (0, 0, 0, pad_len)) + padded.append(c) + cond_final = torch.cat(padded, dim=0) # [N, max_tokens, C] + + pooled_final = None + if pooled_available: + # 校验 pooled 维度并 cat + processed = [] + for p in pooled_list: + if p is None: + pooled_available = False + break + if p.dim() == 1: + p = p.unsqueeze(0) + processed.append(p) + if pooled_available and processed: + try: + pooled_final = torch.cat(processed, dim=0) # [N, D] + except Exception: + # 如果维度不一致,放弃 pooled + pooled_final = None + pooled_available = False + + return cond_final, pooled_final, pooled_available + + +## 删除了 BatchPrompt 节点,保留 PromptQueue + + +def _read_lines_from_source(multiline_text: str, file_path: str = "", use_file: bool = False): + """ + 从多行文本或文件读取,每行一个提示词;自动去除空行和首尾空白。 + """ + lines = [] + if use_file and file_path: + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + except Exception: + # 回退到多行文本 + lines = multiline_text.splitlines() + else: + lines = multiline_text.splitlines() + + cleaned = [] + for raw in lines: + s = str(raw).strip() + if not s: + continue + cleaned.append(s) + return cleaned + + +def _apply_template_to_lines(lines, template: str): + """ + 将每行替换进模板。若模板为空或不含 {p},则直接返回行本身。 + """ + if not template or "{p}" not in template: + return lines + return [template.replace("{p}", line) for line in lines] + + +class PromptQueue: + """ + 将多行提示词视为队列,或从 txt 文件读取(逐行)。 + 输出按批次堆叠的 CONDITIONING,便于一次性批量生成。 + 可选模板,将每行填充到模板中的 {prompt} 占位符。 + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "clip": ("CLIP",), + "multiline_text": ("STRING", {"multiline": True, "default": "A cat\nA dog\nA bird"}), + "use_file": ("BOOLEAN", {"default": False}), + "file_path": ("STRING", {"multiline": False, "default": ""}), + "template": ("STRING", {"multiline": False, "default": "{p}"}), + "mode": (["all", "incremental"],), + "label": ("STRING", {"multiline": False, "default": "Prompt Queue 001"}), + } + } + + RETURN_TYPES = ("CONDITIONING",) + RETURN_NAMES = ("COND",) + FUNCTION = "run" + CATEGORY = "SimpleBatch" + + def run(self, clip, multiline_text, use_file, file_path, template, mode="all", label="Prompt Queue 001"): + lines = _read_lines_from_source(multiline_text, file_path, use_file) + lines = _apply_template_to_lines(lines, template) + + if not lines: + dummy = torch.zeros(1, 1) + return ([[dummy, {"pooled_output": ""}]],) + + # Mode: incremental consumes one line per call using a persistent counter per label + if mode == "incremental": + idx = _pq_get_next_index(label, _pq_total=len(lines), source_descriptor=_pq_source_descriptor(use_file, file_path, multiline_text)) + line = lines[idx] + ordered = [(0, line)] + cond, pooled, has_pooled = encode_prompts_sequential(clip, ordered) + meta = {"pooled_output": pooled if has_pooled and pooled is not None else ""} + return ([[cond, meta]],) + + # Default: all lines batched together + ordered = list(enumerate(lines)) + cond, pooled, has_pooled = encode_prompts_sequential(clip, ordered) + meta = {"pooled_output": pooled if has_pooled and pooled is not None else ""} + return ([[cond, meta]],) + + @classmethod + def IS_CHANGED(cls, clip, multiline_text, use_file, file_path, template, mode="all", label="Prompt Queue 001"): + # Ensure incremental mode re-executes each time to advance the queue + if mode == "incremental": + return str(time.time()) + # For deterministic modes, hash inputs so caching works as expected + try: + desc = _pq_source_descriptor(bool(use_file), str(file_path), str(multiline_text)) + key = f"{desc}|{template}" + return hashlib.sha1(key.encode("utf-8", errors="ignore")).hexdigest() + except Exception: + return "" + + +# --- Persistent queue state (per label) --- +class _JsonKVStore: + def __init__(self, file_path: str): + self.file_path = file_path + self.data = {} + self._load() + + def _load(self): + try: + if os.path.exists(self.file_path): + with open(self.file_path, "r", encoding="utf-8") as f: + self.data = json.load(f) + else: + self.data = {} + except Exception: + self.data = {} + + def _save(self): + try: + os.makedirs(os.path.dirname(self.file_path), exist_ok=True) + with open(self.file_path, "w", encoding="utf-8") as f: + json.dump(self.data, f, indent=2) + except Exception: + pass + + def get(self, category: str, key: str, default=None): + return self.data.get(category, {}).get(key, default) + + def put(self, category: str, key: str, value) -> None: + if category not in self.data: + self.data[category] = {} + self.data[category][key] = value + self._save() + + +_pq_state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_prompt_queue_state") +_pq_store = _JsonKVStore(os.path.join(_pq_state_dir, "state.json")) + + +def _pq_source_descriptor(use_file: bool, file_path: str, multiline_text: str) -> str: + if use_file and file_path: + try: + return "FILE:" + os.path.abspath(file_path) + except Exception: + return "FILE:" + file_path + # Text hash as descriptor; content changes won't reset index to allow continuing when appending + h = hashlib.sha1(multiline_text.encode("utf-8", errors="ignore")).hexdigest() + return "TEXT:" + h + + +def _pq_get_next_index(label: str, _pq_total: int, source_descriptor: str) -> int: + if _pq_total <= 0: + return 0 + # Reset only when source type switches between FILE and TEXT, or file path changes + stored_src = _pq_store.get("PromptQueue Sources", label, None) + if stored_src != source_descriptor: + _pq_store.put("PromptQueue Sources", label, source_descriptor) + _pq_store.put("PromptQueue Counters", label, 0) + + current = _pq_store.get("PromptQueue Counters", label, 0) or 0 + next_index = current % _pq_total + _pq_store.put("PromptQueue Counters", label, (current + 1) % _pq_total) + return next_index + + +# 注册新节点 +NODE_CLASS_MAPPINGS.update({ + "PromptQueue": PromptQueue, +}) + +NODE_DISPLAY_NAME_MAPPINGS.update({ + "PromptQueue": "Prompt Queue", +}) \ No newline at end of file