Add cache no cascade property

This commit is contained in:
Alexis Rolland 2026-06-16 16:05:43 +08:00
parent d3767dae9e
commit 513d9e995f
6 changed files with 93 additions and 38 deletions

View File

@ -1600,6 +1600,8 @@ class Schema:
"""Optional client-evaluated pricing badge declaration for this node.""" """Optional client-evaluated pricing badge declaration for this node."""
not_idempotent: bool=False not_idempotent: bool=False
"""Flags a node as not idempotent; when True, the node will run and not reuse the cached outputs when identical inputs are provided on a different node in the graph.""" """Flags a node as not idempotent; when True, the node will run and not reuse the cached outputs when identical inputs are provided on a different node in the graph."""
cache_no_cascade: bool=False
"""When True, changes to this node's widget inputs re-run this node but do not invalidate the caches of downstream nodes. Use for passthrough nodes whose widget inputs only affect side effects (e.g. a save node's filename), not the data passed to its outputs."""
enable_expand: bool=False enable_expand: bool=False
"""Flags a node as expandable, allowing NodeOutput to include 'expand' property.""" """Flags a node as expandable, allowing NodeOutput to include 'expand' property."""
accept_all_inputs: bool=False accept_all_inputs: bool=False
@ -2065,6 +2067,14 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal):
cls.GET_SCHEMA() cls.GET_SCHEMA()
return cls._NOT_IDEMPOTENT return cls._NOT_IDEMPOTENT
_CACHE_NO_CASCADE = None
@final
@classproperty
def CACHE_NO_CASCADE(cls): # noqa
if cls._CACHE_NO_CASCADE is None:
cls.GET_SCHEMA()
return cls._CACHE_NO_CASCADE
_ACCEPT_ALL_INPUTS = None _ACCEPT_ALL_INPUTS = None
@final @final
@classproperty @classproperty
@ -2115,6 +2125,8 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal):
cls._INPUT_IS_LIST = schema.is_input_list cls._INPUT_IS_LIST = schema.is_input_list
if cls._NOT_IDEMPOTENT is None: if cls._NOT_IDEMPOTENT is None:
cls._NOT_IDEMPOTENT = schema.not_idempotent cls._NOT_IDEMPOTENT = schema.not_idempotent
if cls._CACHE_NO_CASCADE is None:
cls._CACHE_NO_CASCADE = schema.cache_no_cascade
if cls._ACCEPT_ALL_INPUTS is None: if cls._ACCEPT_ALL_INPUTS is None:
cls._ACCEPT_ALL_INPUTS = schema.accept_all_inputs cls._ACCEPT_ALL_INPUTS = schema.accept_all_inputs

View File

