Files
Taremin c8647cab54 fix: ComfyUIの仕様変更に伴うリスト型ノードの出力バグの修正
最新のComfyUIにおける実行エンジンのリスト自動アンラップ廃止に伴い、リスト型ノードの出力データが下流ノードで二重にラップされて正常に結合できなくなる不具合を解消するため、およびE2Eテスト環境の不整合を修正

- StringToolsRandomChoiceList、StringToolsBalancedChoiceList、StringToolsConcatList の process 戻り値から不要なリストブラケットを削除
- test_string_tools_complex_interconnection において seed と text_list の接続ポートが逆になっていた不具合を修正
- テスト内モックノード StringToolsMockStringList をテスト実行環境(COMFYUI_TEST_MODE または pytest 稼働中)のみ動的に登録するように制御
- モックノード誤露出時のフェイルセーフとして NODE_DISPLAY_NAME_MAPPINGS にテスト用である旨の表示名を設定
- conftest.py の起動オプションにホワイトリストを追加しテスト実行速度を高速化
2026-07-04 09:08:02 +09:00

550 lines
18 KiB
Python

import random
import re
# 真の動的入力を実現するための辞書クラス
class StringToolsOptionalDict(dict):
def __contains__(self, key: object) -> bool:
# text_NN 形式のキーなら常に存在するとみなす
if isinstance(key, str) and re.match(r"text_\d+", key):
return True
return super().__contains__(key)
def __getitem__(self, key):
if isinstance(key, str) and re.match(r"text_\d+", key):
return super().get(key, ("STRING", {"forceInput": True}))
return super().__getitem__(key)
def get(self, key, default=None):
if isinstance(key, str) and re.match(r"text_\d+", key):
return super().get(key, ("STRING", {"forceInput": True}))
return super().get(key, default)
def sort_kwargs_value(basename, kwargs):
basename_dict = {}
for key, value in kwargs.items():
if key.startswith(basename) and value is not None:
parts = key.split("_")
if len(parts) > 1 and parts[1].isdigit():
# value がリストの場合は最初の要素を取り出し、そうでなければそのまま文字列化
if isinstance(value, list):
basename_dict[int(parts[1])] = str(value[0]) if value else ""
else:
basename_dict[int(parts[1])] = str(value)
sorted_basename_dict = sorted(basename_dict.items())
return list(map(lambda kv: kv[1], sorted_basename_dict))
def get_node(kwargs):
extra_pnginfo = kwargs.get("extra_pnginfo", None)
unique_id = kwargs.get("unique_id", None)
if isinstance(extra_pnginfo, list):
extra_pnginfo = extra_pnginfo[0] if extra_pnginfo else None
if isinstance(unique_id, list):
unique_id = unique_id[0] if unique_id else None
if extra_pnginfo is None or unique_id is None:
return None
workflow = extra_pnginfo.get("workflow", None)
if workflow is None:
return None
node_path = [unique_id]
def walkdown_node_path(current_path, workflow):
if not current_path: return None
node_id = current_path[0]
node = None
for n in workflow.get("nodes", []):
if str(n["id"]) == str(node_id):
node = n
break
if node is None:
return None
if node["type"] == "Subgraph" or "subgraph" in node:
subgraphs_by_id = workflow.get("definitions", {})
if node["type"] in subgraphs_by_id:
return walkdown_node_path(current_path[1:], subgraphs_by_id[node["type"]])
return node
node = walkdown_node_path(node_path, workflow)
return node
def calculate_weights_from_prompt(prompt, target_node_id, with_weights_classes, weight_prefix="text"):
# JS 実装 (index.ts) と同様に ID をフラット化してマッチングするための前処理
# 末尾の数値IDをキーにしたマップを作成する
flatten_prompt = {}
for k, v in prompt.items():
flatten_id = str(k).split(':')[-1]
flatten_prompt[flatten_id] = v
# ターゲットIDも同様に正規化
target_id_str = str(target_node_id).split(':')[-1]
# 再帰的にリーフノードの合計数を数える内部関数
def count_leaves(current_id):
curr_id_str = str(current_id).split(':')[-1]
if not curr_id_str or curr_id_str not in flatten_prompt:
return 1
node_info = flatten_prompt[curr_id_str]
class_type = node_info.get("class_type")
# ターゲットクラスの場合
if class_type in with_weights_classes:
node_inputs = node_info.get("inputs", {})
total = 0
for key, value in node_inputs.items():
if key.startswith(weight_prefix):
if isinstance(value, list) and len(value) >= 2:
total += count_leaves(value[0])
else:
total += 1
elif key == "text_list":
if isinstance(value, list) and len(value) >= 2:
total += count_leaves(value[0])
else:
total += 1
return max(1, total)
# 中間ノードの場合は全入力を再帰探索
node_inputs = node_info.get("inputs", {})
total = 0
has_connections = False
for key, value in node_inputs.items():
if isinstance(value, list) and len(value) >= 2:
has_connections = True
total += count_leaves(value[0])
if not has_connections:
return 1
return max(1, total)
weights = {}
main_node = flatten_prompt.get(target_id_str)
if not main_node:
return weights
main_inputs = main_node.get("inputs", {})
for key, value in main_inputs.items():
if key.startswith(weight_prefix) or key == "text_list":
if isinstance(value, list) and len(value) >= 2:
weights[key] = count_leaves(value[0])
else:
weights[key] = 1
return weights
class StringToolsString:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": (
"STRING",
{"default": "", "multiline": False, "dynamicPrompts": False},
),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, text):
return (text,)
class StringToolsText:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": (
"STRING",
{"default": "", "multiline": True, "dynamicPrompts": False},
),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, text):
return (text,)
class StringToolsConcat:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
},
"optional": StringToolsOptionalDict({
"separator": ("STRING", {"forceInput": True}),
}),
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, **kwargs):
separator = kwargs.get("separator", "")
if isinstance(separator, list):
separator = str(separator[0]) if separator else ""
values = sort_kwargs_value("text", kwargs)
return (separator.join(values),)
class StringToolsConcatList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text_list": ("STRING", {"forceInput": True}),
},
"optional": {
"separator": ("STRING", {"forceInput": True}),
},
}
INPUT_IS_LIST = True
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, text_list=None, separator=None):
if not text_list: return ("",)
if separator is None:
sep = "\n"
elif isinstance(separator, list):
sep = separator[0] if separator else "\n"
else:
sep = str(separator)
flat_list = []
for x in text_list:
if isinstance(x, list):
flat_list.extend(map(str, x))
else:
flat_list.append(str(x))
return (sep.join(flat_list),)
class StringToolsRandomChoice:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "forceInput": True}),
},
"optional": StringToolsOptionalDict(),
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, **kwargs):
seed = kwargs.get("seed", 0)
values = sort_kwargs_value("text", kwargs)
if not values:
return ("",)
random.seed(seed)
choice = random.choice(values)
return (choice,)
class StringToolsRandomChoiceList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "forceInput": True}),
"text_list": ("STRING", {"forceInput": True}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID",
},
}
INPUT_IS_LIST = True
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, seed=0, text_list=None, **kwargs):
if not text_list:
return ("",)
flat_list = []
for x in text_list:
if isinstance(x, list):
flat_list.extend(x)
else:
flat_list.append(x)
s = seed[0] if isinstance(seed, list) else seed
random.seed(s)
choice = random.choice(flat_list)
return (choice,)
class StringToolsBalancedChoice(StringToolsRandomChoice):
total_count = {}
counts = {}
debug = False
@classmethod
def INPUT_TYPES(s):
return super().INPUT_TYPES()
def process(self, **kwargs):
seed = kwargs.get("seed", 0)
node = get_node(kwargs)
values = sort_kwargs_value("text", kwargs)
if not values:
return ("",)
if node is None:
random.seed(seed)
return (random.choice(values),)
unique_id = kwargs.get("unique_id", None)
if isinstance(unique_id, list): unique_id = unique_id[0]
id = str(unique_id) if unique_id is not None else str(node.get("id", ""))
counts = self.counts.get(id, None)
if counts is None:
counts = self.counts[id] = {}
total_count = self.total_count.get(id, None)
if total_count is None:
total_count = self.total_count[id] = 0
title = node.get("title", node.get("type", None))
prompt = kwargs.get("prompt", {})
computed_weights = calculate_weights_from_prompt(
prompt, id, ["StringToolsRandomChoice", "StringToolsBalancedChoice", "StringToolsRandomChoiceList", "StringToolsBalancedChoiceList"], "text"
)
if computed_weights:
weights = [computed_weights.get(f"text_{i}", 1) for i in range(len(values))]
else:
weights = [1] * len(values)
random.seed(seed)
choice_idx = random.choices(range(len(values)), weights=weights)[0]
choice_text = "_".join(["text", str(choice_idx)])
counts[choice_text] = counts.get(choice_text, 0) + 1
self.total_count[id] += 1
total_weight = sum(weights)
if self.debug or kwargs.get("debug", False):
print(f"#{id} {title} (Seed:{seed}):")
for idx in range(len(values)):
text = "_".join(["text", str(idx)])
count = counts.get(text, 0)
print(f"\tInput:{text} Weight:{weights[idx]} ({weights[idx] / total_weight * 100}%) - Count:{count} ({count / self.total_count[id] * 100}%)")
return (values[choice_idx],)
class StringToolsBalancedChoiceList(StringToolsRandomChoiceList):
total_count = {}
counts = {}
debug = False
@classmethod
def INPUT_TYPES(s):
input_types = super().INPUT_TYPES()
input_types["optional"] = {
"weight_list": ("INT", {"forceInput": True})
}
return input_types
def process(self, seed=0, text_list=None, weight_list=None, **kwargs):
if isinstance(kwargs.get("prompt"), list):
kwargs["prompt"] = kwargs["prompt"][0]
if isinstance(kwargs.get("extra_pnginfo"), list):
kwargs["extra_pnginfo"] = kwargs["extra_pnginfo"][0]
if isinstance(kwargs.get("unique_id"), list):
kwargs["unique_id"] = kwargs["unique_id"][0]
if not text_list:
return ("",)
flat_list = []
for x in text_list:
if isinstance(x, list):
flat_list.extend(x)
else:
flat_list.append(x)
s = seed[0] if isinstance(seed, list) else seed
random.seed(s)
node = get_node(kwargs)
if node is None:
return (random.choice(flat_list),)
title = node.get("title", node.get("type", None))
unique_id = kwargs.get("unique_id", None)
if isinstance(unique_id, list): unique_id = unique_id[0]
id = str(unique_id) if unique_id is not None else str(node.get("id", ""))
counts = self.counts.get(id, None)
if counts is None:
counts = self.counts[id] = {}
total_count = self.total_count.get(id, None)
if total_count is None:
total_count = self.total_count[id] = 0
weights = []
if weight_list is not None:
# weight_list も平坦化が必要な可能性があるが、通常は 1つ
flat_weights = []
for w in weight_list:
if isinstance(w, list): flat_weights.extend(w)
else: flat_weights.append(w)
if len(flat_weights) == len(flat_list):
weights = flat_weights
if not weights:
prompt = kwargs.get("prompt", {})
computed_weights = calculate_weights_from_prompt(
prompt, id, ["StringToolsRandomChoice", "StringToolsBalancedChoice", "StringToolsRandomChoiceList", "StringToolsBalancedChoiceList"], "text_list"
)
if computed_weights:
w = computed_weights.get("text_list", 1)
weights = [w / len(flat_list)] * len(flat_list)
else:
weights = [1] * len(flat_list)
choice_idx = random.choices(range(len(flat_list)), weights=weights)[0]
choice_text = str(choice_idx)
counts[choice_text] = counts.get(choice_text, 0) + 1
self.total_count[id] += 1
total_weight = sum(weights)
if self.debug or kwargs.get("debug", False):
print(f"#{id} {title} (Seed:{seed}):")
for idx in range(len(flat_list)):
count = counts.get(str(idx), 0)
print(f"\tInput:{idx} Weight:{weights[idx]} ({weights[idx] / total_weight * 100 if total_weight > 0 else 0}%) - Count:{count} ({count / self.total_count[id] * 100}%)")
return (flat_list[choice_idx],)
class StringToolsSeed:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"seed": (
"INT",
{"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF},
),
}
}
RETURN_TYPES = ("INT",)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, seed):
return (seed,)
class StringToolsStringsToList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
"optional": StringToolsOptionalDict(),
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "process"
CATEGORY = "string-tools"
def process(self, **kwargs):
values = sort_kwargs_value("text", kwargs)
return (values,)
class StringToolsMockStringList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"str1": ("STRING", {"forceInput": True}),
"str2": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "process"
CATEGORY = "string-tools/test"
def process(self, str1, str2):
return ([str1, str2],)
NODE_CLASS_MAPPINGS = {
"StringToolsString": StringToolsString,
"StringToolsText": StringToolsText,
"StringToolsConcat": StringToolsConcat,
"StringToolsConcatList": StringToolsConcatList,
"StringToolsRandomChoice": StringToolsRandomChoice,
"StringToolsRandomChoiceList": StringToolsRandomChoiceList,
"StringToolsBalancedChoice": StringToolsBalancedChoice,
"StringToolsBalancedChoiceList": StringToolsBalancedChoiceList,
"StringToolsSeed": StringToolsSeed,
"StringToolsStringsToList": StringToolsStringsToList,
}
import os
if os.environ.get("COMFYUI_TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST"):
NODE_CLASS_MAPPINGS["StringToolsMockStringList"] = StringToolsMockStringList
NODE_DISPLAY_NAME_MAPPINGS = {
"StringToolsMockStringList": "StringTools [TEST ONLY] Mock String List"
}
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]