Add has_intermediate_output flag for nodes with interactive UI

This commit is contained in:
Terry Jia 2026-03-26 09:42:04 -04:00
parent 1dc64f3526
commit bb2d1c824b
6 changed files with 54 additions and 5 deletions

View File

@ -1373,6 +1373,7 @@ class NodeInfoV1:
price_badge: dict | None = None price_badge: dict | None = None
search_aliases: list[str]=None search_aliases: list[str]=None
essentials_category: str=None essentials_category: str=None
has_intermediate_output: bool=None
@dataclass @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.""" """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 essentials_category: str | None = None
"""Optional category for the Essentials tab. Path-based like category field (e.g., 'Basic', 'Image Tools/Editing').""" """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): def validate(self):
'''Validate the schema: '''Validate the schema:
@ -1595,6 +1606,7 @@ class Schema:
category=self.category, category=self.category,
description=self.description, description=self.description,
output_node=self.is_output_node, output_node=self.is_output_node,
has_intermediate_output=self.has_intermediate_output,
deprecated=self.is_deprecated, deprecated=self.is_deprecated,
experimental=self.is_experimental, experimental=self.is_experimental,
dev_only=self.is_dev_only, dev_only=self.is_dev_only,
@ -1886,6 +1898,14 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal):
cls.GET_SCHEMA() cls.GET_SCHEMA()
return cls._OUTPUT_NODE 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 _INPUT_IS_LIST = None
@final @final
@classproperty @classproperty
@ -1978,6 +1998,8 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal):
cls._API_NODE = schema.is_api_node cls._API_NODE = schema.is_api_node
if cls._OUTPUT_NODE is None: if cls._OUTPUT_NODE is None:
cls._OUTPUT_NODE = schema.is_output_node 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: if cls._INPUT_IS_LIST is None:
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:

View File

@ -813,6 +813,7 @@ class GLSLShader(io.ComfyNode):
"u_resolution (vec2) is always available." "u_resolution (vec2) is always available."
), ),
is_experimental=True, is_experimental=True,
has_intermediate_output=True,
inputs=[ inputs=[
io.String.Input( io.String.Input(
"fragment_shader", "fragment_shader",

View File

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

View File

@ -30,6 +30,7 @@ class PainterNode(io.ComfyNode):
node_id="Painter", node_id="Painter",
display_name="Painter", display_name="Painter",
category="image", category="image",
has_intermediate_output=True,
inputs=[ inputs=[
io.Image.Input( io.Image.Input(
"image", "image",

View File

@ -411,6 +411,19 @@ def format_value(x):
else: else:
return str(x) 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): 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 unique_id = current_item
real_node_id = dynprompt.get_real_node_id(unique_id) 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] class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
cached = await caches.outputs.get(unique_id) cached = await caches.outputs.get(unique_id)
if cached is not None: if cached is not None:
if server.client_id is not None: _send_cached_ui(server, unique_id, display_node_id, cached, prompt_id, ui_outputs)
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
get_progress_state().finish_progress(unique_id) get_progress_state().finish_progress(unique_id)
execution_list.cache_update(unique_id, cached) execution_list.cache_update(unique_id, cached)
return (ExecutionResult.SUCCESS, None, None) return (ExecutionResult.SUCCESS, None, None)
@ -767,6 +776,16 @@ class PromptExecutor:
self.caches.outputs.poll(ram_headroom=self.cache_args["ram"]) self.caches.outputs.poll(ram_headroom=self.cache_args["ram"])
else: else:
# Only execute when the while-loop ends without break # 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) self.add_message("execution_success", { "prompt_id": prompt_id }, broadcast=False)
ui_outputs = {} ui_outputs = {}

View File

@ -709,6 +709,11 @@ class PromptServer():
else: else:
info['output_node'] = False 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'): if hasattr(obj_class, 'CATEGORY'):
info['category'] = obj_class.CATEGORY info['category'] = obj_class.CATEGORY