From fbc3b0fed3f873cfb813499e63548db61377765f Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sun, 22 Mar 2026 23:20:50 -0400 Subject: [PATCH] Add `has_intermediate_output` flag for nodes with interactive UI --- comfy_api/latest/_io.py | 22 ++++++++++++++++++++++ comfy_execution/graph.py | 5 +++++ comfy_extras/nodes_glsl.py | 1 + comfy_extras/nodes_images.py | 1 + comfy_extras/nodes_painter.py | 1 + execution.py | 24 +++++++++++++++++++----- server.py | 2 ++ 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 7ca8f4e0c..7066c9d44 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1353,6 +1353,7 @@ class NodeInfoV1: python_module: Any=None category: str=None output_node: bool=None + has_intermediate_output: bool=None deprecated: bool=None experimental: bool=None dev_only: bool=None @@ -1465,6 +1466,16 @@ class Schema: Comfy Docs: https://docs.comfy.org/custom-nodes/backend/server_overview#output-node """ + has_intermediate_output: bool=False + """Flags this node as having intermediate output that should persist across page refreshes. + + Nodes with this flag behave like output nodes (their UI results are cached and resent + to the frontend) but do NOT automatically get added to the execution list. This means + they will only execute if they are on the dependency path of a real output node. + + Use this for nodes with interactive/operable UI regions that produce intermediate outputs + (e.g., Image Crop, Painter) rather than final outputs (e.g., Save Image). + """ is_deprecated: bool=False """Flags a node as deprecated, indicating to users that they should find alternatives to this node.""" is_experimental: bool=False @@ -1582,6 +1593,7 @@ class Schema: category=self.category, description=self.description, output_node=self.is_output_node, + has_intermediate_output=self.has_intermediate_output, deprecated=self.is_deprecated, experimental=self.is_experimental, dev_only=self.is_dev_only, @@ -1873,6 +1885,14 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal): cls.GET_SCHEMA() return cls._OUTPUT_NODE + _HAS_INTERMEDIATE_OUTPUT = None + @final + @classproperty + def HAS_INTERMEDIATE_OUTPUT(cls): # noqa + if cls._HAS_INTERMEDIATE_OUTPUT is None: + cls.GET_SCHEMA() + return cls._HAS_INTERMEDIATE_OUTPUT + _INPUT_IS_LIST = None @final @classproperty @@ -1965,6 +1985,8 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal): cls._API_NODE = schema.is_api_node if cls._OUTPUT_NODE is None: cls._OUTPUT_NODE = schema.is_output_node + if cls._HAS_INTERMEDIATE_OUTPUT is None: + cls._HAS_INTERMEDIATE_OUTPUT = schema.has_intermediate_output if cls._INPUT_IS_LIST is None: cls._INPUT_IS_LIST = schema.is_input_list if cls._NOT_IDEMPOTENT is None: diff --git a/comfy_execution/graph.py b/comfy_execution/graph.py index c47f3c79b..8c80e2792 100644 --- a/comfy_execution/graph.py +++ b/comfy_execution/graph.py @@ -118,6 +118,11 @@ class TopologicalSort: class_def = nodes.NODE_CLASS_MAPPINGS[class_type] return get_input_info(class_def, input_name) + def is_intermediate_output(self, node_id): + class_type = self.dynprompt.get_node(node_id)["class_type"] + class_def = nodes.NODE_CLASS_MAPPINGS[class_type] + return hasattr(class_def, 'HAS_INTERMEDIATE_OUTPUT') and class_def.HAS_INTERMEDIATE_OUTPUT == True + def make_input_strong_link(self, to_node_id, to_input): inputs = self.dynprompt.get_node(to_node_id)["inputs"] if to_input not in inputs: diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 2a59a9285..1ee7e17ad 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -762,6 +762,7 @@ class GLSLShader(io.ComfyNode): "Apply GLSL ES fragment shaders to images. " "u_resolution (vec2) is always available." ), + has_intermediate_output=True, inputs=[ io.String.Input( "fragment_shader", diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index a8223cf8b..a77f0641f 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -59,6 +59,7 @@ class ImageCropV2(IO.ComfyNode): display_name="Image Crop", category="image/transform", essentials_category="Image Tools", + has_intermediate_output=True, inputs=[ IO.Image.Input("image"), IO.BoundingBox.Input("crop_region", component="ImageCrop"), diff --git a/comfy_extras/nodes_painter.py b/comfy_extras/nodes_painter.py index b9ecdf5ea..e104c8480 100644 --- a/comfy_extras/nodes_painter.py +++ b/comfy_extras/nodes_painter.py @@ -30,6 +30,7 @@ class PainterNode(io.ComfyNode): node_id="Painter", display_name="Painter", category="image", + has_intermediate_output=True, inputs=[ io.Image.Input( "image", diff --git a/execution.py b/execution.py index 1a6c3429c..19a864541 100644 --- a/execution.py +++ b/execution.py @@ -411,6 +411,14 @@ def format_value(x): else: return str(x) +def _send_cached_ui(server, node_id, display_node_id, cached, prompt_id, ui_outputs): + if server.client_id is None: + return + cached_ui = cached.ui or {} + server.send_sync("executed", { "node": node_id, "display_node": display_node_id, "output": cached_ui.get("output", None), "prompt_id": prompt_id }, server.client_id) + if cached.ui is not None: + ui_outputs[node_id] = cached.ui + async def execute(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_outputs): unique_id = current_item real_node_id = dynprompt.get_real_node_id(unique_id) @@ -421,11 +429,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed, class_def = nodes.NODE_CLASS_MAPPINGS[class_type] cached = await caches.outputs.get(unique_id) if cached is not None: - if server.client_id is not None: - cached_ui = cached.ui or {} - server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": cached_ui.get("output",None), "prompt_id": prompt_id }, server.client_id) - if cached.ui is not None: - ui_outputs[unique_id] = cached.ui + _send_cached_ui(server, unique_id, display_node_id, cached, prompt_id, ui_outputs) get_progress_state().finish_progress(unique_id) execution_list.cache_update(unique_id, cached) return (ExecutionResult.SUCCESS, None, None) @@ -748,6 +752,16 @@ class PromptExecutor: for node_id in list(execute_outputs): execution_list.add_node(node_id) + # Resend cached UI for intermediate output nodes that are not in the execution list. + for node_id in list(prompt.keys()): + if node_id in execution_list.pendingNodes: + continue + if not execution_list.is_intermediate_output(node_id): + continue + cached = await self.caches.outputs.get(node_id) + if cached is not None: + _send_cached_ui(self.server, node_id, node_id, cached, prompt_id, ui_node_outputs) + while not execution_list.is_empty(): node_id, error, ex = await execution_list.stage_node_execution() if error is not None: diff --git a/server.py b/server.py index 173a28376..5b6cf2056 100644 --- a/server.py +++ b/server.py @@ -709,6 +709,8 @@ class PromptServer(): else: info['output_node'] = False + info['has_intermediate_output'] = getattr(obj_class, 'HAS_INTERMEDIATE_OUTPUT', False) + if hasattr(obj_class, 'CATEGORY'): info['category'] = obj_class.CATEGORY