From bb2d1c824b1748c5d6d876fec5b8ff009c866f80 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Thu, 26 Mar 2026 09:42:04 -0400 Subject: [PATCH] Add `has_intermediate_output` flag for nodes with interactive UI --- comfy_api/latest/_io.py | 22 ++++++++++++++++++++++ comfy_extras/nodes_glsl.py | 1 + comfy_extras/nodes_images.py | 1 + comfy_extras/nodes_painter.py | 1 + execution.py | 29 ++++++++++++++++++++++++----- server.py | 5 +++++ 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 1cbc8ed26..fdeffea2d 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1373,6 +1373,7 @@ class NodeInfoV1: price_badge: dict | None = None search_aliases: list[str]=None essentials_category: str=None + has_intermediate_output: bool=None @dataclass @@ -1496,6 +1497,16 @@ class Schema: """When True, all inputs from the prompt will be passed to the node as kwargs, even if not defined in the schema.""" essentials_category: str | None = None """Optional category for the Essentials tab. Path-based like category field (e.g., 'Basic', 'Image Tools/Editing').""" + 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). + """ def validate(self): '''Validate the schema: @@ -1595,6 +1606,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, @@ -1886,6 +1898,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 @@ -1978,6 +1998,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_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 0e4d957ff..ea7420a73 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -813,6 +813,7 @@ class GLSLShader(io.ComfyNode): "u_resolution (vec2) is always available." ), is_experimental=True, + 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..43c3c648d 100644 --- a/execution.py +++ b/execution.py @@ -411,6 +411,19 @@ def format_value(x): else: return str(x) +def _is_intermediate_output(dynprompt, node_id): + class_type = dynprompt.get_node(node_id)["class_type"] + class_def = nodes.NODE_CLASS_MAPPINGS[class_type] + return getattr(class_def, 'HAS_INTERMEDIATE_OUTPUT', False) + +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 +434,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) @@ -767,6 +776,16 @@ class PromptExecutor: self.caches.outputs.poll(ram_headroom=self.cache_args["ram"]) else: # Only execute when the while-loop ends without break + # Send cached UI for intermediate output nodes that weren't executed + for node_id in dynamic_prompt.all_node_ids(): + if node_id in executed: + continue + if not _is_intermediate_output(dynamic_prompt, node_id): + continue + cached = await self.caches.outputs.get(node_id) + if cached is not None: + display_node_id = dynamic_prompt.get_display_node_id(node_id) + _send_cached_ui(self.server, node_id, display_node_id, cached, prompt_id, ui_node_outputs) self.add_message("execution_success", { "prompt_id": prompt_id }, broadcast=False) ui_outputs = {} diff --git a/server.py b/server.py index 173a28376..27b14825e 100644 --- a/server.py +++ b/server.py @@ -709,6 +709,11 @@ class PromptServer(): else: info['output_node'] = False + if hasattr(obj_class, 'HAS_INTERMEDIATE_OUTPUT') and obj_class.HAS_INTERMEDIATE_OUTPUT == True: + info['has_intermediate_output'] = True + else: + info['has_intermediate_output'] = False + if hasattr(obj_class, 'CATEGORY'): info['category'] = obj_class.CATEGORY