mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-24 10:03:55 +08:00
DynamicOutputs.ByKey: schema, type resolver, and execution wiring
Introduce a per-prompt finalized output list for V3 nodes whose active
output shape depends on a literal input. First slice:
* Schema:
- io.DynamicOutputs.ByKey(id, selector, options=[io.DynamicOutputs.Option(...)])
- Validates: selector input exists; output ids unique within and across
static + dynamic branches; no overlap between branches.
- get_v1_info() advertises dynamic groups separately via NodeInfoV1.dynamic_outputs;
static RETURN_TYPES / OUTPUT_IS_LIST stay as the always-present prefix.
* NodeOutput:
- New NodeOutput.from_named({id: value}) for dynamic nodes; engine reorders
against the finalized active output list. Mixing positional+named raises.
* Execution:
- execute() computes FinalizedOutputs once per attempt and threads it
through initial / pending-async / pending-subgraph output paths.
- merge_result_data / get_output_data / get_output_from_returns size
blocker tuples and concat behaviour against the finalized list.
- validate_inputs rejects positional links whose slot index is outside
the upstream node's active output count as 'bad_linked_output'.
* TypeResolver:
- resolve_output_type and is_output_list consult the finalized output
list when present; finalized_output_count() exposes the active count
for validation.
Covered by new unit tests under tests-unit/comfy_api_test and
tests-unit/execution_test.
Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
116
execution.py
116
execution.py
@@ -314,15 +314,56 @@ async def _async_map_node_over_list(prompt_id, unique_id, obj, input_data_all, f
|
||||
return results
|
||||
|
||||
|
||||
def merge_result_data(results, obj):
|
||||
def _expected_output_count(obj, finalized_outputs=None):
|
||||
"""Size results / blocker tuples by the finalized output list when present."""
|
||||
if finalized_outputs is not None:
|
||||
return len(finalized_outputs)
|
||||
return len(getattr(obj, "RETURN_TYPES", ()))
|
||||
|
||||
|
||||
def _normalize_named_result(node_output, finalized_outputs):
|
||||
"""Convert a ``NodeOutput.from_named({...})`` payload into an ordered tuple.
|
||||
|
||||
Strict by design: an active output id missing from the payload is an
|
||||
error; an unknown id is an error. Use the finalized output list as the
|
||||
single source of truth for ordering.
|
||||
"""
|
||||
if finalized_outputs is None:
|
||||
raise Exception(
|
||||
"NodeOutput(named=...) is only supported for V3 nodes with a finalized "
|
||||
"output schema (e.g. nodes using DynamicOutputs)."
|
||||
)
|
||||
expected_ids = finalized_outputs.output_ids
|
||||
payload = node_output.named
|
||||
missing = [oid for oid in expected_ids if oid not in payload]
|
||||
unknown = [oid for oid in payload if oid not in expected_ids]
|
||||
if missing or unknown:
|
||||
raise Exception(
|
||||
f"NodeOutput(named=...) ids do not match active outputs: "
|
||||
f"missing={missing}, unknown={unknown}, expected={expected_ids}"
|
||||
)
|
||||
return tuple(payload[oid] for oid in expected_ids)
|
||||
|
||||
|
||||
def merge_result_data(results, obj, finalized_outputs=None):
|
||||
# check which outputs need concatenating
|
||||
output = []
|
||||
output_is_list = [False] * len(results[0])
|
||||
if hasattr(obj, "OUTPUT_IS_LIST"):
|
||||
output_is_list = obj.OUTPUT_IS_LIST
|
||||
expected_count = _expected_output_count(obj, finalized_outputs)
|
||||
if finalized_outputs is not None:
|
||||
output_is_list = finalized_outputs.output_is_list
|
||||
else:
|
||||
output_is_list = [False] * len(results[0])
|
||||
if hasattr(obj, "OUTPUT_IS_LIST"):
|
||||
output_is_list = obj.OUTPUT_IS_LIST
|
||||
|
||||
for r in results:
|
||||
if len(r) != expected_count:
|
||||
raise Exception(
|
||||
f"Node returned {len(r)} outputs but active schema has {expected_count}"
|
||||
)
|
||||
|
||||
# merge node execution results
|
||||
for i, is_list in zip(range(len(results[0])), output_is_list):
|
||||
for i, is_list in zip(range(expected_count), output_is_list):
|
||||
if is_list:
|
||||
value = []
|
||||
for o in results:
|
||||
@@ -335,19 +376,20 @@ def merge_result_data(results, obj):
|
||||
output.append([o[i] for o in results])
|
||||
return output
|
||||
|
||||
async def get_output_data(prompt_id, unique_id, obj, input_data_all, execution_block_cb=None, pre_execute_cb=None, v3_data=None):
|
||||
async def get_output_data(prompt_id, unique_id, obj, input_data_all, execution_block_cb=None, pre_execute_cb=None, v3_data=None, finalized_outputs=None):
|
||||
return_values = await _async_map_node_over_list(prompt_id, unique_id, obj, input_data_all, obj.FUNCTION, allow_interrupt=True, execution_block_cb=execution_block_cb, pre_execute_cb=pre_execute_cb, v3_data=v3_data)
|
||||
has_pending_task = any(isinstance(r, asyncio.Task) and not r.done() for r in return_values)
|
||||
if has_pending_task:
|
||||
return return_values, {}, False, has_pending_task
|
||||
output, ui, has_subgraph = get_output_from_returns(return_values, obj)
|
||||
output, ui, has_subgraph = get_output_from_returns(return_values, obj, finalized_outputs=finalized_outputs)
|
||||
return output, ui, has_subgraph, False
|
||||
|
||||
def get_output_from_returns(return_values, obj):
|
||||
def get_output_from_returns(return_values, obj, finalized_outputs=None):
|
||||
results = []
|
||||
uis = []
|
||||
subgraph_results = []
|
||||
has_subgraph = False
|
||||
expected_count = _expected_output_count(obj, finalized_outputs)
|
||||
for i in range(len(return_values)):
|
||||
r = return_values[i]
|
||||
if isinstance(r, dict):
|
||||
@@ -359,12 +401,12 @@ def get_output_from_returns(return_values, obj):
|
||||
new_graph = r['expand']
|
||||
result = r.get("result", None)
|
||||
if isinstance(result, ExecutionBlocker):
|
||||
result = tuple([result] * len(obj.RETURN_TYPES))
|
||||
result = tuple([result] * expected_count)
|
||||
subgraph_results.append((new_graph, result))
|
||||
elif 'result' in r:
|
||||
result = r.get("result", None)
|
||||
if isinstance(result, ExecutionBlocker):
|
||||
result = tuple([result] * len(obj.RETURN_TYPES))
|
||||
result = tuple([result] * expected_count)
|
||||
results.append(result)
|
||||
subgraph_results.append((None, result))
|
||||
elif isinstance(r, _NodeOutputInternal):
|
||||
@@ -374,29 +416,36 @@ def get_output_from_returns(return_values, obj):
|
||||
uis.append(r.ui)
|
||||
else:
|
||||
uis.append(r.ui.as_dict())
|
||||
# Named NodeOutput → reorder against the finalized output list before
|
||||
# downstream code treats this as a fixed-shape tuple.
|
||||
named_result = (
|
||||
_normalize_named_result(r, finalized_outputs)
|
||||
if getattr(r, "named", None) is not None
|
||||
else None
|
||||
)
|
||||
if r.expand is not None:
|
||||
has_subgraph = True
|
||||
new_graph = r.expand
|
||||
result = r.result
|
||||
result = named_result if named_result is not None else r.result
|
||||
if r.block_execution is not None:
|
||||
result = tuple([ExecutionBlocker(r.block_execution)] * len(obj.RETURN_TYPES))
|
||||
result = tuple([ExecutionBlocker(r.block_execution)] * expected_count)
|
||||
subgraph_results.append((new_graph, result))
|
||||
elif r.result is not None:
|
||||
result = r.result
|
||||
elif named_result is not None or r.result is not None:
|
||||
result = named_result if named_result is not None else r.result
|
||||
if r.block_execution is not None:
|
||||
result = tuple([ExecutionBlocker(r.block_execution)] * len(obj.RETURN_TYPES))
|
||||
result = tuple([ExecutionBlocker(r.block_execution)] * expected_count)
|
||||
results.append(result)
|
||||
subgraph_results.append((None, result))
|
||||
else:
|
||||
if isinstance(r, ExecutionBlocker):
|
||||
r = tuple([r] * len(obj.RETURN_TYPES))
|
||||
r = tuple([r] * expected_count)
|
||||
results.append(r)
|
||||
subgraph_results.append((None, r))
|
||||
|
||||
if has_subgraph:
|
||||
output = subgraph_results
|
||||
elif len(results) > 0:
|
||||
output = merge_result_data(results, obj)
|
||||
output = merge_result_data(results, obj, finalized_outputs=finalized_outputs)
|
||||
else:
|
||||
output = []
|
||||
ui = dict()
|
||||
@@ -444,6 +493,15 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
execution_list.cache_update(unique_id, cached)
|
||||
return (ExecutionResult.SUCCESS, None, None)
|
||||
|
||||
# Finalize the active output list for this prompt (no-op for static V3 / V1
|
||||
# nodes). Computed once per execute() call so the three output-shaping paths
|
||||
# below — initial, pending async resume, pending subgraph resume — all agree.
|
||||
finalized_outputs = None
|
||||
if issubclass(class_def, _ComfyNodeInternal):
|
||||
schema = class_def.GET_SCHEMA()
|
||||
if any(isinstance(o, _io.DynamicOutputs.ByKey) for o in schema.outputs):
|
||||
finalized_outputs = _io.get_finalized_class_outputs(schema.outputs, inputs)
|
||||
|
||||
input_data_all = None
|
||||
try:
|
||||
if unique_id in pending_async_nodes:
|
||||
@@ -459,7 +517,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
else:
|
||||
results.append(r)
|
||||
del pending_async_nodes[unique_id]
|
||||
output_data, output_ui, has_subgraph = get_output_from_returns(results, class_def)
|
||||
output_data, output_ui, has_subgraph = get_output_from_returns(results, class_def, finalized_outputs=finalized_outputs)
|
||||
elif unique_id in pending_subgraph_results:
|
||||
cached_results = pending_subgraph_results[unique_id]
|
||||
resolved_outputs = []
|
||||
@@ -478,7 +536,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
else:
|
||||
resolved_output.append(r)
|
||||
resolved_outputs.append(tuple(resolved_output))
|
||||
output_data = merge_result_data(resolved_outputs, class_def)
|
||||
output_data = merge_result_data(resolved_outputs, class_def, finalized_outputs=finalized_outputs)
|
||||
output_ui = []
|
||||
del pending_subgraph_results[unique_id]
|
||||
has_subgraph = False
|
||||
@@ -536,7 +594,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
GraphBuilder.set_default_prefix(unique_id, call_index, 0)
|
||||
|
||||
try:
|
||||
output_data, output_ui, has_subgraph, has_pending_tasks = await get_output_data(prompt_id, unique_id, obj, input_data_all, execution_block_cb=execution_block_cb, pre_execute_cb=pre_execute_cb, v3_data=v3_data)
|
||||
output_data, output_ui, has_subgraph, has_pending_tasks = await get_output_data(prompt_id, unique_id, obj, input_data_all, execution_block_cb=execution_block_cb, pre_execute_cb=pre_execute_cb, v3_data=v3_data, finalized_outputs=finalized_outputs)
|
||||
finally:
|
||||
if comfy.memory_management.aimdo_enabled:
|
||||
if args.verbose == "DEBUG":
|
||||
@@ -921,6 +979,24 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ
|
||||
continue
|
||||
|
||||
o_id = val[0]
|
||||
# Reject links pointing at slot indices outside the upstream node's
|
||||
# active output list (e.g. stale link after a DynamicOutputs branch
|
||||
# change). Reports the active count for clearer diagnostics.
|
||||
upstream_output_count = type_resolver.finalized_output_count(o_id)
|
||||
if not isinstance(val[1], int) or isinstance(val[1], bool) or val[1] < 0 or val[1] >= upstream_output_count:
|
||||
error = {
|
||||
"type": "bad_linked_output",
|
||||
"message": "Linked output slot does not exist on the source node",
|
||||
"details": f"{x}, linked_node({o_id}), output_index({val[1]}), active_output_count({upstream_output_count})",
|
||||
"extra_info": {
|
||||
"input_name": x,
|
||||
"linked_node": val,
|
||||
"output_index": val[1],
|
||||
"active_output_count": upstream_output_count,
|
||||
}
|
||||
}
|
||||
errors.append(error)
|
||||
continue
|
||||
# Walks MatchType/template chains so API workflows without
|
||||
# frontend-injected type metadata get the same answer as the UI.
|
||||
received_type = type_resolver.resolve_output_type(o_id, val[1])
|
||||
|
||||
Reference in New Issue
Block a user