Add files via upload
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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}` 替换后再统一编码,因此能批量生效。
|
||||
@@ -0,0 +1,3 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
@@ -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",
|
||||
})
|
||||
Reference in New Issue
Block a user