@ -14,6 +14,7 @@ import nodes
from comfy_execution.graph_utils import is_link from comfy_execution.graph_utils import is_link
NODE_CLASS_CONTAINS_UNIQUE_ID: Dict[str, bool] = {} NODE_CLASS_CONTAINS_UNIQUE_ID: Dict[str, bool] = {}
NODE_CLASS_CACHE_NO_CASCADE: Dict[str, bool] = {}
def include_unique_id_in_input(class_type: str) -> bool: def include_unique_id_in_input(class_type: str) -> bool:
@ -23,6 +24,16 @@ def include_unique_id_in_input(class_type: str) -> bool:
NODE_CLASS_CONTAINS_UNIQUE_ID[class_type] = "UNIQUE_ID" in class_def.INPUT_TYPES().get("hidden", {}).values() NODE_CLASS_CONTAINS_UNIQUE_ID[class_type] = "UNIQUE_ID" in class_def.INPUT_TYPES().get("hidden", {}).values()
return NODE_CLASS_CONTAINS_UNIQUE_ID[class_type] return NODE_CLASS_CONTAINS_UNIQUE_ID[class_type]
def is_cache_no_cascade(class_type: str) -> bool:
"""Whether changes to this node's widget inputs should not invalidate downstream caches."""
if class_type in NODE_CLASS_CACHE_NO_CASCADE:
return NODE_CLASS_CACHE_NO_CASCADE[class_type]
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
NODE_CLASS_CACHE_NO_CASCADE[class_type] = bool(getattr(class_def, "CACHE_NO_CASCADE", False))
return NODE_CLASS_CACHE_NO_CASCADE[class_type]
class CacheKeySet(ABC): class CacheKeySet(ABC):
def __init__(self, dynprompt, node_ids, is_changed_cache): def __init__(self, dynprompt, node_ids, is_changed_cache):
self.keys = {} self.keys = {}
@ -101,12 +112,12 @@ class CacheKeySetInputSignature(CacheKeySet):
async def get_node_signature(self, dynprompt, node_id): async def get_node_signature(self, dynprompt, node_id):
signature = [] signature = []
ancestors, order_mapping = self.get_ordered_ancestry(dynprompt, node_id) ancestors, order_mapping = self.get_ordered_ancestry(dynprompt, node_id)
signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping)) signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping, as_ancestor=False))
for ancestor_id in ancestors: for ancestor_id in ancestors:
signature.append(await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping)) signature.append(await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping, as_ancestor=True))
return to_hashable(signature) return to_hashable(signature)
async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping, as_ancestor=False):
if not dynprompt.has_node(node_id): if not dynprompt.has_node(node_id):
# This node doesn't exist -- we can't cache it. # This node doesn't exist -- we can't cache it.
return [float("NaN")] return [float("NaN")]
@ -117,12 +128,13 @@ class CacheKeySetInputSignature(CacheKeySet):
if self.include_node_id_in_input() or (hasattr(class_def, "NOT_IDEMPOTENT") and class_def.NOT_IDEMPOTENT) or include_unique_id_in_input(class_type): if self.include_node_id_in_input() or (hasattr(class_def, "NOT_IDEMPOTENT") and class_def.NOT_IDEMPOTENT) or include_unique_id_in_input(class_type):
signature.append(node_id) signature.append(node_id)
inputs = node["inputs"] inputs = node["inputs"]
skip_widgets = as_ancestor and is_cache_no_cascade(class_type)
for key in sorted(inputs.keys()): for key in sorted(inputs.keys()):
if is_link(inputs[key]): if is_link(inputs[key]):
(ancestor_id, ancestor_socket) = inputs[key] (ancestor_id, ancestor_socket) = inputs[key]
ancestor_index = ancestor_order_mapping[ancestor_id] ancestor_index = ancestor_order_mapping[ancestor_id]
signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket)))
else: elif not skip_widgets:
signature.append((key, inputs[key])) signature.append((key, inputs[key]))
return signature return signature

View File

