Compare commits

..

3 Commits

24 changed files with 40 additions and 776 deletions

View File

@ -30,7 +30,7 @@ from enum import Enum
import logging
import comfy.model_management
import comfy.ops
ops = comfy.ops.manual_cast
ops = comfy.ops.disable_weight_init
def _seedvr2_temporal_slicing_min_size(temporal_size, temporal_overlap, temporal_scale=1):
@ -103,10 +103,11 @@ def tiled_vae(
storage_device = vae_model.device
result = None
count = None
def run_temporal_chunks(spatial_tile, model=vae_model):
t_chunk = spatial_tile.contiguous()
def run_temporal_chunks(spatial_tile, model=vae_model, device=storage_device):
device = torch.device(device)
t_chunk = spatial_tile.to(device=device, dtype=next(model.parameters()).dtype, non_blocking=True).contiguous()
old_device = getattr(model, "device", None)
model.device = t_chunk.device
model.device = device
old_slicing_min_size = getattr(model, slicing_attr, None)
if old_slicing_min_size is not None and slicing_min_size is not None:
if slicing_min_size <= 0:
@ -396,7 +397,7 @@ class Attention(nn.Module):
def causal_norm_wrapper(norm_layer: nn.Module, x: torch.Tensor) -> torch.Tensor:
input_dtype = x.dtype
if isinstance(norm_layer, (nn.LayerNorm, nn.RMSNorm)):
if isinstance(norm_layer, (ops.LayerNorm, ops.RMSNorm)):
if x.ndim == 4:
x = x.permute(0, 2, 3, 1)
x = norm_layer(x)
@ -407,14 +408,14 @@ def causal_norm_wrapper(norm_layer: nn.Module, x: torch.Tensor) -> torch.Tensor:
x = norm_layer(x)
x = x.permute(0, 4, 1, 2, 3)
return x.to(input_dtype)
if isinstance(norm_layer, (nn.GroupNorm, nn.BatchNorm2d, nn.SyncBatchNorm)):
if isinstance(norm_layer, (ops.GroupNorm, nn.BatchNorm2d, nn.SyncBatchNorm)):
if x.ndim <= 4:
return norm_layer(x).to(input_dtype)
if x.ndim == 5:
b, c, t, h, w = x.shape
x = x.transpose(1, 2).reshape(b * t, c, h, w)
memory_occupy = x.numel() * x.element_size() / 1024**3
if isinstance(norm_layer, nn.GroupNorm) and memory_occupy > get_norm_limit():
if isinstance(norm_layer, ops.GroupNorm) and memory_occupy > get_norm_limit():
num_chunks = min(BYTEDANCE_GN_CHUNKS_FP16 if x.element_size() == 2 else BYTEDANCE_GN_CHUNKS_FP32, norm_layer.num_groups)
if norm_layer.num_groups % num_chunks != 0:
raise ValueError(
@ -422,9 +423,9 @@ def causal_norm_wrapper(norm_layer: nn.Module, x: torch.Tensor) -> torch.Tensor:
)
num_groups_per_chunk = norm_layer.num_groups // num_chunks
weights = comfy.ops.cast_to_input(norm_layer.weight, x).chunk(num_chunks, dim=0)
biases = comfy.ops.cast_to_input(norm_layer.bias, x).chunk(num_chunks, dim=0)
x = list(x.chunk(num_chunks, dim=1))
weights = norm_layer.weight.chunk(num_chunks, dim=0)
biases = norm_layer.bias.chunk(num_chunks, dim=0)
for i, (w, bias) in enumerate(zip(weights, biases)):
x[i] = F.group_norm(x[i], num_groups_per_chunk, w, bias, norm_layer.eps)
x[i] = x[i].to(input_dtype)
@ -1458,6 +1459,7 @@ class VideoAutoencoderKLWrapper(VideoAutoencoderKL):
def _encode_with_raw_latent(self, x):
if x.ndim == 4:
x = x.unsqueeze(2)
x = x.to(dtype=next(self.parameters()).dtype)
self.device = x.device
p = super().encode(x)
z = p.squeeze(2)

View File

@ -48,7 +48,7 @@ try:
# older Triton lacks libdevice.rint on the HIP backend and hard-crashes the INT8 path.
if args.disable_triton_backend:
ck.registry.disable("triton")
elif args.enable_triton_backend: # or (torch.version.hip is not None and _rocm_kitchen_arch_supported()):
elif args.enable_triton_backend or (torch.version.hip is not None and _rocm_kitchen_arch_supported()):
try:
import triton
triton_version = tuple(int(v) for v in triton.__version__.split(".")[:2])

View File

@ -100,7 +100,6 @@ def _parse_cli_feature_flags() -> dict[str, Any]:
# Default server capabilities
_CORE_FEATURE_FLAGS: dict[str, Any] = {
"supports_preview_metadata": True,
"supports_node_failure_policy": True,
"supports_model_type_tags": True,
"max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes
"extension": {"manager": {"supports_v4": True}},

View File

@ -3,12 +3,11 @@ from typing import Type, Literal
import nodes
import asyncio
import inspect
from comfy_execution.graph_utils import is_link, ExecutionBlocker, ExecutionFailureBlocker
from comfy_execution.graph_utils import is_link, ExecutionBlocker
from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, InputTypeOptions
# NOTE: ExecutionBlocker code got moved to graph_utils.py to prevent torch being imported too soon during unit tests
ExecutionBlocker = ExecutionBlocker
ExecutionFailureBlocker = ExecutionFailureBlocker
class DependencyCycleError(Exception):
pass
@ -202,27 +201,19 @@ class ExecutionList(TopologicalSort):
self.staged_node_id = None
self.execution_cache = {}
self.execution_cache_listeners = {}
self.transient_cache = {}
def is_cached(self, node_id):
return node_id in self.transient_cache or self.output_cache.get_local(node_id) is not None
def _get_cache_value(self, node_id):
if node_id in self.transient_cache:
return self.transient_cache[node_id]
return self.output_cache.get_local(node_id)
return self.output_cache.get_local(node_id) is not None
def cache_link(self, from_node_id, to_node_id):
if to_node_id not in self.execution_cache:
self.execution_cache[to_node_id] = {}
self.execution_cache[to_node_id][from_node_id] = self._get_cache_value(from_node_id)
self.execution_cache[to_node_id][from_node_id] = self.output_cache.get_local(from_node_id)
if from_node_id not in self.execution_cache_listeners:
self.execution_cache_listeners[from_node_id] = set()
self.execution_cache_listeners[from_node_id].add(to_node_id)
def get_cache(self, from_node_id, to_node_id):
if from_node_id in self.transient_cache:
return self.transient_cache[from_node_id]
if to_node_id not in self.execution_cache:
return None
value = self.execution_cache[to_node_id].get(from_node_id)
@ -232,9 +223,7 @@ class ExecutionList(TopologicalSort):
self.output_cache.set_local(from_node_id, value)
return value
def cache_update(self, node_id, value, transient=False):
if transient:
self.transient_cache[node_id] = value
def cache_update(self, node_id, value):
if node_id in self.execution_cache_listeners:
for to_node_id in self.execution_cache_listeners[node_id]:
if to_node_id in self.execution_cache:

View File

@ -153,9 +153,3 @@ class ExecutionBlocker:
"""
def __init__(self, message):
self.message = message
class ExecutionFailureBlocker(ExecutionBlocker):
def __init__(self, node_id):
super().__init__(None)
self.node_id = node_id

View File

@ -204,10 +204,8 @@ def normalize_history_item(prompt_id: str, history_item: dict, include_outputs:
outputs_count, preview_output = get_outputs_summary(outputs)
execution_error = None
execution_errors = []
execution_start_time = None
execution_end_time = None
execution_success = None
was_interrupted = False
if status_info:
messages = status_info.get('messages', [])
@ -219,23 +217,10 @@ def normalize_history_item(prompt_id: str, history_item: dict, include_outputs:
execution_start_time = event_data.get('timestamp')
elif event_name in ('execution_success', 'execution_error', 'execution_interrupted'):
execution_end_time = event_data.get('timestamp')
if event_name == 'execution_success':
execution_success = event_data
elif event_name == 'execution_error':
if event_name == 'execution_error':
execution_error = event_data
elif event_name == 'execution_interrupted':
was_interrupted = True
elif event_name == 'execution_node_error':
execution_errors.append(event_data)
execution_summary = (status_info.get('execution_summary') if status_info else None) or {}
completion_status = execution_summary.get('completion_status')
if completion_status is None and execution_success is not None:
completion_status = execution_success.get('completion_status', 'success')
if completion_status is None and status_str == 'success':
completion_status = 'success'
has_errors = execution_summary.get('has_errors', bool(execution_errors)) if completion_status is not None else None
execution_error_count = execution_summary.get('execution_error_count', len(execution_errors)) if completion_status is not None else None
if status_str == 'success':
status = JobStatus.COMPLETED
@ -252,9 +237,6 @@ def normalize_history_item(prompt_id: str, history_item: dict, include_outputs:
'execution_start_time': execution_start_time,
'execution_end_time': execution_end_time,
'execution_error': execution_error,
'completion_status': completion_status,
'has_errors': has_errors,
'execution_error_count': execution_error_count,
'outputs_count': outputs_count,
'preview_output': preview_output,
'workflow_id': workflow_id,
@ -263,7 +245,6 @@ def normalize_history_item(prompt_id: str, history_item: dict, include_outputs:
if include_outputs:
job['outputs'] = normalize_outputs(outputs)
job['execution_status'] = status_info
job['execution_errors'] = execution_errors
job['workflow'] = {
'prompt': prompt,
'extra_data': extra_data,

View File

@ -17,7 +17,6 @@ class NodeState(Enum):
Running = "running"
Finished = "finished"
Error = "error"
Blocked = "blocked"
class NodeProgressState(TypedDict):
@ -302,25 +301,17 @@ class ProgressRegistry:
node_id, value, max_value, entry, self.prompt_id, image
)
def _finish_progress(self, node_id: str, state: NodeState) -> None:
def finish_progress(self, node_id: str) -> None:
"""Finish progress tracking for a node"""
entry = self.ensure_entry(node_id)
entry["state"] = state
entry["state"] = NodeState.Finished
entry["value"] = entry["max"]
# Notify all enabled handlers
for handler in self.handlers.values():
if handler.enabled:
handler.finish_handler(node_id, entry, self.prompt_id)
def finish_progress(self, node_id: str) -> None:
"""Finish progress tracking for a node"""
self._finish_progress(node_id, NodeState.Finished)
def error_progress(self, node_id: str) -> None:
self._finish_progress(node_id, NodeState.Error)
def block_progress(self, node_id: str) -> None:
self._finish_progress(node_id, NodeState.Blocked)
def reset_handlers(self) -> None:
"""Reset all handlers"""
for handler in self.handlers.values():

View File

@ -298,7 +298,6 @@ class PreviewAudio(IO.ComfyNode):
search_aliases=["play audio"],
display_name="Preview Audio",
category="audio",
description="Preview the audio without saving it to the ComfyUI output directory.",
inputs=[
IO.Audio.Input("audio"),
],

View File

@ -92,7 +92,6 @@ class Preview3D(IO.ComfyNode):
search_aliases=["view mesh", "3d viewer"],
display_name="Preview 3D & Animation",
category="3d",
description="Preview a 3D model file without saving it to the ComfyUI output directory.",
is_experimental=True,
is_output_node=True,
inputs=[
@ -137,7 +136,6 @@ class Preview3DAdvanced(IO.ComfyNode):
display_name="Preview 3D (Advanced)",
search_aliases=["preview 3d", "3d viewer", "view mesh", "frame 3d", "3d camera output"],
category="3d",
description="Preview a 3D model file without saving it to the ComfyUI output directory.",
is_experimental=True,
is_output_node=True,
inputs=[
@ -195,7 +193,6 @@ class PreviewGaussianSplat(IO.ComfyNode):
node_id="PreviewGaussianSplat",
display_name="Preview Splat",
category="3d",
description="Preview a gaussian splat 3D file without saving it to the ComfyUI output directory.",
is_experimental=True,
is_output_node=True,
search_aliases=[
@ -264,7 +261,6 @@ class PreviewPointCloud(IO.ComfyNode):
node_id="PreviewPointCloud",
display_name="Preview Point Cloud",
category="3d",
description="Preview a point cloud 3D file without saving it to the ComfyUI output directory.",
is_experimental=True,
is_output_node=True,
search_aliases=[

View File

@ -419,18 +419,17 @@ class MaskPreview(IO.ComfyNode):
search_aliases=["show mask", "view mask", "inspect mask", "debug mask"],
display_name="Preview Mask",
category="image/mask",
description="Preview the masks without saving them to the ComfyUI output directory.",
description="Saves the input images to your ComfyUI output directory.",
inputs=[
IO.Mask.Input("mask"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
outputs=[IO.Mask.Output(display_name="mask")]
)
@classmethod
def execute(cls, mask, filename_prefix="ComfyUI") -> IO.NodeOutput:
return IO.NodeOutput(mask, ui=UI.PreviewMask(mask))
return IO.NodeOutput(ui=UI.PreviewMask(mask))
class MaskExtension(ComfyExtension):

View File

@ -18,7 +18,6 @@ class PreviewAny():
CATEGORY = "utilities"
SEARCH_ALIASES = ["show output", "inspect", "debug", "print value", "show text"]
DESCRIPTION = "Preview any input value as text."
def main(self, source=None):
torch.set_printoptions(edgeitems=6)

View File

@ -10,10 +10,11 @@ class String(io.ComfyNode):
return io.Schema(
node_id="PrimitiveString",
search_aliases=["text", "string", "text box", "prompt"],
display_name="Text",
display_name="Text String (DEPRECATED)",
category="utilities/primitive",
inputs=[io.String.Input("value")],
outputs=[io.String.Output()]
outputs=[io.String.Output()],
is_deprecated=True
)
@classmethod
@ -27,7 +28,7 @@ class StringMultiline(io.ComfyNode):
return io.Schema(
node_id="PrimitiveStringMultiline",
search_aliases=["text", "string", "text multiline", "string multiline", "text box", "prompt"],
display_name="Text (Multiline)",
display_name="Input Text",
category="utilities/primitive",
essentials_category="Basics",
inputs=[io.String.Input("value", multiline=True)],

View File

@ -13,7 +13,7 @@ from typing_extensions import override
import folder_paths
from comfy.cli_args import args
from comfy_api.latest import ComfyExtension, IO, Types, UI
from comfy_api.latest import ComfyExtension, IO, Types
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None, unlit=False):
@ -406,164 +406,10 @@ class SaveGLB(IO.ComfyNode):
return IO.NodeOutput(ui={"3d": results})
def _save_file3d_to_output(model_3d: Types.File3D, filename_prefix: str) -> str:
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory()
)
ext = model_3d.format or "glb"
saved_filename = f"{filename}_{counter:05}.{ext}"
model_3d.save_to(os.path.join(full_output_folder, saved_filename))
return f"{subfolder}/{saved_filename}" if subfolder else saved_filename
def execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs) -> IO.NodeOutput:
model_file = _save_file3d_to_output(model_3d, filename_prefix)
camera_info_input = kwargs.get("camera_info", None)
camera_info = camera_info_input if camera_info_input is not None else viewport_state['camera_info']
model_3d_info_input = kwargs.get("model_3d_info", None)
model_3d_info = model_3d_info_input if model_3d_info_input is not None else viewport_state.get('model_3d_info', [])
return IO.NodeOutput(
model_3d,
model_3d_info,
camera_info,
width,
height,
ui=UI.PreviewUI3DAdvanced(model_file, camera_info, model_3d_info),
)
class Save3DAdvanced(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="Save3DAdvanced",
display_name="Save 3D (Advanced)",
search_aliases=["save 3d", "export 3d model", "save mesh advanced"],
category="3d",
is_experimental=True,
is_output_node=True,
inputs=[
IO.MultiType.Input(
"model_3d",
types=[
IO.File3DGLB,
IO.File3DGLTF,
IO.File3DFBX,
IO.File3DOBJ,
IO.File3DSTL,
IO.File3DUSDZ,
IO.File3DAny,
],
tooltip="3D model file from an upstream 3D node.",
),
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
IO.Load3D.Input("viewport_state"),
IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True),
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
IO.Int.Input("width", default=1024, min=1, max=4096, step=1),
IO.Int.Input("height", default=1024, min=1, max=4096, step=1),
],
outputs=[
IO.File3DAny.Output(display_name="model_3d"),
IO.Load3DModelInfo.Output(display_name="model_3d_info"),
IO.Load3DCamera.Output(display_name="camera_info"),
IO.Int.Output(display_name="width"),
IO.Int.Output(display_name="height"),
],
)
@classmethod
def execute(cls, model_3d: Types.File3D, viewport_state, width: int, height: int, filename_prefix: str, **kwargs) -> IO.NodeOutput:
return execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs)
class SaveGaussianSplat(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SaveGaussianSplat",
display_name="Save Splat",
search_aliases=["save splat", "save gaussian splat", "export gaussian", "export splat"],
category="3d",
is_experimental=True,
is_output_node=True,
inputs=[
IO.MultiType.Input(
"model_3d",
types=[
IO.File3DSplatAny,
IO.File3DPLY,
IO.File3DSPLAT,
IO.File3DSPZ,
IO.File3DKSPLAT,
],
tooltip="A gaussian splat 3D file.",
),
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
IO.Load3D.Input("viewport_state"),
IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True),
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
IO.Int.Input("width", default=1024, min=1, max=4096, step=1),
IO.Int.Input("height", default=1024, min=1, max=4096, step=1),
],
outputs=[
IO.File3DSplatAny.Output(display_name="model_3d"),
IO.Load3DModelInfo.Output(display_name="model_3d_info"),
IO.Load3DCamera.Output(display_name="camera_info"),
IO.Int.Output(display_name="width"),
IO.Int.Output(display_name="height"),
],
)
@classmethod
def execute(cls, model_3d: Types.File3D, viewport_state, width: int, height: int, filename_prefix: str, **kwargs) -> IO.NodeOutput:
return execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs)
class SavePointCloud(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SavePointCloud",
display_name="Save Point Cloud",
search_aliases=["save point cloud", "save pointcloud", "export point cloud"],
category="3d",
is_experimental=True,
is_output_node=True,
inputs=[
IO.MultiType.Input(
"model_3d",
types=[
IO.File3DPointCloudAny,
IO.File3DPLY,
],
tooltip="Point cloud file (.ply)",
),
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
IO.Load3D.Input("viewport_state"),
IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True),
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
IO.Int.Input("width", default=1024, min=1, max=4096, step=1),
IO.Int.Input("height", default=1024, min=1, max=4096, step=1),
],
outputs=[
IO.File3DPointCloudAny.Output(display_name="model_3d"),
IO.Load3DModelInfo.Output(display_name="model_3d_info"),
IO.Load3DCamera.Output(display_name="camera_info"),
IO.Int.Output(display_name="width"),
IO.Int.Output(display_name="height"),
],
)
@classmethod
def execute(cls, model_3d: Types.File3D, viewport_state, width: int, height: int, filename_prefix: str, **kwargs) -> IO.NodeOutput:
return execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs)
class Save3DExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [SaveGLB, Save3DAdvanced, SaveGaussianSplat, SavePointCloud]
return [SaveGLB]
async def comfy_entrypoint() -> Save3DExtension:

View File

@ -32,13 +32,9 @@ from comfy_execution.caching import (
RAM_CACHE_LARGE_INTERMEDIATE,
)
from comfy_execution.graph import (
DependencyCycleError,
DynamicPrompt,
ExecutionBlocker,
ExecutionFailureBlocker,
ExecutionList,
NodeInputError,
NodeNotFoundError,
get_input_info,
)
from comfy_execution.graph_utils import GraphBuilder, is_link
@ -55,16 +51,6 @@ class ExecutionResult(Enum):
SUCCESS = 0
FAILURE = 1
PENDING = 2
BLOCKED = 3
NODE_FAILURE_POLICY_FAIL_FAST = "fail_fast"
NODE_FAILURE_POLICY_CONTINUE_INDEPENDENT = "continue_independent"
NODE_FAILURE_POLICIES = frozenset({
NODE_FAILURE_POLICY_FAIL_FAST,
NODE_FAILURE_POLICY_CONTINUE_INDEPENDENT,
})
NODE_FAILURE_POLICY_EXTRA_DATA_KEY = "_node_failure_policy"
class DuplicateNodeError(Exception):
pass
@ -118,38 +104,6 @@ class CacheEntry(NamedTuple):
outputs: list
def _failure_blocker_state(value):
if isinstance(value, ExecutionFailureBlocker):
return True, False
if isinstance(value, dict):
values = value.values()
elif isinstance(value, (list, tuple)):
values = value
else:
return False, True
has_failure_blocker = False
has_normal_value = False
for child in values:
child_failure, child_normal = _failure_blocker_state(child)
has_failure_blocker = has_failure_blocker or child_failure
has_normal_value = has_normal_value or child_normal
if has_failure_blocker and has_normal_value:
break
return has_failure_blocker, has_normal_value
def _is_recoverable_node_failure(ex):
if isinstance(ex, (
comfy.model_management.InterruptProcessingException,
DependencyCycleError,
NodeInputError,
NodeNotFoundError,
)):
return False
return not comfy.model_management.is_oom(ex)
class CacheType(Enum):
CLASSIC = 0
LRU = 1
@ -198,7 +152,7 @@ class CacheSet:
}
return result
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_comfy_org", "api_key_comfy_org", NODE_FAILURE_POLICY_EXTRA_DATA_KEY)
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_comfy_org", "api_key_comfy_org")
def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}):
is_v3 = issubclass(class_def, _ComfyNodeInternal)
@ -495,8 +449,6 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
return (ExecutionResult.SUCCESS, None, None)
input_data_all = None
failure_blocked_invocations = 0
successful_invocations = 0
try:
if unique_id in pending_async_nodes:
results = []
@ -566,9 +518,6 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
return (ExecutionResult.PENDING, None, None)
def execution_block_cb(block):
nonlocal failure_blocked_invocations
if isinstance(block, ExecutionFailureBlocker):
failure_blocked_invocations += 1
if block.message is not None:
mes = {
"prompt_id": prompt_id,
@ -587,8 +536,6 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
else:
return block
def pre_execute_cb(call_index):
nonlocal successful_invocations
successful_invocations += 1
# TODO - How to handle this with async functions without contextvars (which requires Python 3.12)?
GraphBuilder.set_default_prefix(unique_id, call_index, 0)
@ -664,14 +611,8 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
return (ExecutionResult.PENDING, None, None)
cache_entry = CacheEntry(ui=ui_outputs.get(unique_id), outputs=output_data)
if extra_data.get(NODE_FAILURE_POLICY_EXTRA_DATA_KEY) == NODE_FAILURE_POLICY_CONTINUE_INDEPENDENT:
has_failure_blocker, has_normal_output = _failure_blocker_state(output_data)
else:
has_failure_blocker, has_normal_output = False, True
failure_tainted = has_failure_blocker or failure_blocked_invocations > 0
execution_list.cache_update(unique_id, cache_entry, transient=failure_tainted)
if not failure_tainted:
await caches.outputs.set(unique_id, cache_entry)
execution_list.cache_update(unique_id, cache_entry)
await caches.outputs.set(unique_id, cache_entry)
except comfy.model_management.InterruptProcessingException as iex:
logging.info("Processing interrupted")
@ -713,12 +654,6 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
return (ExecutionResult.FAILURE, error_details, ex)
fully_failure_blocked = failure_blocked_invocations > 0 and successful_invocations == 0
fully_failure_blocked = fully_failure_blocked or (has_failure_blocker and not has_normal_output)
if fully_failure_blocked:
get_progress_state().block_progress(unique_id)
return (ExecutionResult.BLOCKED, None, None)
get_progress_state().finish_progress(unique_id)
executed.add(unique_id)
@ -735,7 +670,6 @@ class PromptExecutor:
self.caches = CacheSet(cache_type=self.cache_type, cache_args=self.cache_args)
self.status_messages = []
self.success = True
self.execution_summary = {}
def add_message(self, event, data: dict, broadcast: bool):
data = {
@ -774,21 +708,6 @@ class PromptExecutor:
}
self.add_message("execution_error", mes, broadcast=False)
def handle_node_execution_error(self, prompt_id, prompt, current_outputs, executed, error):
node_id = error["node_id"]
mes = {
"prompt_id": prompt_id,
"node_id": node_id,
"node_type": prompt[node_id]["class_type"],
"executed": list(executed),
"exception_message": error["exception_message"],
"exception_type": error["exception_type"],
"traceback": error["traceback"],
"current_inputs": error["current_inputs"],
"current_outputs": list(current_outputs),
}
self.add_message("execution_node_error", mes, broadcast=False)
def _notify_prompt_lifecycle(self, event: str, prompt_id: str):
if not _has_cache_providers():
return
@ -809,8 +728,6 @@ class PromptExecutor:
set_preview_method(extra_data.get("preview_method"))
nodes.interrupt_processing(False)
self.success = True
self.execution_summary = {}
if "client_id" in extra_data:
self.server.client_id = extra_data["client_id"]
@ -855,59 +772,24 @@ class PromptExecutor:
executed = set()
execution_list = ExecutionList(dynamic_prompt, self.caches.outputs)
current_outputs = self.caches.outputs.all_node_ids()
output_targets = set(execute_outputs)
failed_node_ids = set()
blocked_node_ids = set()
blocked_output_node_ids = set()
successful_output_node_ids = set()
node_failures = []
continue_independent = extra_data.get(
NODE_FAILURE_POLICY_EXTRA_DATA_KEY,
NODE_FAILURE_POLICY_FAIL_FAST,
) == NODE_FAILURE_POLICY_CONTINUE_INDEPENDENT
for node_id in list(execute_outputs):
execution_list.add_node(node_id)
while not execution_list.is_empty():
node_id, error, ex = await execution_list.stage_node_execution()
if error is not None:
self.success = False
self.handle_execution_error(prompt_id, dynamic_prompt.original_prompt, current_outputs, executed, error, ex)
break
assert node_id is not None, "Node ID should not be None at this point"
result, error, ex = await execute(self.server, dynamic_prompt, self.caches, node_id, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_node_outputs)
self.success = result != ExecutionResult.FAILURE
if result == ExecutionResult.FAILURE:
if continue_independent and _is_recoverable_node_failure(ex):
self.handle_node_execution_error(prompt_id, dynamic_prompt.original_prompt, current_outputs, executed, error)
real_node_id = error["node_id"]
failed_node_ids.add(real_node_id)
node_failures.append((error, ex))
blocker = ExecutionFailureBlocker(real_node_id)
class_type = dynamic_prompt.get_node(node_id)["class_type"]
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
cache_entry = CacheEntry(
ui=None,
outputs=[[blocker] for _ in class_def.RETURN_TYPES],
)
execution_list.cache_update(node_id, cache_entry, transient=True)
get_progress_state().error_progress(node_id)
execution_list.complete_node_execution()
else:
self.success = False
self.handle_execution_error(prompt_id, dynamic_prompt.original_prompt, current_outputs, executed, error, ex)
break
self.handle_execution_error(prompt_id, dynamic_prompt.original_prompt, current_outputs, executed, error, ex)
break
elif result == ExecutionResult.PENDING:
execution_list.unstage_node_execution()
elif result == ExecutionResult.BLOCKED:
real_node_id = dynamic_prompt.get_real_node_id(node_id)
blocked_node_ids.add(real_node_id)
if node_id in output_targets:
blocked_output_node_ids.add(real_node_id)
execution_list.complete_node_execution()
else: # result == ExecutionResult.SUCCESS:
if node_id in output_targets:
successful_output_node_ids.add(dynamic_prompt.get_real_node_id(node_id))
execution_list.complete_node_execution()
if self.cache_type == CacheType.RAM_PRESSURE:
@ -935,41 +817,7 @@ class PromptExecutor:
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)
if node_failures:
self.execution_summary = {
"has_errors": True,
"execution_error_count": len(node_failures),
"failed_node_ids": sorted(failed_node_ids)[:100],
"blocked_node_ids": sorted(blocked_node_ids)[:100],
"blocked_output_node_ids": sorted(blocked_output_node_ids)[:100],
"successful_output_node_ids": sorted(successful_output_node_ids)[:100],
}
if successful_output_node_ids:
self.execution_summary["completion_status"] = "partial_success"
self.add_message(
"execution_success",
{"prompt_id": prompt_id, **self.execution_summary},
broadcast=False,
)
else:
self.success = False
last_error, last_ex = node_failures[-1]
self.handle_execution_error(
prompt_id,
dynamic_prompt.original_prompt,
current_outputs,
executed,
last_error,
last_ex,
)
else:
self.execution_summary = {
"completion_status": "success",
"has_errors": False,
"execution_error_count": 0,
}
self.add_message("execution_success", { "prompt_id": prompt_id }, broadcast=False)
self.add_message("execution_success", { "prompt_id": prompt_id }, broadcast=False)
ui_outputs = {}
meta_outputs = {}
@ -1427,7 +1275,6 @@ class PromptQueue:
status_str: Literal['success', 'error']
completed: bool
messages: List[str]
execution_summary: Optional[dict] = None
def task_done(self, item_id, history_result,
status: Optional['PromptQueue.ExecutionStatus'], process_item=None):

View File

@ -366,8 +366,7 @@ def prompt_worker(q, server_instance):
status=execution.PromptQueue.ExecutionStatus(
status_str='success' if e.success else 'error',
completed=e.success,
messages=e.status_messages,
execution_summary=e.execution_summary), process_item=remove_sensitive)
messages=e.status_messages), process_item=remove_sensitive)
if server_instance.client_id is not None:
server_instance.send_sync("executing", {"node": None, "prompt_id": prompt_id}, server_instance.client_id)

View File

@ -1709,7 +1709,6 @@ class PreviewImage(SaveImage):
self.compress_level = 1
SEARCH_ALIASES = ["preview", "preview image", "show image", "view image", "display image", "image viewer"]
DESCRIPTION = "Preview the images without saving them to the ComfyUI output directory."
@classmethod
def INPUT_TYPES(s):

View File

@ -922,13 +922,6 @@ components:
number:
description: Priority number for the queue (lower numbers have higher priority)
type: number
node_failure_policy:
default: fail_fast
description: Controls whether a runtime node failure terminates the prompt or only blocks dependent nodes
enum:
- fail_fast
- continue_independent
type: string
partial_execution_targets:
description: List of node names to execute
items:

View File

@ -1097,19 +1097,6 @@ class PromptServer():
if "partial_execution_targets" in json_data:
partial_execution_targets = json_data["partial_execution_targets"]
node_failure_policy = json_data.get(
"node_failure_policy",
execution.NODE_FAILURE_POLICY_FAIL_FAST,
)
if not isinstance(node_failure_policy, str) or node_failure_policy not in execution.NODE_FAILURE_POLICIES:
error = {
"type": "invalid_node_failure_policy",
"message": "node_failure_policy must be 'fail_fast' or 'continue_independent'",
"details": f"Invalid node_failure_policy: {node_failure_policy!r}",
"extra_info": {},
}
return web.json_response({"error": error, "node_errors": {}}, status=400)
self.node_replace_manager.apply_replacements(prompt)
valid = await execution.validate_prompt(prompt_id, prompt, partial_execution_targets)
@ -1117,10 +1104,6 @@ class PromptServer():
if "extra_data" in json_data:
extra_data = json_data["extra_data"]
extra_data.pop(execution.NODE_FAILURE_POLICY_EXTRA_DATA_KEY, None)
if node_failure_policy == execution.NODE_FAILURE_POLICY_CONTINUE_INDEPENDENT:
extra_data[execution.NODE_FAILURE_POLICY_EXTRA_DATA_KEY] = node_failure_policy
if "client_id" in json_data:
extra_data["client_id"] = json_data["client_id"]

View File

@ -1,5 +1,4 @@
import torch
import torch.nn as nn
from comfy.cli_args import args as cli_args
@ -49,31 +48,3 @@ def test_seedvr2_vae_decode_memory_covers_full_frame_lab_transfer():
assert estimate == 101 * 960 * 1280 * 160
assert estimate > 15 * 1024 ** 3
assert estimate > old_estimate * 100
def test_seedvr2_vae_encode_preserves_compute_dtype(monkeypatch):
wrapper = seedvr_vae.VideoAutoencoderKLWrapper.__new__(seedvr_vae.VideoAutoencoderKLWrapper)
nn.Module.__init__(wrapper)
wrapper._dummy = nn.Parameter(torch.empty(1, dtype=torch.float16))
input_dtype = None
def encode(self, x):
nonlocal input_dtype
input_dtype = x.dtype
return x
monkeypatch.setattr(seedvr_vae.VideoAutoencoderKL, "encode", encode)
x = torch.zeros((1, 3, 1, 8, 8), dtype=torch.float32)
wrapper._encode_with_raw_latent(x)
assert input_dtype == torch.float32
def test_seedvr2_vae_ops_cast_weights_to_compute_dtype():
attention = seedvr_vae.Attention(query_dim=4, heads=1, dim_head=4).to(torch.float16)
hidden_states = torch.zeros((1, 2, 4), dtype=torch.float32)
output = attention(hidden_states)
assert output.dtype == torch.float32

View File

@ -122,31 +122,6 @@ def test_tiled_vae_encode_uses_tensor_return_without_indexing():
assert tuple(out.shape) == (2, _LATENT_CHANNELS, 1, 8, 8)
def test_tiled_vae_preserves_compute_dtype_with_different_parameter_dtype():
class DummyVAE(nn.Module):
spatial_downsample_factor = 8
temporal_downsample_factor = 4
slicing_sample_min_size = 8
def __init__(self):
super().__init__()
self.device = torch.device("cpu")
self._dummy = nn.Parameter(torch.zeros(1, dtype=torch.float16))
self.input_dtype = None
def encode(self, t_chunk):
self.input_dtype = t_chunk.dtype
b, _, _, h, w = t_chunk.shape
return torch.ones((b, _LATENT_CHANNELS, 1, h // 8, w // 8), dtype=t_chunk.dtype)
vae = DummyVAE()
x = torch.zeros((1, 3, 1, 64, 64), dtype=torch.float32)
tiled_vae(x, vae, tile_size=(64, 64), tile_overlap=(16, 16), encode=True)
assert vae.input_dtype == torch.float32
def test_tiled_vae_preserves_input_dtype_on_single_tile():
class FloatOutputVAEModel(torch.nn.Module):
def __init__(self):

View File

@ -281,41 +281,6 @@ class TestAsyncNodes:
# Verify the sync error was caught even though async was running
assert 'prompt_id' in e.args[0]
def test_async_sibling_completes_after_error(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestAsyncError", value=image.out(0), error_after=0.05)
sleep_node = g.node("TestSleep", value=image.out(0), seconds=0.1)
g.node("PreviewImage", images=error_node.out(0))
successful_output = g.node("SaveImage", images=sleep_node.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.did_run(error_node)
assert result.did_run(sleep_node)
assert result.was_executed(successful_output)
assert len(result.get_images(successful_output)) == 1
assert result.execution_success['completion_status'] == 'partial_success'
assert result.node_errors[0]['node_id'] == error_node.id
def test_async_sibling_completes_after_multiple_errors(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error1 = g.node("TestAsyncError", value=image.out(0), error_after=0.02)
error2 = g.node("TestAsyncError", value=image.out(0), error_after=0.04)
sleep_node = g.node("TestSleep", value=image.out(0), seconds=0.06)
g.node("PreviewImage", images=error1.out(0))
g.node("PreviewImage", images=error2.out(0))
successful_output = g.node("SaveImage", images=sleep_node.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert {error['node_id'] for error in result.node_errors} == {error1.id, error2.id}
assert result.did_run(sleep_node)
assert result.was_executed(successful_output)
assert result.execution_success['completion_status'] == 'partial_success'
assert result.execution_success['execution_error_count'] == 2
# Edge Cases
def test_async_with_execution_blocker(self, client: ComfyClient, builder: GraphBuilder):

View File

@ -13,35 +13,8 @@ import uuid
import urllib.request
import urllib.parse
import urllib.error
from comfy_execution.graph import DynamicPrompt, ExecutionList
from comfy_execution.graph_utils import GraphBuilder, Node
def test_execution_list_transient_cache_is_prompt_scoped():
class OutputCache:
def __init__(self):
self.values = {}
self.set_calls = []
def get_local(self, node_id):
return self.values.get(node_id)
def set_local(self, node_id, value):
self.set_calls.append((node_id, value))
self.values[node_id] = value
output_cache = OutputCache()
execution_list = ExecutionList(DynamicPrompt({}), output_cache)
failure_entry = object()
execution_list.cache_update("failed", failure_entry, transient=True)
execution_list.cache_link("failed", "late_consumer")
assert execution_list.is_cached("failed")
assert execution_list.get_cache("failed", "late_consumer") is failure_entry
assert output_cache.set_calls == []
assert not ExecutionList(DynamicPrompt({}), output_cache).is_cached("failed")
def run_warmup(client, prefix="warmup"):
"""Run a simple workflow to warm up the server."""
warmup_g = GraphBuilder(prefix=prefix)
@ -55,9 +28,6 @@ class RunResult:
self.runs: Dict[str,bool] = {}
self.cached: Dict[str,bool] = {}
self.prompt_id: str = prompt_id
self.node_errors = []
self.execution_success = None
self.run_counts: Dict[str, int] = {}
def get_output(self, node: Node):
return self.outputs.get(node.id, None)
@ -96,12 +66,10 @@ class ComfyClient:
ws.connect("ws://{}/ws?clientId={}".format(self.server_address, self.client_id))
self.ws = ws
def queue_prompt(self, prompt, partial_execution_targets=None, node_failure_policy=None):
def queue_prompt(self, prompt, partial_execution_targets=None):
p = {"prompt": prompt, "client_id": self.client_id}
if partial_execution_targets is not None:
p["partial_execution_targets"] = partial_execution_targets
if node_failure_policy is not None:
p["node_failure_policy"] = node_failure_policy
data = json.dumps(p).encode('utf-8')
req = urllib.request.Request("http://{}/prompt".format(self.server_address), data=data)
return json.loads(urllib.request.urlopen(req).read())
@ -165,13 +133,13 @@ class ComfyClient:
def set_test_name(self, name):
self.test_name = name
def run(self, graph, partial_execution_targets=None, node_failure_policy=None):
def run(self, graph, partial_execution_targets=None):
prompt = graph.finalize()
for node in graph.nodes.values():
if node.class_type == 'SaveImage':
node.inputs['filename_prefix'] = self.test_name
prompt_id = self.queue_prompt(prompt, partial_execution_targets, node_failure_policy)['prompt_id']
prompt_id = self.queue_prompt(prompt, partial_execution_targets)['prompt_id']
result = RunResult(prompt_id)
while True:
out = self.ws.recv()
@ -184,15 +152,8 @@ class ComfyClient:
if data['node'] is None:
break
result.runs[data['node']] = True
result.run_counts[data['node']] = result.run_counts.get(data['node'], 0) + 1
elif message['type'] == 'execution_error':
raise Exception(message['data'])
elif message['type'] == 'execution_node_error':
if message['data']['prompt_id'] == prompt_id:
result.node_errors.append(message['data'])
elif message['type'] == 'execution_success':
if message['data']['prompt_id'] == prompt_id:
result.execution_success = message['data']
elif message['type'] == 'execution_cached':
if message['data']['prompt_id'] == prompt_id:
cached_nodes = message['data'].get('nodes', [])
@ -344,122 +305,6 @@ class TestExecution:
except Exception as e:
assert 'prompt_id' in e.args[0], f"Did not get back a proper error message: {e}"
def test_continue_independent_after_error(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
blocked_output = g.node("PreviewImage", images=error_node.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.did_run(error_node)
assert result.was_executed(successful_output)
assert len(result.get_images(successful_output)) == 1
assert len(result.node_errors) == 1
assert result.node_errors[0]['node_id'] == error_node.id
assert result.execution_success['completion_status'] == 'partial_success'
assert result.execution_success['has_errors'] is True
assert result.execution_success['execution_error_count'] == 1
assert blocked_output.id in result.execution_success['blocked_output_node_ids']
assert successful_output.id in result.execution_success['successful_output_node_ids']
history = client.get_history(result.prompt_id)[result.prompt_id]
assert '_node_failure_policy' not in history['prompt'][3]
retry = client.run(g, node_failure_policy="continue_independent")
assert retry.did_run(error_node), "Failed nodes must be retried on a new prompt"
assert len(retry.node_errors) == 1
def test_continue_independent_reuses_failed_node_for_late_lazy_link(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
mask = g.node("StubMask", value=0.0, height=32, width=32, batch_size=1)
unused_image = g.node("StubImage", content="WHITE", height=32, width=32, batch_size=1)
lazy_mix = g.node("TestLazyMixImages", image1=error_node.out(0), image2=unused_image.out(0), mask=mask.out(0))
g.node("PreviewImage", images=lazy_mix.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.run_counts[error_node.id] == 1
assert lazy_mix.id in result.execution_success['blocked_node_ids']
assert successful_output.id in result.execution_success['successful_output_node_ids']
assert result.execution_success['completion_status'] == 'partial_success'
def test_continue_independent_handles_mixed_dynamic_results(self, client: ComfyClient, builder: GraphBuilder):
g = builder
zero = g.node("StubInt", value=0)
one = g.node("StubInt", value=1)
values = g.node("TestMakeListNode", value1=zero.out(0), value2=one.out(0))
mixed = g.node("TestMixedExpansionFailure", value=values.out(0))
output = g.node("PreviewImage", images=mixed.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert len(result.node_errors) == 1
assert result.node_errors[0]['node_id'] == mixed.id
assert len(result.get_images(output)) == 1
assert output.id in result.execution_success['successful_output_node_ids']
assert output.id not in result.execution_success['blocked_output_node_ids']
assert result.execution_success['completion_status'] == 'partial_success'
retry = client.run(g, node_failure_policy="continue_independent")
assert retry.did_run(mixed), "Failure-tainted dynamic parents must not be reused from cache"
assert len(retry.node_errors) == 1
def test_continue_independent_keeps_oom_terminal(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
oom_node = g.node("TestOOMError", value=image.out(0))
g.node("PreviewImage", images=oom_node.out(0))
g.node("SaveImage", images=image.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="continue_independent")
assert exc_info.value.args[0]['node_id'] == oom_node.id
assert exc_info.value.args[0]['exception_type'] == 'torch.OutOfMemoryError'
def test_continue_independent_accepts_cached_output(self, client: ComfyClient, builder: GraphBuilder, server):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
client.run(g, partial_execution_targets=[successful_output.id])
result = client.run(g, node_failure_policy="continue_independent")
if server["should_cache_results"]:
assert result.was_cached(successful_output)
assert len(result.node_errors) == 1
assert result.execution_success['completion_status'] == 'partial_success'
assert successful_output.id in result.execution_success['successful_output_node_ids']
def test_continue_independent_fails_when_no_output_survives(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="continue_independent")
assert exc_info.value.args[0]['node_id'] == error_node.id
def test_invalid_node_failure_policy(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
g.node("PreviewImage", images=image.out(0))
with pytest.raises(urllib.error.HTTPError) as exc_info:
client.queue_prompt(g.finalize(), node_failure_policy="continue_everything")
assert exc_info.value.code == 400
@pytest.mark.parametrize("test_value, expect_error", [
(5, True),
("foo", True),

View File

@ -500,76 +500,6 @@ class TestNormalizeHistoryItem:
'extra_data': {'create_time': 1234567890, 'client_id': 'abc'},
}
def test_missing_status(self):
history_item = {
'prompt': (
5,
'prompt-without-status',
{'nodes': {}},
{'create_time': 100},
[],
),
'status': None,
'outputs': {},
}
job = normalize_history_item('prompt-without-status', history_item)
assert job['status'] == 'completed'
assert 'completion_status' not in job
def test_partial_success_metadata_and_errors(self):
node_error = {
'prompt_id': 'prompt-partial',
'node_id': '2',
'node_type': 'TestSyncError',
'exception_message': 'failed',
'exception_type': 'RuntimeError',
'traceback': [],
'current_inputs': {},
'current_outputs': [],
'timestamp': 200,
}
history_item = {
'prompt': (
5,
'prompt-partial',
{'nodes': {}},
{'create_time': 100},
['3', '4'],
),
'status': {
'status_str': 'success',
'completed': True,
'execution_summary': {
'completion_status': 'partial_success',
'has_errors': True,
'execution_error_count': 1,
},
'messages': [
('execution_start', {'prompt_id': 'prompt-partial', 'timestamp': 150}),
('execution_node_error', node_error),
('execution_success', {
'prompt_id': 'prompt-partial',
'completion_status': 'partial_success',
'has_errors': True,
'execution_error_count': 1,
'timestamp': 300,
}),
],
},
'outputs': {'4': {'images': [{'filename': 'survived.png'}]}},
}
job = normalize_history_item('prompt-partial', history_item, include_outputs=True)
assert job['status'] == 'completed'
assert job['completion_status'] == 'partial_success'
assert job['has_errors'] is True
assert job['execution_error_count'] == 1
assert job['execution_errors'] == [node_error]
assert job['outputs']['4']['images'] == [{'filename': 'survived.png'}]
def test_include_outputs_normalizes_3d_strings(self):
"""Detail view should transform string 3D filenames into file output dicts."""
history_item = {

View File

@ -135,41 +135,6 @@ class TestSyncError(ComfyNodeABC):
raise RuntimeError("Intentional sync execution error for testing")
class TestOOMError(ComfyNodeABC):
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": (IO.ANY, {})}}
RETURN_TYPES = (IO.ANY,)
FUNCTION = "oom_error"
CATEGORY = "experimental/async"
def oom_error(self, value):
raise torch.OutOfMemoryError("Intentional out of memory error for testing")
class TestMixedExpansionFailure(ComfyNodeABC):
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT", {})}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "expand"
CATEGORY = "experimental/async"
def expand(self, value):
image = torch.zeros([1, 32, 32, 3])
if value == 0:
return (image,)
graph = GraphBuilder()
error = graph.node("TestSyncError", value=image)
return {
"result": (error.out(0),),
"expand": graph.finalize(),
}
class TestAsyncLazyCheck(ComfyNodeABC):
"""Test node with async check_lazy_status."""
@ -357,8 +322,6 @@ ASYNC_TEST_NODE_CLASS_MAPPINGS = {
"TestAsyncValidationError": TestAsyncValidationError,
"TestAsyncTimeout": TestAsyncTimeout,
"TestSyncError": TestSyncError,
"TestOOMError": TestOOMError,
"TestMixedExpansionFailure": TestMixedExpansionFailure,
"TestAsyncLazyCheck": TestAsyncLazyCheck,
"TestDynamicAsyncGeneration": TestDynamicAsyncGeneration,
"TestAsyncResourceUser": TestAsyncResourceUser,
@ -372,8 +335,6 @@ ASYNC_TEST_NODE_DISPLAY_NAME_MAPPINGS = {
"TestAsyncValidationError": "Test Async Validation Error",
"TestAsyncTimeout": "Test Async Timeout",
"TestSyncError": "Test Sync Error",
"TestOOMError": "Test OOM Error",
"TestMixedExpansionFailure": "Test Mixed Expansion Failure",
"TestAsyncLazyCheck": "Test Async Lazy Check",
"TestDynamicAsyncGeneration": "Test Dynamic Async Generation",
"TestAsyncResourceUser": "Test Async Resource User",