Add files via upload

This commit is contained in:
Ruinan
2025-09-25 19:18:18 +08:00
committed by GitHub
parent b6f32fdf16
commit 38a3697272
4 changed files with 391 additions and 0 deletions
+23
View File
@@ -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.
+77
View File
@@ -0,0 +1,77 @@
## 安装
将本仓库克隆到 ComfyUI 的 `custom_nodes` 目录,然后重启 ComfyUI。
```
cd <ComfyUI 根目录>/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}` 替换后再统一编码,因此能批量生效。
+3
View File
@@ -0,0 +1,3 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+288
View File
@@ -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",
})