Compare commits

...

10 Commits

Author SHA1 Message Date
xmarre
a6472b1514 Fix to_hashable traversal stack handling 2026-03-16 15:34:15 +01:00
xmarre
6158cd5820 Prevent redundant signature rewalk 2026-03-16 13:31:02 +01:00
xmarre
bff714dda0 Fix non-link input cache signature 2026-03-16 10:13:04 +01:00
xmarre
fce22da313 Prevent signature traversal of raw 2026-03-16 09:29:00 +01:00
xmarre
9f9d37bd9a
Merge branch 'master' into master 2026-03-16 09:07:29 +01:00
Christian Byrne
593be209a4
feat: add essentials_category to nodes and blueprints for Essentials tab (#12573)
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
* feat: add essentials_category to nodes and blueprints for Essentials tab

Add ESSENTIALS_CATEGORY or essentials_category to 12 node classes and all
36 blueprint JSONs. Update SubgraphEntry TypedDict and subgraph_manager to
extract and pass through the field.

Fixes COM-15221

Amp-Thread-ID: https://ampcode.com/threads/T-019c83de-f7ab-7779-a451-0ba5940b56a9

* fix: import NotRequired from typing_extensions for Python 3.10 compat

* refactor: keep only node class ESSENTIALS_CATEGORY, remove blueprint/subgraph changes

Frontend will own blueprint categorization separately.

* fix: remove essentials_category from CreateVideo (not in spec)

---------

Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-03-15 16:18:04 -07:00
lostdisc
3814bf4454
Enable Pytorch Attention for gfx1150 (#12973)
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
2026-03-15 12:45:30 -07:00
comfyanonymous
d062becb33
Make EmptyLatentImage follow intermediate dtype. (#12974) 2026-03-15 15:37:27 -04:00
rattus
e84a200a3c
ops: opt out of deferred weight init if subclassed (#12967)
If a subclass BYO _load_from_state_dict and doesnt call the super() the
needed default init of these weights is missed and can lead to problems
for uninitialized weights.
2026-03-15 11:49:49 -07:00
Dr.Lt.Data
192cb8eeb9
bump manager version to 4.1b5 (#12957) 2026-03-15 11:48:56 -07:00
13 changed files with 248 additions and 36 deletions

View File

@ -400,7 +400,7 @@ try:
if args.use_split_cross_attention == False and args.use_quad_cross_attention == False:
if aotriton_supported(arch): # AMD efficient attention implementation depends on aotriton.
if torch_version_numeric >= (2, 7): # works on 2.6 but doesn't actually seem to improve much
if any((a in arch) for a in ["gfx90a", "gfx942", "gfx950", "gfx1100", "gfx1101", "gfx1151"]): # TODO: more arches, TODO: gfx950
if any((a in arch) for a in ["gfx90a", "gfx942", "gfx950", "gfx1100", "gfx1101", "gfx1150", "gfx1151"]): # TODO: more arches, TODO: gfx950
ENABLE_PYTORCH_ATTENTION = True
if rocm_version >= (7, 0):
if any((a in arch) for a in ["gfx1200", "gfx1201"]):

View File

@ -336,7 +336,10 @@ class disable_weight_init:
class Linear(torch.nn.Linear, CastWeightBiasOp):
def __init__(self, in_features, out_features, bias=True, device=None, dtype=None):
if not comfy.model_management.WINDOWS or not comfy.memory_management.aimdo_enabled:
# don't trust subclasses that BYO state dict loader to call us.
if (not comfy.model_management.WINDOWS
or not comfy.memory_management.aimdo_enabled
or type(self)._load_from_state_dict is not disable_weight_init.Linear._load_from_state_dict):
super().__init__(in_features, out_features, bias, device, dtype)
return
@ -357,7 +360,9 @@ class disable_weight_init:
def _load_from_state_dict(self, state_dict, prefix, local_metadata,
strict, missing_keys, unexpected_keys, error_msgs):
if not comfy.model_management.WINDOWS or not comfy.memory_management.aimdo_enabled:
if (not comfy.model_management.WINDOWS
or not comfy.memory_management.aimdo_enabled
or type(self)._load_from_state_dict is not disable_weight_init.Linear._load_from_state_dict):
return super()._load_from_state_dict(state_dict, prefix, local_metadata, strict,
missing_keys, unexpected_keys, error_msgs)
disable_weight_init._lazy_load_from_state_dict(
@ -564,7 +569,10 @@ class disable_weight_init:
def __init__(self, num_embeddings, embedding_dim, padding_idx=None, max_norm=None,
norm_type=2.0, scale_grad_by_freq=False, sparse=False, _weight=None,
_freeze=False, device=None, dtype=None):
if not comfy.model_management.WINDOWS or not comfy.memory_management.aimdo_enabled:
# don't trust subclasses that BYO state dict loader to call us.
if (not comfy.model_management.WINDOWS
or not comfy.memory_management.aimdo_enabled
or type(self)._load_from_state_dict is not disable_weight_init.Embedding._load_from_state_dict):
super().__init__(num_embeddings, embedding_dim, padding_idx, max_norm,
norm_type, scale_grad_by_freq, sparse, _weight,
_freeze, device, dtype)
@ -590,7 +598,9 @@ class disable_weight_init:
def _load_from_state_dict(self, state_dict, prefix, local_metadata,
strict, missing_keys, unexpected_keys, error_msgs):
if not comfy.model_management.WINDOWS or not comfy.memory_management.aimdo_enabled:
if (not comfy.model_management.WINDOWS
or not comfy.memory_management.aimdo_enabled
or type(self)._load_from_state_dict is not disable_weight_init.Embedding._load_from_state_dict):
return super()._load_from_state_dict(state_dict, prefix, local_metadata, strict,
missing_keys, unexpected_keys, error_msgs)
disable_weight_init._lazy_load_from_state_dict(

View File

@ -1459,6 +1459,7 @@ class OmniProEditVideoNode(IO.ComfyNode):
node_id="KlingOmniProEditVideoNode",
display_name="Kling 3.0 Omni Edit Video",
category="api node/video/Kling",
essentials_category="Video Generation",
description="Edit an existing video with the latest model from Kling.",
inputs=[
IO.Combo.Input("model_name", options=["kling-v3-omni", "kling-video-o1"]),

View File

@ -833,6 +833,7 @@ class RecraftVectorizeImageNode(IO.ComfyNode):
node_id="RecraftVectorizeImageNode",
display_name="Recraft Vectorize Image",
category="api node/image/Recraft",
essentials_category="Image Tools",
description="Generates SVG synchronously from an input image.",
inputs=[
IO.Image.Input("image"),

View File

@ -68,19 +68,20 @@ _FAILED_SIGNATURE = object()
def _shallow_is_changed_signature(value):
"""Sanitize execution-time `is_changed` values without deep recursion."""
"""Sanitize execution-time `is_changed` values with a small fail-closed budget."""
value_type = type(value)
if value_type in _PRIMITIVE_SIGNATURE_TYPES:
return value
canonical = to_hashable(value, max_nodes=64)
if type(canonical) is Unhashable:
return canonical
if value_type is list or value_type is tuple:
try:
items = tuple(value)
except RuntimeError:
return Unhashable()
if all(type(item) in _PRIMITIVE_SIGNATURE_TYPES for item in items):
container_tag = "is_changed_list" if value_type is list else "is_changed_tuple"
return (container_tag, items)
return Unhashable()
container_tag = "is_changed_list" if value_type is list else "is_changed_tuple"
return (container_tag, canonical[1])
return canonical
def _primitive_signature_sort_key(obj):
@ -274,7 +275,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS):
snapshots = {}
sort_memo = {}
processed = 0
stack = [(obj, False)]
# Keep traversal state separate from container snapshots/results.
work_stack = [(obj, False)]
def resolve_value(value):
"""Resolve a child value from the completed memo table when available."""
@ -308,8 +310,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS):
return (container_tag, tuple(value for _, value in ordered_items))
while stack:
current, expanded = stack.pop()
while work_stack:
current, expanded = work_stack.pop()
current_type = type(current)
if current_type in _PRIMITIVE_SIGNATURE_TYPES or current_type is Unhashable:
@ -387,7 +389,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS):
return Unhashable()
active.add(current_id)
stack.append((current, True))
work_stack.append((current, True))
if current_type is dict:
try:
items = list(current.items())
@ -397,8 +399,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS):
active.discard(current_id)
continue
for key, value in reversed(items):
stack.append((value, False))
stack.append((key, False))
work_stack.append((value, False))
work_stack.append((key, False))
else:
try:
items = list(current)
@ -408,7 +410,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS):
active.discard(current_id)
continue
for item in reversed(items):
stack.append((item, False))
work_stack.append((item, False))
return memo.get(id(obj), Unhashable())
@ -460,18 +462,18 @@ class CacheKeySetInputSignature(CacheKeySet):
signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping))
for ancestor_id in ancestors:
signature.append(await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping))
return _signature_to_hashable(signature)
return tuple(signature)
async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping):
"""Build the immediate cache-signature fragment for a node.
Link inputs are reduced to ancestor references here, while non-link
values are appended as-is. Full canonicalization happens later in
`get_node_signature()` via `_signature_to_hashable()`.
Link inputs are reduced to ancestor references here. Non-link values
are canonicalized or failed closed before being appended so the final
node signature is assembled from already-hashable fragments.
"""
if not dynprompt.has_node(node_id):
# This node doesn't exist -- we can't cache it.
return [float("NaN")]
return (float("NaN"),)
node = dynprompt.get_node(node_id)
class_type = node["class_type"]
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
@ -485,8 +487,8 @@ class CacheKeySetInputSignature(CacheKeySet):
ancestor_index = ancestor_order_mapping[ancestor_id]
signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket)))
else:
signature.append((key, inputs[key]))
return signature
signature.append((key, to_hashable(inputs[key])))
return tuple(signature)
# This function returns a list of all ancestors of the given node. The order of the list is
# deterministic based on which specific inputs the ancestor is connected by.