@ -168,6 +168,7 @@ class SaveAudio(IO.ComfyNode):
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_deprecated=True, is_deprecated=True,
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.Audio.Output("audio")] outputs=[IO.Audio.Output("audio")]
) )
@ -198,6 +199,7 @@ class SaveAudioMP3(IO.ComfyNode):
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_deprecated=True, is_deprecated=True,
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.Audio.Output("audio")] outputs=[IO.Audio.Output("audio")]
) )
@ -229,6 +231,7 @@ class SaveAudioOpus(IO.ComfyNode):
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_deprecated=True, is_deprecated=True,
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.Audio.Output("audio")] outputs=[IO.Audio.Output("audio")]
) )
@ -258,20 +261,25 @@ class SaveAudioAdvanced(IO.ComfyNode):
IO.String.Input( IO.String.Input(
"filename_prefix", "filename_prefix",
default="audio/ComfyUI", default="audio/ComfyUI",
tooltip=( tooltip=("The prefix for the file to save. May include formatting tokens such as %date:yyyy-MM-dd%."),
"The prefix for the file to save. May include formatting tokens "
"such as %date:yyyy-MM-dd%."
),
), ),
IO.DynamicCombo.Input( IO.DynamicCombo.Input(
"format", "format",
options=[ options=[
IO.DynamicCombo.Option("flac", []), IO.DynamicCombo.Option("flac", []),
IO.DynamicCombo.Option("mp3", [ IO.DynamicCombo.Option("mp3", [
IO.Combo.Input("quality", options=["V0", "128k", "320k"], default="V0"), IO.Combo.Input(
"quality",
options=["V0", "128k", "320k"],
default="V0",
),
]), ]),
IO.DynamicCombo.Option("opus", [ IO.DynamicCombo.Option("opus", [
IO.Combo.Input("quality", options=["64k", "96k", "128k", "192k", "320k"], default="128k"), IO.Combo.Input(
"quality",
options=["64k", "96k", "128k", "192k", "320k"],
default="128k",
),
]), ]),
], ],
tooltip="The file format in which to save the audio.", tooltip="The file format in which to save the audio.",
@ -279,6 +287,8 @@ class SaveAudioAdvanced(IO.ComfyNode):
], ],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.Audio.Output("audio")],
) )
@classmethod @classmethod
@ -289,7 +299,7 @@ class SaveAudioAdvanced(IO.ComfyNode):
ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=file_format, quality=quality) ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=file_format, quality=quality)
else: else:
ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=file_format) ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=file_format)
return IO.NodeOutput(ui=ui) return IO.NodeOutput(audio, ui=ui)
class PreviewAudio(IO.ComfyNode): class PreviewAudio(IO.ComfyNode):

View File

@ -214,6 +214,7 @@ class SaveAnimatedWEBP(IO.ComfyNode):
], ],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.Image.Output(display_name="images")] outputs=[IO.Image.Output(display_name="images")]
) )
@ -249,6 +250,7 @@ class SaveAnimatedPNG(IO.ComfyNode):
], ],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.Image.Output(display_name="images")] outputs=[IO.Image.Output(display_name="images")]
) )
@ -513,6 +515,7 @@ class SaveSVGNode(IO.ComfyNode):
], ],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.SVG.Output("svg")], outputs=[IO.SVG.Output("svg")],
) )
@ -1156,40 +1159,42 @@ class SaveImageAdvanced(IO.ComfyNode):
IO.String.Input( IO.String.Input(
"filename_prefix", "filename_prefix",
default="ComfyUI", default="ComfyUI",
tooltip=( tooltip=("The prefix for the file to save. May include formatting tokens such as %date:yyyy-MM-dd% or %Empty Latent Image.width%."),
"The prefix for the file to save. May include formatting tokens "
"such as %date:yyyy-MM-dd% or %Empty Latent Image.width%."
),
), ),
IO.DynamicCombo.Input( IO.DynamicCombo.Input(
"format", "format",
options=[ options=[
IO.DynamicCombo.Option("png", [ IO.DynamicCombo.Option("png", [
IO.Combo.Input("bit_depth", options=["8-bit", "16-bit"], IO.Combo.Input(
default="8-bit", advanced=True), "bit_depth",
IO.Combo.Input("input_color_space", options=["sRGB"], options=["8-bit", "16-bit"],
default="sRGB", advanced=True), default="8-bit",
advanced=True,
),
IO.Combo.Input(
"input_color_space",
options=["sRGB"],
default="sRGB",
advanced=True,
),
]), ]),
IO.DynamicCombo.Option("exr", [ IO.DynamicCombo.Option("exr", [
IO.Combo.Input("bit_depth", options=["32-bit float"], IO.Combo.Input(
default="32-bit float", advanced=True), "bit_depth",
options=["32-bit float"],
default="32-bit float",
advanced=True,
),
IO.Combo.Input( IO.Combo.Input(
"input_color_space", "input_color_space",
options=["sRGB", "HDR", "linear"], options=["sRGB", "HDR", "linear"],
default="sRGB", default="sRGB",
advanced=True, advanced=True,
tooltip=( tooltip=(
"Colorspace of the input tensor. The EXR is " "Colorspace of the input tensor. The EXR is always written as scene-linear in the matching gamut.\n"
"always written as scene-linear in the matching " "sRGB — input is sRGB-encoded Rec.709; the inverse sRGB EOTF is applied.\n"
"gamut.\n" "HDR — input is HLG-encoded Rec.2020 (BT.2100); the inverse HLG OETF is applied to get scene-linear light.\n"
" 'sRGB' — input is sRGB-encoded Rec.709; " "linear — input is already scene-linear (Rec.709 primaries); written through unchanged. Use this for renderer/compositor output."
"the inverse sRGB EOTF is applied.\n"
" 'HDR' — input is HLG-encoded Rec.2020 "
"(BT.2100); the inverse HLG OETF is applied "
"to get scene-linear light.\n"
" 'linear' — input is already scene-linear "
"(Rec.709 primaries); written through unchanged. "
"Use this for renderer/compositor output."
), ),
), ),
]), ]),
@ -1199,6 +1204,7 @@ class SaveImageAdvanced(IO.ComfyNode):
], ],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[IO.Image.Output(display_name="images")] outputs=[IO.Image.Output(display_name="images")]
) )

