935 lines
38 KiB
Python
935 lines
38 KiB
Python
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 或放占位空字符串
|
||
"""
|
||
|
||
# ComfyUI 节点注册字典(在文件末尾进行 update 之前,必须先定义)
|
||
NODE_CLASS_MAPPINGS = {}
|
||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||
|
||
# 参数标签翻译映射
|
||
PARAMETER_LABELS = {
|
||
"multiline_text": "多行文本",
|
||
"use_file": "使用文件",
|
||
"file_folder": "提示词文件夹",
|
||
"file_name": "文件名",
|
||
"mode": "运行模式",
|
||
"label": "队列标识",
|
||
"preset": "预设模板",
|
||
"template": "模板",
|
||
"save_preset": "保存预设",
|
||
"delete_preset": "删除预设",
|
||
"global_prefix": "全局前缀",
|
||
"save_global_prefix": "保存为全局前缀",
|
||
"artist_style_prompt": "画师风格提示词",
|
||
"prompt_suffix": "提示词后缀",
|
||
"preset_name_for_save": "保存预设名称",
|
||
"index_setting": "索引设置"
|
||
}
|
||
|
||
|
||
def _pq_normalize_prompt_part(s: str) -> str:
|
||
"""清理提示词片段首尾空白与多余逗号,避免拼接后出现 ', ,' 或 ',,'。"""
|
||
if s is None:
|
||
return ""
|
||
s = str(s).strip()
|
||
# 仅处理首尾逗号(不改中间内容)
|
||
s = s.strip(",").strip()
|
||
return s
|
||
|
||
|
||
_PQ_GLOBAL_PREFIX_CATEGORY = "Global"
|
||
_PQ_GLOBAL_PREFIX_KEY = "Global Prefix"
|
||
|
||
|
||
def _pq_get_global_prefix() -> str:
|
||
return str(_pq_store.get(_PQ_GLOBAL_PREFIX_CATEGORY, _PQ_GLOBAL_PREFIX_KEY, "") or "")
|
||
|
||
|
||
def _pq_set_global_prefix(value: str) -> None:
|
||
_pq_store.put(_PQ_GLOBAL_PREFIX_CATEGORY, _PQ_GLOBAL_PREFIX_KEY, str(value or ""))
|
||
|
||
|
||
def _pq_build_prompt_file_path(file_folder: str, file_name: str) -> str:
|
||
"""
|
||
将“提示词文件夹 + 文件名”组合成最终 .txt 路径。
|
||
- file_name 若已包含 .txt(忽略大小写),则不重复追加。
|
||
- file_folder 可为空;为空时返回 file_name(便于直接写完整路径)。
|
||
"""
|
||
folder = str(file_folder or "").strip()
|
||
name = str(file_name or "").strip()
|
||
if not name:
|
||
return ""
|
||
if not name.lower().endswith(".txt"):
|
||
name = name + ".txt"
|
||
if folder:
|
||
return os.path.join(folder, name)
|
||
return name
|
||
|
||
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"意外的 cond 张量形状: {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)
|
||
# 创建正确维度的 dummy pooled tensor (SDXL 需要 2816 维)
|
||
dummy_pooled = torch.zeros(1, 2816)
|
||
return dummy, dummy_pooled, 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
|
||
|
||
# 如果 pooled 不可用,创建 dummy pooled tensor
|
||
if pooled_final is None:
|
||
# 创建正确维度的 dummy pooled tensor (SDXL 需要 2816 维)
|
||
pooled_final = torch.zeros(cond_final.shape[0], 2816) # [N, 2816]
|
||
|
||
return cond_final, pooled_final, pooled_available
|
||
|
||
|
||
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]
|
||
|
||
|
||
def _parse_index_setting(index_str: str) -> list:
|
||
"""
|
||
解析索引设置字符串。
|
||
支持格式:
|
||
- "-1" 或 "": 记忆模式 (Auto/Resume) -> 返回 [-1]
|
||
- "0": 重置模式 (Reset to 0) -> 返回 [0]
|
||
- "5": 单行模式 -> 返回 [5] (第6行)
|
||
- "2,8,35": 列表模式 -> 返回 [2, 8, 35]
|
||
"""
|
||
s = str(index_str).strip()
|
||
if not s:
|
||
return [-1]
|
||
|
||
# 尝试分割逗号
|
||
parts = s.replace(",", ",").split(",")
|
||
indices = []
|
||
for p in parts:
|
||
p = p.strip()
|
||
if not p:
|
||
continue
|
||
try:
|
||
val = int(p)
|
||
indices.append(val)
|
||
except ValueError:
|
||
continue
|
||
|
||
if not indices:
|
||
return [-1]
|
||
|
||
return indices
|
||
|
||
|
||
class NegativePromptQueue:
|
||
"""
|
||
负面提示词队列节点,专门用于处理负面提示词。
|
||
支持多行文本输入和预设模板功能,模板配置方式与PromptQueue一致。
|
||
"""
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
# 动态获取预设列表(负面)
|
||
presets = _pq_load_negative_template_presets()
|
||
preset_list = ["None"] + list(presets.keys())
|
||
|
||
return {
|
||
"required": {
|
||
"clip": ("CLIP",),
|
||
"多行文本": ("STRING", {"multiline": True, "default": "blurry, low quality, distorted"}),
|
||
"预设模板": (preset_list, {"default": "None"}),
|
||
"模板": ("STRING", {"multiline": False, "default": "{p}"}),
|
||
"保存预设": ("BOOLEAN", {"default": False}),
|
||
"删除预设": ("BOOLEAN", {"default": False}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("CONDITIONING",)
|
||
RETURN_NAMES = ("negative",)
|
||
FUNCTION = "run"
|
||
CATEGORY = "PromptQueue"
|
||
|
||
def run(self, clip, 多行文本, 预设模板="None", 模板="{p}", 保存预设=False, 删除预设=False):
|
||
# 处理删除预设 - 拥有最高优先级
|
||
if 删除预设 and 预设模板 != "None":
|
||
_pq_delete_negative_template_preset(预设模板)
|
||
print(f"[Negative Prompt Queue] 已删除预设 '{预设模板}'")
|
||
print(f"[Negative Prompt Queue] 使用 template 内容(删除预设后): {模板}")
|
||
# 处理保存预设 - 第二优先级,强制使用 template 内容
|
||
elif 保存预设 and 模板.strip():
|
||
_pq_save_negative_template_preset(模板.strip(), 模板.strip())
|
||
print(f"[Negative Prompt Queue] 已保存预设 '{模板.strip()}': {模板.strip()}")
|
||
print(f"[Negative Prompt Queue] 使用 template 内容(save_preset 优先级): {模板}")
|
||
# 处理预设选择 - 当选择非"None"预设且未开启保存/删除时,使用预设内容
|
||
elif 预设模板 != "None":
|
||
presets = _pq_load_negative_template_presets()
|
||
if 预设模板 in presets:
|
||
模板 = presets[预设模板]
|
||
print(f"[Negative Prompt Queue] 使用预设 '{预设模板}': {模板}")
|
||
else:
|
||
print(f"[Negative Prompt Queue] 预设 '{预设模板}' 不存在,使用 template 内容")
|
||
else:
|
||
print(f"[Negative Prompt Queue] 使用 template 内容: {模板}")
|
||
|
||
lines = _read_lines_from_source(多行文本, "", False)
|
||
lines = _apply_template_to_lines(lines, 模板)
|
||
|
||
if not lines:
|
||
# 使用标准的 CLIP 编码方式处理空输入
|
||
tokens = clip.tokenize("")
|
||
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
|
||
if pooled is None:
|
||
pooled = torch.zeros(1, 2816) # SDXL 需要 2816 维
|
||
return ([[cond, {"pooled_output": pooled}]],)
|
||
|
||
# 使用标准的 CLIP 编码方式处理每一行
|
||
cond_list = []
|
||
pooled_list = []
|
||
|
||
with torch.no_grad():
|
||
for line in lines:
|
||
tokens = clip.tokenize(line)
|
||
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
|
||
|
||
# 确保 cond 是 3D 张量 [B, T, C]
|
||
if cond.dim() == 2:
|
||
cond = cond.unsqueeze(0)
|
||
|
||
# 确保 pooled 是 2D 张量 [B, D]
|
||
if pooled is None:
|
||
pooled = torch.zeros(1, 2816) # SDXL 需要 2816 维
|
||
elif pooled.dim() == 1:
|
||
pooled = pooled.unsqueeze(0)
|
||
|
||
cond_list.append(cond)
|
||
pooled_list.append(pooled)
|
||
|
||
# 将所有条件连接起来
|
||
if cond_list:
|
||
# 对 cond 做 padding 以确保长度一致
|
||
max_tokens = max(c.shape[1] for c in cond_list)
|
||
padded_conds = []
|
||
for c in cond_list:
|
||
if c.shape[1] < max_tokens:
|
||
pad_len = max_tokens - c.shape[1]
|
||
c = F.pad(c, (0, 0, 0, pad_len))
|
||
padded_conds.append(c)
|
||
cond_final = torch.cat(padded_conds, dim=0)
|
||
pooled_final = torch.cat(pooled_list, dim=0)
|
||
else:
|
||
# 空情况
|
||
cond_final = torch.zeros(1, 1)
|
||
pooled_final = torch.zeros(1, 2816)
|
||
|
||
return ([[cond_final, {"pooled_output": pooled_final}]],)
|
||
|
||
@classmethod
|
||
def IS_CHANGED(cls, clip, 多行文本, 预设模板="None", 模板="{p}", 保存预设=False, 删除预设=False):
|
||
# 当预设更改时强制重新执行以实时更新模板
|
||
if 预设模板 != "None":
|
||
return str(time.time())
|
||
# 对于确定性模式,对输入进行哈希以便缓存按预期工作
|
||
try:
|
||
desc = _pq_source_descriptor(False, "", str(多行文本))
|
||
key = f"{desc}|{模板}|{预设模板}"
|
||
return hashlib.sha1(key.encode("utf-8", errors="ignore")).hexdigest()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
class StylePromptQueue:
|
||
"""
|
||
[增量模式] 风格化提示词队列节点。
|
||
- 每次运行仅输出一行提示词。
|
||
- 即使 Batch Size > 1,也全部使用这一行(用于提高容错率)。
|
||
- 索引设置:支持 "-1"(记忆/自动), "0"(从0开始), "5"(从5开始), "2,8,35"(列表循环)
|
||
"""
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
# 动态获取预设列表(正面)
|
||
presets = _pq_load_positive_template_presets()
|
||
preset_list = ["None"] + list(presets.keys())
|
||
|
||
return {
|
||
"required": {
|
||
"clip": ("CLIP",),
|
||
"全局前缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"保存为全局前缀": ("BOOLEAN", {"default": False}),
|
||
"画师风格提示词": ("STRING", {"multiline": False, "default": ""}),
|
||
"多行文本": ("STRING", {"multiline": True, "default": "A cat\nA dog"}),
|
||
"提示词后缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"使用文件": ("BOOLEAN", {"default": False}),
|
||
"提示词文件夹": ("STRING", {"multiline": False, "default": ""}),
|
||
"文件名": ("STRING", {"multiline": False, "default": ""}),
|
||
"索引设置": ("STRING", {"multiline": False, "default": "-1"}),
|
||
"预设模板": (preset_list, {"default": "None"}),
|
||
"保存预设名称": ("STRING", {"multiline": False, "default": ""}),
|
||
"保存预设": ("BOOLEAN", {"default": False}),
|
||
"删除预设": ("BOOLEAN", {"default": False}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||
RETURN_NAMES = ("positive", "prompt_text")
|
||
FUNCTION = "run"
|
||
CATEGORY = "PromptQueue"
|
||
|
||
def run(self, clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板, 保存预设名称, 保存预设, 删除预设, unique_id=None):
|
||
# 处理全局前缀(独立于预设逻辑,永远保留最高层级)
|
||
if 保存为全局前缀:
|
||
_pq_set_global_prefix(全局前缀)
|
||
global_prefix = _pq_normalize_prompt_part(全局前缀) or _pq_normalize_prompt_part(_pq_get_global_prefix())
|
||
|
||
prefix = 画师风格提示词
|
||
suffix = 提示词后缀
|
||
|
||
# 处理预设
|
||
if 删除预设 and 预设模板 != "None":
|
||
_pq_delete_positive_template_preset(预设模板)
|
||
print(f"[Style Prompt Queue] 已删除预设 '{预设模板}'")
|
||
|
||
elif 保存预设 and 保存预设名称.strip():
|
||
preset_name = 保存预设名称.strip()
|
||
|
||
parts = []
|
||
p = prefix.strip()
|
||
s = suffix.strip()
|
||
if p:
|
||
parts.append(p)
|
||
parts.append("{p}")
|
||
if s:
|
||
parts.append(s)
|
||
|
||
preset_data = ",".join(parts)
|
||
_pq_save_positive_template_preset(preset_name, preset_data)
|
||
print(f"[Style Prompt Queue] 已保存预设 '{preset_name}': {preset_data}")
|
||
|
||
elif 预设模板 != "None":
|
||
presets = _pq_load_positive_template_presets()
|
||
if 预设模板 in presets:
|
||
preset_content = presets[预设模板]
|
||
|
||
is_legacy_json = False
|
||
try:
|
||
data = json.loads(preset_content)
|
||
if isinstance(data, dict) and ("prefix" in data or "suffix" in data):
|
||
prefix = data.get("prefix", "")
|
||
suffix = data.get("suffix", "")
|
||
print(f"[Style Prompt Queue] 已从旧版JSON格式加载预设 '{预设模板}'")
|
||
is_legacy_json = True
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
|
||
if not is_legacy_json:
|
||
if "{p}" in preset_content:
|
||
parts = preset_content.split("{p}", 1)
|
||
prefix = parts[0].rstrip(',').strip()
|
||
if len(parts) > 1:
|
||
suffix = parts[1].lstrip(',').strip()
|
||
else:
|
||
suffix = ""
|
||
print(f"[Style Prompt Queue] 已加载预设 '{预设模板}'")
|
||
else:
|
||
prefix = preset_content
|
||
suffix = ""
|
||
print(f"[Style Prompt Queue] 无法解析预设 '{预设模板}',已作为前缀处理")
|
||
|
||
# 组合文件路径(优先“文件夹+文件名”,回退到“文件路径”以兼容旧工作流)
|
||
effective_file_path = _pq_build_prompt_file_path(提示词文件夹, 文件名)
|
||
lines = _read_lines_from_source(多行文本, effective_file_path, 使用文件)
|
||
|
||
# 应用前缀和后缀
|
||
final_lines = []
|
||
for line in lines:
|
||
parts = []
|
||
for p in [global_prefix, prefix, line, suffix]:
|
||
pp = _pq_normalize_prompt_part(p)
|
||
if pp:
|
||
parts.append(pp)
|
||
final_lines.append(", ".join(parts))
|
||
|
||
if not final_lines:
|
||
dummy = torch.zeros(1, 1)
|
||
dummy_pooled = torch.zeros(1, 2816)
|
||
return ([[dummy, {"pooled_output": dummy_pooled}]], "")
|
||
|
||
# --- 核心索引控制逻辑 ---
|
||
indices = _parse_index_setting(索引设置)
|
||
idx_mode = indices[0]
|
||
|
||
target_idx = 0
|
||
desc = _pq_source_descriptor(使用文件, effective_file_path, 多行文本)
|
||
|
||
# 扩展逻辑:支持 "Start From X" 且自动递增
|
||
# 检测 索引设置变更 或 源内容变更
|
||
|
||
queue_label = "Auto_Queue"
|
||
if unique_id:
|
||
queue_label = f"{unique_id}_Auto_Queue"
|
||
|
||
current_setting_str = str(索引设置).strip()
|
||
# 索引设置状态也需要隔离,否则不同节点使用相同文本但不同设置时会互相干扰
|
||
setting_key = f"{queue_label}_Settings_{desc}"
|
||
last_setting = _pq_store.get("Index Settings", setting_key, "")
|
||
|
||
setting_changed = (current_setting_str != last_setting)
|
||
if setting_changed:
|
||
_pq_store.put("Index Settings", setting_key, current_setting_str)
|
||
|
||
# 获取 Auto_Queue 的最后一次源,用于判断是否发生源变更
|
||
last_source = _pq_store.get("PromptQueue Sources", queue_label, None)
|
||
source_changed = (last_source != desc)
|
||
|
||
should_reset_counter = setting_changed or source_changed
|
||
|
||
if idx_mode == -1:
|
||
# 记忆模式 (Auto/Resume)
|
||
target_idx = _pq_get_next_index(queue_label, len(final_lines), desc)
|
||
|
||
elif len(indices) == 1 and idx_mode >= 0:
|
||
# 0: Auto from beginning (Start from 0 + Auto Increment)
|
||
# N > 0: Fixed single line N (1-based index) -> Index N-1
|
||
|
||
if idx_mode == 0:
|
||
# Auto Mode (0)
|
||
if should_reset_counter:
|
||
_pq_store.put("PromptQueue Counters", queue_label, 0)
|
||
_pq_store.put("PromptQueue Sources", queue_label, desc)
|
||
|
||
target_idx = _pq_get_next_index(queue_label, len(final_lines), desc)
|
||
else:
|
||
# Fixed Line Mode (e.g. 1 -> 1st line -> index 0)
|
||
# Non-incremental
|
||
target_idx = idx_mode - 1
|
||
|
||
else:
|
||
# 列表模式 (2,8,35) -> 视为 1-based index
|
||
# 使用一个基于列表内容的特殊 key
|
||
list_key = f"List_{索引设置}"
|
||
# 这里的 total 是列表长度
|
||
list_idx = _pq_get_next_index(list_key, len(indices), list_key)
|
||
val = indices[list_idx]
|
||
# 转换为 0-based
|
||
target_idx = val - 1
|
||
if target_idx < 0:
|
||
target_idx = 0
|
||
|
||
# 越界保护
|
||
if target_idx >= len(final_lines):
|
||
target_idx = target_idx % len(final_lines)
|
||
if target_idx < 0:
|
||
target_idx = 0
|
||
|
||
# Debug print
|
||
print(f"[PromptQueue] Index Setting: '{索引设置}' -> Mode: {idx_mode} -> Target Line: {target_idx + 1}")
|
||
|
||
line_to_process = final_lines[target_idx]
|
||
|
||
ordered = [(0, line_to_process)]
|
||
cond, pooled, has_pooled = encode_prompts_sequential(clip, ordered)
|
||
meta = {"pooled_output": pooled}
|
||
return ([[cond, meta]], line_to_process)
|
||
|
||
@classmethod
|
||
def IS_CHANGED(cls, clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板, 保存预设名称, 保存预设, 删除预设):
|
||
# 始终返回时间戳以强制重新执行
|
||
return str(time.time())
|
||
|
||
|
||
class SimpleStylePromptQueue(StylePromptQueue):
|
||
"""
|
||
[增量模式] 简化版提示词队列节点 (无预设管理)。
|
||
继承自 StylePromptQueue,但去除了预设管理相关的输入。
|
||
"""
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
presets = _pq_load_positive_template_presets()
|
||
preset_list = ["None"] + list(presets.keys())
|
||
return {
|
||
"required": {
|
||
"clip": ("CLIP",),
|
||
"全局前缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"保存为全局前缀": ("BOOLEAN", {"default": False}),
|
||
"画师风格提示词": ("STRING", {"multiline": False, "default": ""}),
|
||
"多行文本": ("STRING", {"multiline": True, "default": "A cat\nA dog"}),
|
||
"提示词后缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"使用文件": ("BOOLEAN", {"default": False}),
|
||
"提示词文件夹": ("STRING", {"multiline": False, "default": ""}),
|
||
"文件名": ("STRING", {"multiline": False, "default": ""}),
|
||
"索引设置": ("STRING", {"multiline": False, "default": "-1"}),
|
||
"预设模板": (preset_list, {"default": "None"}),
|
||
}
|
||
}
|
||
|
||
def run(self, clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板, unique_id=None):
|
||
# 调用父类的 run,传入 None/False 作为预设参数
|
||
return super().run(
|
||
clip,
|
||
全局前缀,
|
||
保存为全局前缀,
|
||
画师风格提示词,
|
||
多行文本,
|
||
提示词后缀,
|
||
使用文件,
|
||
提示词文件夹,
|
||
文件名,
|
||
索引设置,
|
||
预设模板,
|
||
"",
|
||
False,
|
||
False,
|
||
unique_id=unique_id
|
||
)
|
||
|
||
@classmethod
|
||
def IS_CHANGED(cls, clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板):
|
||
return str(time.time())
|
||
|
||
|
||
class StylePromptQueueBatch:
|
||
"""
|
||
[批量模式] 风格化提示词队列节点。
|
||
- 每次运行输出所有行(或指定范围)。
|
||
- 这里的索引设置主要用于指定 "从哪一行开始" 或 "只输出哪几行"。
|
||
"""
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
presets = _pq_load_positive_template_presets()
|
||
preset_list = ["None"] + list(presets.keys())
|
||
|
||
return {
|
||
"required": {
|
||
"clip": ("CLIP",),
|
||
"全局前缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"保存为全局前缀": ("BOOLEAN", {"default": False}),
|
||
"画师风格提示词": ("STRING", {"multiline": False, "default": ""}),
|
||
"多行文本": ("STRING", {"multiline": True, "default": "A cat\nA dog"}),
|
||
"提示词后缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"使用文件": ("BOOLEAN", {"default": False}),
|
||
"提示词文件夹": ("STRING", {"multiline": False, "default": ""}),
|
||
"文件名": ("STRING", {"multiline": False, "default": ""}),
|
||
"索引设置": ("STRING", {"multiline": False, "default": "0"}),
|
||
"预设模板": (preset_list, {"default": "None"}),
|
||
"保存预设名称": ("STRING", {"multiline": False, "default": ""}),
|
||
"保存预设": ("BOOLEAN", {"default": False}),
|
||
"删除预设": ("BOOLEAN", {"default": False}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||
RETURN_NAMES = ("positive", "prompt_text_list")
|
||
FUNCTION = "run"
|
||
CATEGORY = "PromptQueue"
|
||
|
||
def run(self, clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板, 保存预设名称, 保存预设, 删除预设):
|
||
if 保存为全局前缀:
|
||
_pq_set_global_prefix(全局前缀)
|
||
global_prefix = _pq_normalize_prompt_part(全局前缀) or _pq_normalize_prompt_part(_pq_get_global_prefix())
|
||
|
||
prefix = 画师风格提示词
|
||
suffix = 提示词后缀
|
||
|
||
# 处理预设
|
||
if 删除预设 and 预设模板 != "None":
|
||
_pq_delete_positive_template_preset(预设模板)
|
||
print(f"[Prompt Queue Batch] 已删除预设 '{预设模板}'")
|
||
|
||
elif 保存预设 and 保存预设名称.strip():
|
||
preset_name = 保存预设名称.strip()
|
||
|
||
parts = []
|
||
p = prefix.strip()
|
||
s = suffix.strip()
|
||
if p:
|
||
parts.append(p)
|
||
parts.append("{p}")
|
||
if s:
|
||
parts.append(s)
|
||
|
||
preset_data = ",".join(parts)
|
||
_pq_save_positive_template_preset(preset_name, preset_data)
|
||
print(f"[Prompt Queue Batch] 已保存预设 '{preset_name}': {preset_data}")
|
||
|
||
elif 预设模板 != "None":
|
||
presets = _pq_load_positive_template_presets()
|
||
if 预设模板 in presets:
|
||
preset_content = presets[预设模板]
|
||
# 简化处理,仅支持新版 {p} 格式或纯前缀
|
||
if "{p}" in preset_content:
|
||
parts = preset_content.split("{p}", 1)
|
||
prefix = parts[0].rstrip(',').strip()
|
||
if len(parts) > 1:
|
||
suffix = parts[1].lstrip(',').strip()
|
||
else:
|
||
suffix = ""
|
||
else:
|
||
prefix = preset_content
|
||
suffix = ""
|
||
|
||
effective_file_path = _pq_build_prompt_file_path(提示词文件夹, 文件名)
|
||
lines = _read_lines_from_source(多行文本, effective_file_path, 使用文件)
|
||
|
||
final_lines = []
|
||
for line in lines:
|
||
parts = []
|
||
for p in [global_prefix, prefix, line, suffix]:
|
||
pp = _pq_normalize_prompt_part(p)
|
||
if pp:
|
||
parts.append(pp)
|
||
final_lines.append(", ".join(parts))
|
||
|
||
if not final_lines:
|
||
dummy = torch.zeros(1, 1)
|
||
dummy_pooled = torch.zeros(1, 2816)
|
||
return ([[dummy, {"pooled_output": dummy_pooled}]], "")
|
||
|
||
# 索引处理
|
||
# 批量模式下,索引设置通常意味着:
|
||
# "0": 输出所有,从 0 开始
|
||
# "5": 输出所有,从 5 开始 (5, 6, 7...)
|
||
# "2,8,35": 只输出这几行
|
||
|
||
indices = _parse_index_setting(索引设置)
|
||
selected_lines = []
|
||
|
||
if indices[0] == -1:
|
||
# 默认全部
|
||
selected_lines = list(enumerate(final_lines))
|
||
elif len(indices) == 1 and indices[0] >= 0:
|
||
# 从指定行开始到结束
|
||
start_idx = indices[0]
|
||
if start_idx >= len(final_lines):
|
||
start_idx = 0 # 越界回退
|
||
|
||
# 创建切片
|
||
subset = final_lines[start_idx:]
|
||
# 重新编号,为了 encode_prompts_sequential 里的排序(其实顺序已经定了)
|
||
# 注意:这里的 idx 仅仅用于排序,只要是升序即可
|
||
for i, line in enumerate(subset):
|
||
selected_lines.append((i, line))
|
||
else:
|
||
# 指定列表
|
||
for i, idx in enumerate(indices):
|
||
if 0 <= idx < len(final_lines):
|
||
selected_lines.append((i, final_lines[idx]))
|
||
|
||
if not selected_lines:
|
||
# 如果选完是空的,回退到全部
|
||
selected_lines = list(enumerate(final_lines))
|
||
|
||
cond, pooled, has_pooled = encode_prompts_sequential(clip, selected_lines)
|
||
meta = {"pooled_output": pooled}
|
||
|
||
# 批量模式下返回所有选中的提示词文本,用换行符分隔
|
||
output_text = "\n".join([item[1] for item in selected_lines])
|
||
return ([[cond, meta]], output_text)
|
||
|
||
@classmethod
|
||
def IS_CHANGED(cls, **kwargs):
|
||
# 批量模式通常是确定性的,除非输入变了
|
||
# 但为了避免缓存导致修改索引无效,我们可以简单 hash 输入
|
||
return float("nan") # 总是更新
|
||
|
||
|
||
class SimpleStylePromptQueueBatch(StylePromptQueueBatch):
|
||
"""
|
||
[批量模式] 简化版提示词队列节点 (无预设管理)。
|
||
继承自 StylePromptQueueBatch,但去除了预设管理相关的输入。
|
||
"""
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
presets = _pq_load_positive_template_presets()
|
||
preset_list = ["None"] + list(presets.keys())
|
||
return {
|
||
"required": {
|
||
"clip": ("CLIP",),
|
||
"全局前缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"保存为全局前缀": ("BOOLEAN", {"default": False}),
|
||
"画师风格提示词": ("STRING", {"multiline": False, "default": ""}),
|
||
"多行文本": ("STRING", {"multiline": True, "default": "A cat\nA dog"}),
|
||
"提示词后缀": ("STRING", {"multiline": False, "default": ""}),
|
||
"使用文件": ("BOOLEAN", {"default": False}),
|
||
"提示词文件夹": ("STRING", {"multiline": False, "default": ""}),
|
||
"文件名": ("STRING", {"multiline": False, "default": ""}),
|
||
"索引设置": ("STRING", {"multiline": False, "default": "0"}),
|
||
"预设模板": (preset_list, {"default": "None"}),
|
||
}
|
||
}
|
||
|
||
def run(self, clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板):
|
||
# 调用父类的 run,传入 None 作为预设参数
|
||
return super().run(clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板, "", False, False)
|
||
|
||
@classmethod
|
||
def IS_CHANGED(cls, clip, 全局前缀, 保存为全局前缀, 画师风格提示词, 多行文本, 提示词后缀, 使用文件, 提示词文件夹, 文件名, 索引设置, 预设模板):
|
||
return float("nan")
|
||
|
||
|
||
# --- 持久队列状态(每个标签)---
|
||
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()
|
||
|
||
def remove(self, category: str, key: str) -> None:
|
||
"""从存储中移除一个键"""
|
||
if category in self.data and key in self.data[category]:
|
||
del self.data[category][key]
|
||
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"))
|
||
_preset_store = _JsonKVStore(os.path.join(_pq_state_dir, "presets.json"))
|
||
|
||
# --- 数据迁移逻辑 ---
|
||
# 检查旧 store 中是否有预设数据,如果有则迁移到新 store 并从旧 store 删除
|
||
_migration_occurred = False
|
||
for category in ["Template Presets Positive", "Template Presets Negative"]:
|
||
if category in _pq_store.data:
|
||
# 复制数据到 preset store
|
||
if category not in _preset_store.data:
|
||
_preset_store.data[category] = {}
|
||
|
||
# 合并数据(优先保留 preset store 现有的,或者覆盖?这里假设 preset store 为空或应该包含旧数据)
|
||
for k, v in _pq_store.data[category].items():
|
||
_preset_store.data[category][k] = v
|
||
|
||
# 从旧 store 删除 category
|
||
del _pq_store.data[category]
|
||
_migration_occurred = True
|
||
|
||
if _migration_occurred:
|
||
_preset_store._save()
|
||
_pq_store._save()
|
||
print("[PromptQueue] Migrated presets from state.json to presets.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
|
||
# 文本哈希作为描述符;内容更改不会重置索引以允许在追加时继续
|
||
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
|
||
# 仅在源类型在 FILE 和 TEXT 之间切换或文件路径更改时重置
|
||
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
|
||
|
||
|
||
def _pq_save_positive_template_preset(name: str, template: str) -> None:
|
||
"""保存正面模板预设"""
|
||
_preset_store.put("Template Presets Positive", name, template)
|
||
|
||
|
||
def _pq_load_positive_template_presets() -> dict:
|
||
"""加载所有正面模板预设"""
|
||
return _preset_store.data.get("Template Presets Positive", {})
|
||
|
||
|
||
def _pq_delete_positive_template_preset(name: str) -> None:
|
||
"""删除正面模板预设"""
|
||
_preset_store.remove("Template Presets Positive", name)
|
||
|
||
|
||
def _pq_save_negative_template_preset(name: str, template: str) -> None:
|
||
"""保存负面模板预设"""
|
||
_preset_store.put("Template Presets Negative", name, template)
|
||
|
||
|
||
def _pq_load_negative_template_presets() -> dict:
|
||
"""加载所有负面模板预设"""
|
||
return _preset_store.data.get("Template Presets Negative", {})
|
||
|
||
|
||
def _pq_delete_negative_template_preset(name: str) -> None:
|
||
"""删除负面模板预设"""
|
||
_preset_store.remove("Template Presets Negative", name)
|
||
|
||
|
||
# 注册新节点
|
||
NODE_CLASS_MAPPINGS.update({
|
||
"NegativePromptQueue": NegativePromptQueue,
|
||
"StylePromptQueue": StylePromptQueue,
|
||
"SimpleStylePromptQueue": SimpleStylePromptQueue,
|
||
"StylePromptQueueBatch": StylePromptQueueBatch,
|
||
"SimpleStylePromptQueueBatch": SimpleStylePromptQueueBatch,
|
||
})
|
||
|
||
NODE_DISPLAY_NAME_MAPPINGS.update({
|
||
"NegativePromptQueue": "Negative PQ / 负面队列",
|
||
"StylePromptQueue": "PQ (Incremental) / 增量队列",
|
||
"SimpleStylePromptQueue": "PQ Simple (Incr.) / 增量简易",
|
||
"StylePromptQueueBatch": "PQ (Batch) / 批量队列",
|
||
"SimpleStylePromptQueueBatch": "PQ Simple (Batch) / 批量简易",
|
||
}) |