View File

@ -19,6 +19,7 @@ class EmptyLatentAudio(IO.ComfyNode):
node_id="EmptyLatentAudio",
display_name="Empty Latent Audio",
category="latent/audio",
essentials_category="Audio",
inputs=[
IO.Float.Input("seconds", default=47.6, min=1.0, max=1000.0, step=0.1),
IO.Int.Input(
@ -185,6 +186,7 @@ class SaveAudioMP3(IO.ComfyNode):
search_aliases=["export mp3"],
display_name="Save Audio (MP3)",
category="audio",
essentials_category="Audio",
inputs=[
IO.Audio.Input("audio"),
IO.String.Input("filename_prefix", default="audio/ComfyUI"),

View File

@ -14,6 +14,7 @@ class ImageCompare(IO.ComfyNode):
display_name="Image Compare",
description="Compares two images side by side with a slider.",
category="image",
essentials_category="Image Tools",
is_experimental=True,
is_output_node=True,
inputs=[

View File

@ -58,6 +58,7 @@ class ImageCropV2(IO.ComfyNode):
search_aliases=["trim"],
display_name="Image Crop",
category="image/transform",
essentials_category="Image Tools",
inputs=[
IO.Image.Input("image"),
IO.BoundingBox.Input("crop_region", component="ImageCrop"),

View File

@ -21,6 +21,7 @@ class Blend(io.ComfyNode):
node_id="ImageBlend",
display_name="Image Blend",
category="image/postprocessing",
essentials_category="Image Tools",
inputs=[
io.Image.Input("image1"),
io.Image.Input("image2"),

View File

@ -1 +1 @@
comfyui_manager==4.1b4
comfyui_manager==4.1b5

View File

@ -81,6 +81,7 @@ class CLIPTextEncode(ComfyNodeABC):
class ConditioningCombine:
ESSENTIALS_CATEGORY = "Image Generation"
@classmethod
def INPUT_TYPES(s):
return {"required": {"conditioning_1": ("CONDITIONING", ), "conditioning_2": ("CONDITIONING", )}}
@ -1211,9 +1212,6 @@ class GLIGENTextBoxApply:
return (c, )
class EmptyLatentImage:
def __init__(self):
self.device = comfy.model_management.intermediate_device()
@classmethod
def INPUT_TYPES(s):
return {
@ -1232,7 +1230,7 @@ class EmptyLatentImage:
SEARCH_ALIASES = ["empty", "empty latent", "new latent", "create latent", "blank latent", "blank"]
def generate(self, width, height, batch_size=1):
latent = torch.zeros([batch_size, 4, height // 8, width // 8], device=self.device)
latent = torch.zeros([batch_size, 4, height // 8, width // 8], device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype())
return ({"samples": latent, "downscale_ratio_spacial": 8}, )
@ -1781,6 +1779,7 @@ class LoadImage:
return True
class LoadImageMask:
ESSENTIALS_CATEGORY = "Image Tools"
SEARCH_ALIASES = ["import mask", "alpha mask", "channel mask"]
_color_channels = ["alpha", "red", "green", "blue"]
@ -1889,6 +1888,7 @@ class ImageScale:
return (s,)
class ImageScaleBy:
ESSENTIALS_CATEGORY = "Image Tools"
upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
@classmethod

View File

@ -353,11 +353,26 @@ def test_shallow_is_changed_signature_accepts_primitive_lists(caching_module):
assert sanitized == ("is_changed_list", (1, "two", None, True))
def test_shallow_is_changed_signature_fails_closed_on_nested_containers(caching_module):
"""Nested containers from `is_changed` should be rejected immediately."""
def test_shallow_is_changed_signature_accepts_structured_builtin_fingerprint_lists(caching_module):
"""Structured built-in `is_changed` fingerprints should remain representable."""
caching, _ = caching_module
sanitized = caching._shallow_is_changed_signature([1, ["nested"]])
sanitized = caching._shallow_is_changed_signature([("seed", 42), {"cfg": 8}])
assert sanitized == (
"is_changed_list",
(
("tuple", ("seed", 42)),
("dict", (("cfg", 8),)),
),
)
def test_shallow_is_changed_signature_fails_closed_for_opaque_payload(caching_module):
"""Opaque `is_changed` payloads should still fail closed."""
caching, _ = caching_module
sanitized = caching._shallow_is_changed_signature([_OpaqueValue()])
assert isinstance(sanitized, caching.Unhashable)

View File

@ -0,0 +1,178 @@
import asyncio
from comfy_execution import caching
class _StubDynPrompt:
def __init__(self, nodes):
self._nodes = nodes
def has_node(self, node_id):
return node_id in self._nodes
def get_node(self, node_id):
return self._nodes[node_id]
class _StubIsChangedCache:
async def get(self, node_id):
return None
class _StubNode:
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
def test_get_immediate_node_signature_canonicalizes_non_link_inputs(monkeypatch):
live_value = [1, {"nested": [2, 3]}]
dynprompt = _StubDynPrompt(
{
"1": {
"class_type": "TestCacheNode",
"inputs": {"value": live_value},
}
}
)
monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode)
monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {})
keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache())
signature = asyncio.run(keyset.get_immediate_node_signature(dynprompt, "1", {}))
assert signature == (
"TestCacheNode",
None,
("value", ("list", (1, ("dict", (("nested", ("list", (2, 3))),))))),
)
def test_to_hashable_walks_dicts_without_rebinding_traversal_stack():
live_value = {
"outer": {"nested": [2, 3]},
"items": [{"leaf": 4}],
}
assert caching.to_hashable(live_value) == (
"dict",
(
("items", ("list", (("dict", (("leaf", 4),)),))),
("outer", ("dict", (("nested", ("list", (2, 3))),))),
),
)
def test_get_immediate_node_signature_fails_closed_for_opaque_non_link_input(monkeypatch):
class OpaqueRuntimeValue:
pass
live_value = OpaqueRuntimeValue()
dynprompt = _StubDynPrompt(
{
"1": {
"class_type": "TestCacheNode",
"inputs": {"value": live_value},
}
}
)
monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode)
monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {})
keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache())
signature = asyncio.run(keyset.get_immediate_node_signature(dynprompt, "1", {}))
assert signature[:2] == ("TestCacheNode", None)
assert signature[2][0] == "value"
assert type(signature[2][1]) is caching.Unhashable
def test_get_node_signature_never_visits_raw_non_link_input(monkeypatch):
live_value = [1, 2, 3]
dynprompt = _StubDynPrompt(
{
"1": {
"class_type": "TestCacheNode",
"inputs": {"value": live_value},
}
}
)
monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode)
monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {})
monkeypatch.setattr(
caching,
"_signature_to_hashable",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("outer signature canonicalizer should not run")
),
)
keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache())
signature = asyncio.run(keyset.get_node_signature(dynprompt, "1"))
assert isinstance(signature, tuple)
def test_get_node_signature_keeps_deep_canonicalized_input_fragment(monkeypatch):
live_value = 1
for _ in range(8):
live_value = [live_value]
expected = caching.to_hashable(live_value)
dynprompt = _StubDynPrompt(
{
"1": {
"class_type": "TestCacheNode",
"inputs": {"value": live_value},
}
}
)
monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode)
monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {})
keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache())
signature = asyncio.run(keyset.get_node_signature(dynprompt, "1"))
assert isinstance(signature, tuple)
assert signature[0][2][0] == "value"
assert signature[0][2][1] == expected
def test_get_node_signature_keeps_large_precanonicalized_fragment(monkeypatch):
live_value = object()
canonical_fragment = ("tuple", tuple(("list", (index, index + 1)) for index in range(256)))
dynprompt = _StubDynPrompt(
{
"1": {
"class_type": "TestCacheNode",
"inputs": {"value": live_value},
}
}
)
monkeypatch.setitem(caching.nodes.NODE_CLASS_MAPPINGS, "TestCacheNode", _StubNode)
monkeypatch.setattr(caching, "NODE_CLASS_CONTAINS_UNIQUE_ID", {})
monkeypatch.setattr(
caching,
"to_hashable",
lambda value, max_nodes=caching._MAX_SIGNATURE_CONTAINER_VISITS: (
canonical_fragment if value is live_value else caching.Unhashable()
),
)
monkeypatch.setattr(
caching,
"_signature_to_hashable",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("outer signature canonicalizer should not run")
),
)
keyset = caching.CacheKeySetInputSignature(dynprompt, [], _StubIsChangedCache())
signature = asyncio.run(keyset.get_node_signature(dynprompt, "1"))
assert isinstance(signature, tuple)
assert signature[0][2] == ("value", canonical_fragment)