View File

@ -23,10 +23,18 @@ class SaveWEBM(io.ComfyNode):
io.String.Input("filename_prefix", default="ComfyUI"), io.String.Input("filename_prefix", default="ComfyUI"),
io.Combo.Input("codec", options=["vp9", "av1"]), io.Combo.Input("codec", options=["vp9", "av1"]),
io.Float.Input("fps", default=24.0, min=0.01, max=1000.0, step=0.01), io.Float.Input("fps", default=24.0, min=0.01, max=1000.0, step=0.01),
io.Float.Input("crf", default=32.0, min=0, max=63.0, step=1, tooltip="Higher crf means lower quality with a smaller file size, lower crf means higher quality higher filesize."), io.Float.Input(
"crf",
default=32.0,
min=0,
max=63.0,
step=1,
tooltip="Higher crf means lower quality with a smaller file size, lower crf means higher quality higher filesize.",
),
], ],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[io.Image.Output(display_name="images")] outputs=[io.Image.Output(display_name="images")]
) )
@ -90,6 +98,7 @@ class SaveVideo(io.ComfyNode):
], ],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
cache_no_cascade=True,
outputs=[io.Video.Output("video")], outputs=[io.Video.Output("video")],
) )

View File

@ -483,15 +483,17 @@ class SaveLatent:
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
return {"required": { "samples": ("LATENT", ), return { "required": {
"filename_prefix": ("STRING", {"default": "latents/ComfyUI"})}, "samples": ("LATENT",),
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, "filename_prefix": ("STRING", {"default": "latents/ComfyUI"})},
} "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("LATENT",) RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("samples",) RETURN_NAMES = ("samples",)
FUNCTION = "save" FUNCTION = "save"
OUTPUT_NODE = True OUTPUT_NODE = True
CACHE_NO_CASCADE = True
CATEGORY = "experimental" CATEGORY = "experimental"
@ -1632,7 +1634,10 @@ class SaveImage:
return { return {
"required": { "required": {
"images": ("IMAGE", {"tooltip": "The images to save."}), "images": ("IMAGE", {"tooltip": "The images to save."}),
"filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."}) "filename_prefix": ("STRING", {
"default": "ComfyUI",
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."
})
}, },
"hidden": { "hidden": {
"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"
@ -1644,6 +1649,7 @@ class SaveImage:
FUNCTION = "save_images" FUNCTION = "save_images"
OUTPUT_NODE = True OUTPUT_NODE = True
CACHE_NO_CASCADE = True
CATEGORY = "image" CATEGORY = "image"
ESSENTIALS_CATEGORY = "Basics" ESSENTIALS_CATEGORY = "Basics"